diff --git a/.changes/unreleased/added-campaign-resource.yaml b/.changes/unreleased/added-campaign-resource.yaml new file mode 100644 index 00000000..1b85199a --- /dev/null +++ b/.changes/unreleased/added-campaign-resource.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Added `opslevel_campaign` resource for managing OpsLevel campaigns as code, including scheduling support. Also added `opslevel_campaign` and `opslevel_campaigns` data sources. diff --git a/docs/data-sources/campaign.md b/docs/data-sources/campaign.md new file mode 100644 index 00000000..69525420 --- /dev/null +++ b/docs/data-sources/campaign.md @@ -0,0 +1,41 @@ +--- +page_title: "opslevel_campaign Data Source - terraform-provider-opslevel" +subcategory: "" +description: |- + Campaign data source +--- + +# opslevel_campaign (Data Source) + +Campaign data source + +## Example Usage + +```terraform +data "opslevel_campaign" "example" { + identifier = "Z2lkOi8vb3BzbGV2ZWwvQ2FtcGFpZ24vMTIz" +} + +output "campaign_name" { + value = data.opslevel_campaign.example.name +} +``` + + +## Schema + +### Required + +- `identifier` (String) The id of the campaign to find. + +### Read-Only + +- `filter_id` (String) The ID of the filter applied to this campaign. +- `html_url` (String) The URL to the campaign in the OpsLevel UI. +- `id` (String) The ID of the campaign. +- `name` (String) The name of the campaign. +- `owner_id` (String) The ID of the team that owns this campaign. +- `project_brief` (String) The raw project brief of the campaign (Markdown). +- `start_date` (String) The start date of the campaign. +- `status` (String) The current status of the campaign. +- `target_date` (String) The target end date of the campaign. diff --git a/docs/data-sources/campaigns.md b/docs/data-sources/campaigns.md new file mode 100644 index 00000000..509fee73 --- /dev/null +++ b/docs/data-sources/campaigns.md @@ -0,0 +1,48 @@ +--- +page_title: "opslevel_campaigns Data Source - terraform-provider-opslevel" +subcategory: "" +description: |- + Campaign data sources +--- + +# opslevel_campaigns (Data Source) + +Campaign data sources — lists all campaigns, optionally filtered by status. + +## Example Usage + +```terraform +data "opslevel_campaigns" "active" { + status = "in_progress" +} + +output "active_campaign_names" { + value = [for c in data.opslevel_campaigns.active.campaigns : c.name] +} +``` + + +## Schema + +### Optional + +- `status` (String) Filter campaigns by status (draft, scheduled, in_progress, delayed, ended). Defaults to in_progress. + +### Read-Only + +- `campaigns` (List of Object) List of Campaign data sources (see [below for nested schema](#nestedatt--campaigns)) + + +### Nested Schema for `campaigns` + +Read-Only: + +- `filter_id` (String) The ID of the filter applied to this campaign. +- `html_url` (String) The URL to the campaign in the OpsLevel UI. +- `id` (String) The ID of the campaign. +- `name` (String) The name of the campaign. +- `owner_id` (String) The ID of the team that owns this campaign. +- `project_brief` (String) The raw project brief of the campaign (Markdown). +- `start_date` (String) The start date of the campaign. +- `status` (String) The current status of the campaign. +- `target_date` (String) The target end date of the campaign. diff --git a/docs/resources/campaign.md b/docs/resources/campaign.md new file mode 100644 index 00000000..02c213a4 --- /dev/null +++ b/docs/resources/campaign.md @@ -0,0 +1,125 @@ +--- +page_title: "opslevel_campaign Resource - terraform-provider-opslevel" +subcategory: "" +description: |- + Campaign Resource +--- + +# opslevel_campaign (Resource) + +Campaign Resource + +Manages an OpsLevel campaign. Campaigns allow you to roll out changes across your engineering organization that require orchestrated effort across multiple teams. + +## Example Usage + +### Draft campaign (no schedule) + +```terraform +data "opslevel_team" "platform" { + alias = "platform" +} + +data "opslevel_filter" "tier_1" { + filter { + field = "name" + value = "Tier 1 Services" + } +} + +resource "opslevel_campaign" "upgrade_rails" { + name = "Upgrade to Rails 7" + owner_id = data.opslevel_team.platform.id + filter_id = data.opslevel_filter.tier_1.id + + project_brief = <<-EOT + ## Overview + All Rails services must upgrade to Rails 7 by end of Q3. + + ## What you need to do + 1. Update your Gemfile to target Rails 7 + 2. Run the Rails upgrade checklist + 3. Verify all tests pass + EOT +} +``` + +### Campaign with checks + +```terraform +resource "opslevel_campaign" "soc2_compliance" { + name = "SOC2 Compliance Rollout" + owner_id = data.opslevel_team.platform.id + filter_id = data.opslevel_filter.tier_1.id + + check_ids = [ + opslevel_check_custom_event.secret_rotation.id, + opslevel_check_custom_event.dependency_scanning.id, + ] + + project_brief = <<-EOT + All Tier 1 services must pass SOC2 checks by end of Q3. + EOT +} +``` + +### Scheduled campaign + +```terraform +resource "opslevel_campaign" "python_upgrade" { + name = "Upgrade to Python 3.12" + owner_id = data.opslevel_team.platform.id + filter_id = data.opslevel_filter.tier_1.id + + start_date = "2026-07-01" + target_date = "2026-09-30" + + project_brief = <<-EOT + Upgrade all Python services to 3.12 for security and performance. + EOT +} +``` + +## Check Management + +The `check_ids` attribute accepts a list of rubric check IDs. On create, these checks are copied into the campaign. On update, the provider reconciles the list — adding new checks and removing stale ones to match the desired configuration. + +Terraform detects drift: if a check is removed from the campaign outside of Terraform (e.g. via the UI), the next `terraform plan` will show it as needing to be re-added. + +~> **Note:** The OpsLevel API copies checks into campaigns as separate instances with different IDs but the same name. The provider matches rubric checks to campaign checks by name. If two rubric checks share the same name, the provider may not be able to distinguish them, which can lead to incorrect removal. Ensure rubric check names are unique when using `check_ids`. + +## Schedule Management + +Setting both `start_date` and `target_date` schedules the campaign. Removing both fields unschedules it back to draft status. + +Both fields must be set together — setting only one will result in an error. + + +## Schema + +### Required + +- `name` (String) The name of the campaign. +- `owner_id` (String) The ID of the team that owns this campaign. + +### Optional + +- `check_ids` (List of String) List of rubric check IDs to associate with this campaign. On create, checks are copied into the campaign. On update, checks are added or removed to match the desired set. +- `filter_id` (String) The ID of the filter applied to this campaign. +- `project_brief` (String) The project brief of the campaign (Markdown). +- `start_date` (String) The start date of the campaign (YYYY-MM-DD). Setting both start_date and target_date schedules the campaign. +- `target_date` (String) The target end date of the campaign (YYYY-MM-DD). Setting both start_date and target_date schedules the campaign. + +### Read-Only + +- `html_url` (String) The URL to the campaign in the OpsLevel UI. +- `id` (String) The ID of the campaign. +- `status` (String) The current status of the campaign (draft, scheduled, in_progress, delayed, ended). + +## Import + +Import is supported using the following syntax: + +```shell +terraform import opslevel_campaign.example Z2lkOi8vb3BzbGV2ZWwvQ2FtcGFpZ24vMTIz +``` diff --git a/examples/resources/opslevel_campaign/import.sh b/examples/resources/opslevel_campaign/import.sh new file mode 100644 index 00000000..b6a50ef0 --- /dev/null +++ b/examples/resources/opslevel_campaign/import.sh @@ -0,0 +1 @@ +terraform import opslevel_campaign.example Z2lkOi8vb3BzbGV2ZWwvQ2FtcGFpZ24vMTIz diff --git a/examples/resources/opslevel_campaign/resource.tf b/examples/resources/opslevel_campaign/resource.tf new file mode 100644 index 00000000..66104c8b --- /dev/null +++ b/examples/resources/opslevel_campaign/resource.tf @@ -0,0 +1,29 @@ +data "opslevel_team" "platform" { + alias = "platform" +} + +data "opslevel_filter" "tier_1" { + filter { + field = "name" + value = "Tier 1 Services" + } +} + +resource "opslevel_campaign" "upgrade_rails" { + name = "Upgrade to Rails 7" + owner_id = data.opslevel_team.platform.id + filter_id = data.opslevel_filter.tier_1.id + + start_date = "2026-07-01" + target_date = "2026-09-30" + + project_brief = <<-EOT + ## Overview + All Rails services must upgrade to Rails 7 by end of Q3. + + ## What you need to do + 1. Update your Gemfile to target Rails 7 + 2. Run the Rails upgrade checklist + 3. Verify all tests pass + EOT +} diff --git a/go.mod b/go.mod index 2da3c95a..858fdf22 100644 --- a/go.mod +++ b/go.mod @@ -55,4 +55,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -// replace github.com/opslevel/opslevel-go/v2026 => ./submodules/opslevel-go +// TODO: Remove this replace directive before merging upstream. +// It points at the local submodule for fork development; once +// OpsLevel/opslevel-go merges the campaign CRUD PR (#611) and +// cuts a release, switch to the published version. +replace github.com/opslevel/opslevel-go/v2026 => ./submodules/opslevel-go diff --git a/go.sum b/go.sum index 394d4bbe..478765cb 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,6 @@ github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= github.com/opslevel/moredefaults v0.0.0-20240529152742-17d1318a3c12 h1:OQZ3W8kbyCcdS8QUWFTnZd6xtdkfhdckc7Paro7nXio= github.com/opslevel/moredefaults v0.0.0-20240529152742-17d1318a3c12/go.mod h1:g2GSXVP6LO+5+AIsnMRPN+BeV86OXuFRTX7HXCDtYeI= -github.com/opslevel/opslevel-go/v2026 v2026.3.6 h1:XdAmWIrzKYUTOHIHV42B5bVTBzU2j4OQzhDiaQ75m7I= -github.com/opslevel/opslevel-go/v2026 v2026.3.6/go.mod h1:FClwt6mxlVa2f4l+z/dUi5u8eYEiNJuSOWMhB6Y9JqI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/opslevel/datasource_opslevel_campaign.go b/opslevel/datasource_opslevel_campaign.go new file mode 100644 index 00000000..3bb1672c --- /dev/null +++ b/opslevel/datasource_opslevel_campaign.go @@ -0,0 +1,139 @@ +package opslevel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/opslevel/opslevel-go/v2026" +) + +var _ datasource.DataSourceWithConfigure = &CampaignDataSource{} + +func NewCampaignDataSource() datasource.DataSource { + return &CampaignDataSource{} +} + +type CampaignDataSource struct { + CommonDataSourceClient +} + +var campaignSchemaAttrs = map[string]schema.Attribute{ + "filter_id": schema.StringAttribute{ + Description: "The ID of the filter applied to this campaign.", + Computed: true, + }, + "html_url": schema.StringAttribute{ + Description: "The URL to the campaign in the OpsLevel UI.", + Computed: true, + }, + "id": schema.StringAttribute{ + Description: "The ID of the campaign.", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the campaign.", + Computed: true, + }, + "owner_id": schema.StringAttribute{ + Description: "The ID of the team that owns this campaign.", + Computed: true, + }, + "project_brief": schema.StringAttribute{ + Description: "The raw project brief of the campaign (Markdown).", + Computed: true, + }, + "start_date": schema.StringAttribute{ + Description: "The start date of the campaign.", + Computed: true, + }, + "status": schema.StringAttribute{ + Description: "The current status of the campaign.", + Computed: true, + }, + "target_date": schema.StringAttribute{ + Description: "The target end date of the campaign.", + Computed: true, + }, +} + +func CampaignAttributes(attrs map[string]schema.Attribute) map[string]schema.Attribute { + for key, value := range campaignSchemaAttrs { + attrs[key] = value + } + return attrs +} + +type campaignDataSourceModel struct { + FilterId types.String `tfsdk:"filter_id"` + HtmlUrl types.String `tfsdk:"html_url"` + Id types.String `tfsdk:"id"` + Identifier types.String `tfsdk:"identifier"` + Name types.String `tfsdk:"name"` + OwnerId types.String `tfsdk:"owner_id"` + ProjectBrief types.String `tfsdk:"project_brief"` + StartDate types.String `tfsdk:"start_date"` + Status types.String `tfsdk:"status"` + TargetDate types.String `tfsdk:"target_date"` +} + +func newCampaignDataSourceModel(campaign opslevel.Campaign, identifier string) campaignDataSourceModel { + model := campaignDataSourceModel{ + FilterId: ComputedStringValue(string(campaign.Filter.Id)), + HtmlUrl: ComputedStringValue(campaign.HtmlUrl), + Id: ComputedStringValue(string(campaign.Id)), + Identifier: ComputedStringValue(identifier), + Name: ComputedStringValue(campaign.Name), + OwnerId: ComputedStringValue(string(campaign.Owner.Id)), + ProjectBrief: ComputedStringValue(campaign.RawProjectBrief), + Status: ComputedStringValue(string(campaign.Status)), + } + if !campaign.StartDate.IsZero() { + model.StartDate = types.StringValue(campaign.StartDate.Format("2006-01-02")) + } else { + model.StartDate = types.StringNull() + } + if !campaign.TargetDate.IsZero() { + model.TargetDate = types.StringValue(campaign.TargetDate.Format("2006-01-02")) + } else { + model.TargetDate = types.StringNull() + } + return model +} + +func (d *CampaignDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_campaign" +} + +func (d *CampaignDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Campaign data source", + Attributes: CampaignAttributes(map[string]schema.Attribute{ + "identifier": schema.StringAttribute{ + Description: "The id of the campaign to find.", + Required: true, + }, + }), + } +} + +func (d *CampaignDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var configModel campaignDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + campaign, err := d.client.GetCampaign(opslevel.ID(configModel.Identifier.ValueString())) + if err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read campaign datasource, got error: %s", err)) + return + } + + stateModel := newCampaignDataSourceModel(*campaign, configModel.Identifier.ValueString()) + tflog.Trace(ctx, "read an OpsLevel Campaign data source") + resp.Diagnostics.Append(resp.State.Set(ctx, &stateModel)...) +} diff --git a/opslevel/datasource_opslevel_campaigns_all.go b/opslevel/datasource_opslevel_campaigns_all.go new file mode 100644 index 00000000..145012bb --- /dev/null +++ b/opslevel/datasource_opslevel_campaigns_all.go @@ -0,0 +1,117 @@ +package opslevel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/opslevel/opslevel-go/v2026" +) + +var _ datasource.DataSourceWithConfigure = &CampaignDataSourcesAll{} + +func NewCampaignDataSourcesAll() datasource.DataSource { + return &CampaignDataSourcesAll{} +} + +type CampaignDataSourcesAll struct { + CommonDataSourceClient +} + +type campaignListItemModel struct { + FilterId types.String `tfsdk:"filter_id"` + HtmlUrl types.String `tfsdk:"html_url"` + Id types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + OwnerId types.String `tfsdk:"owner_id"` + ProjectBrief types.String `tfsdk:"project_brief"` + StartDate types.String `tfsdk:"start_date"` + Status types.String `tfsdk:"status"` + TargetDate types.String `tfsdk:"target_date"` +} + +type campaignDataSourcesAllModel struct { + Status types.String `tfsdk:"status"` + Campaigns []campaignListItemModel `tfsdk:"campaigns"` +} + +func newCampaignListItemModels(campaigns []opslevel.Campaign) []campaignListItemModel { + models := make([]campaignListItemModel, 0, len(campaigns)) + for _, c := range campaigns { + m := campaignListItemModel{ + FilterId: OptionalStringValue(string(c.Filter.Id)), + HtmlUrl: ComputedStringValue(c.HtmlUrl), + Id: ComputedStringValue(string(c.Id)), + Name: ComputedStringValue(c.Name), + OwnerId: OptionalStringValue(string(c.Owner.Id)), + ProjectBrief: OptionalStringValue(c.RawProjectBrief), + Status: ComputedStringValue(string(c.Status)), + } + if !c.StartDate.IsZero() { + m.StartDate = types.StringValue(c.StartDate.Format("2006-01-02")) + } else { + m.StartDate = types.StringNull() + } + if !c.TargetDate.IsZero() { + m.TargetDate = types.StringValue(c.TargetDate.Format("2006-01-02")) + } else { + m.TargetDate = types.StringNull() + } + models = append(models, m) + } + return models +} + +func (d *CampaignDataSourcesAll) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_campaigns" +} + +func (d *CampaignDataSourcesAll) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Campaign data sources", + Attributes: map[string]schema.Attribute{ + "status": schema.StringAttribute{ + Description: "Filter campaigns by status (draft, scheduled, in_progress, delayed, ended). Defaults to in_progress.", + Optional: true, + }, + "campaigns": schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: campaignSchemaAttrs, + }, + Description: "List of Campaign data sources", + Computed: true, + }, + }, + } +} + +func (d *CampaignDataSourcesAll) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var configModel campaignDataSourcesAllModel + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var listVars *opslevel.ListCampaignsVariables + if !configModel.Status.IsNull() && !configModel.Status.IsUnknown() { + status := opslevel.CampaignStatusEnum(configModel.Status.ValueString()) + listVars = &opslevel.ListCampaignsVariables{Status: &status} + } + + campaigns, err := d.client.ListCampaigns(listVars) + if err != nil || campaigns == nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to list campaigns datasource, got error: %s", err)) + return + } + + stateModel := campaignDataSourcesAllModel{ + Status: configModel.Status, + Campaigns: newCampaignListItemModels(campaigns.Nodes), + } + + tflog.Trace(ctx, "read OpsLevel Campaigns data source") + resp.Diagnostics.Append(resp.State.Set(ctx, &stateModel)...) +} diff --git a/opslevel/provider.go b/opslevel/provider.go index 007fe495..9457be26 100644 --- a/opslevel/provider.go +++ b/opslevel/provider.go @@ -170,6 +170,7 @@ func (p *OpslevelProvider) Configure(ctx context.Context, req provider.Configure func (p *OpslevelProvider) Resources(context.Context) []func() resource.Resource { return []func() resource.Resource{ NewAliasResource, + NewCampaignResource, NewCheckAlertSourceUsageResource, NewCheckCodeIssueResource, NewCheckCustomEventResource, @@ -226,6 +227,8 @@ func (p *OpslevelProvider) Resources(context.Context) []func() resource.Resource func (p *OpslevelProvider) DataSources(context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ + NewCampaignDataSource, + NewCampaignDataSourcesAll, NewComponentTypeDataSourceSingle, NewComponentTypeDataSourceMulti, NewCategoryDataSource, diff --git a/opslevel/resource_opslevel_campaign.go b/opslevel/resource_opslevel_campaign.go new file mode 100644 index 00000000..dcc70979 --- /dev/null +++ b/opslevel/resource_opslevel_campaign.go @@ -0,0 +1,536 @@ +package opslevel + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/opslevel/opslevel-go/v2026" + "github.com/relvacode/iso8601" +) + +var _ resource.ResourceWithConfigure = &CampaignResource{} + +var _ resource.ResourceWithImportState = &CampaignResource{} + +var _ resource.ResourceWithValidateConfig = &CampaignResource{} + +func NewCampaignResource() resource.Resource { + return &CampaignResource{} +} + +type CampaignResource struct { + CommonResourceClient +} + +type CampaignResourceModel struct { + Id types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + OwnerId types.String `tfsdk:"owner_id"` + FilterId types.String `tfsdk:"filter_id"` + ProjectBrief types.String `tfsdk:"project_brief"` + CheckIds types.List `tfsdk:"check_ids"` + StartDate types.String `tfsdk:"start_date"` + TargetDate types.String `tfsdk:"target_date"` + Status types.String `tfsdk:"status"` + HtmlUrl types.String `tfsdk:"html_url"` +} + +func NewCampaignResourceModel(campaign opslevel.Campaign, givenModel CampaignResourceModel) CampaignResourceModel { + model := CampaignResourceModel{ + Id: ComputedStringValue(string(campaign.Id)), + Name: RequiredStringValue(campaign.Name), + OwnerId: OptionalStringValue(string(campaign.Owner.Id)), + FilterId: OptionalStringValue(string(campaign.Filter.Id)), + ProjectBrief: StringValueFromResourceAndModelField(campaign.RawProjectBrief, givenModel.ProjectBrief), + CheckIds: types.ListNull(types.StringType), + Status: ComputedStringValue(string(campaign.Status)), + HtmlUrl: ComputedStringValue(campaign.HtmlUrl), + } + + if !campaign.StartDate.IsZero() { + model.StartDate = types.StringValue(campaign.StartDate.Format("2006-01-02")) + } else { + model.StartDate = types.StringNull() + } + + if !campaign.TargetDate.IsZero() { + model.TargetDate = types.StringValue(campaign.TargetDate.Format("2006-01-02")) + } else { + model.TargetDate = types.StringNull() + } + + return model +} + +func (r *CampaignResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_campaign" +} + +func (r *CampaignResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Campaign Resource", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the campaign.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the campaign.", + Required: true, + }, + "owner_id": schema.StringAttribute{ + Description: "The ID of the team that owns this campaign.", + Required: true, + Validators: []validator.String{IdStringValidator()}, + }, + "filter_id": schema.StringAttribute{ + Description: "The ID of the filter applied to this campaign.", + Optional: true, + Validators: []validator.String{IdStringValidator()}, + }, + "project_brief": schema.StringAttribute{ + Description: "The project brief of the campaign (Markdown).", + Optional: true, + }, + "check_ids": schema.ListAttribute{ + Description: "List of rubric check IDs to associate with this campaign. On create, checks are copied into the campaign. On update, checks are added or removed to match the desired set.", + Optional: true, + Computed: true, + ElementType: types.StringType, + }, + "start_date": schema.StringAttribute{ + Description: "The start date of the campaign (YYYY-MM-DD). Setting both start_date and target_date schedules the campaign.", + Optional: true, + }, + "target_date": schema.StringAttribute{ + Description: "The target end date of the campaign (YYYY-MM-DD). Setting both start_date and target_date schedules the campaign.", + Optional: true, + }, + "status": schema.StringAttribute{ + Description: "The current status of the campaign (draft, scheduled, in_progress, delayed, ended).", + Computed: true, + }, + "html_url": schema.StringAttribute{ + Description: "The URL to the campaign in the OpsLevel UI.", + Computed: true, + }, + }, + } +} + +func (r *CampaignResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + var config CampaignResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + hasStart := !config.StartDate.IsNull() && !config.StartDate.IsUnknown() + hasTarget := !config.TargetDate.IsNull() && !config.TargetDate.IsUnknown() + if hasStart != hasTarget { + resp.Diagnostics.AddError( + "Invalid Campaign Schedule", + "Both start_date and target_date must be set together to schedule a campaign, or both must be omitted.", + ) + } +} + +func (r *CampaignResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + planModel := read[CampaignResourceModel](ctx, &resp.Diagnostics, req.Plan) + if resp.Diagnostics.HasError() { + return + } + + input := opslevel.CampaignCreateInput{ + Name: planModel.Name.ValueString(), + OwnerId: opslevel.ID(planModel.OwnerId.ValueString()), + FilterId: nullableID(planModel.FilterId.ValueStringPointer()), + } + if !planModel.ProjectBrief.IsNull() { + brief := planModel.ProjectBrief.ValueString() + input.ProjectBrief = &brief + } + + campaign, err := r.client.CreateCampaign(input) + if err != nil || campaign == nil { + title, detail := formatOpslevelError("create campaign", err) + resp.Diagnostics.AddError(title, detail) + return + } + + if !planModel.StartDate.IsNull() && !planModel.TargetDate.IsNull() { + startDate, sdErr := iso8601.ParseString(planModel.StartDate.ValueString() + "T00:00:00Z") + targetDate, tdErr := iso8601.ParseString(planModel.TargetDate.ValueString() + "T00:00:00Z") + if sdErr != nil || tdErr != nil { + resp.Diagnostics.AddError("invalid date", "start_date and target_date must be valid dates (YYYY-MM-DD)") + return + } + scheduled, err := r.client.ScheduleCampaign(opslevel.CampaignScheduleUpdateInput{ + Id: campaign.Id, + StartDate: iso8601.Time{Time: startDate}, + TargetDate: iso8601.Time{Time: targetDate}, + }) + if err != nil { + title, detail := formatOpslevelError("schedule campaign", err) + resp.Diagnostics.AddError(title, detail) + return + } + campaign = scheduled + } + + if !planModel.CheckIds.IsNull() && !planModel.CheckIds.IsUnknown() { + checkIds := extractCheckIds(ctx, &resp.Diagnostics, planModel.CheckIds) + if resp.Diagnostics.HasError() { + return + } + if len(checkIds) > 0 { + updated, err := r.client.CopyChecksToCampaign(opslevel.ChecksCopyToCampaignInput{ + CampaignId: campaign.Id, + CheckIds: checkIds, + }) + if err != nil { + title, detail := formatOpslevelError("copy checks to campaign", err) + resp.Diagnostics.AddError(title, detail) + return + } + campaign = updated + } + } + + createdModel := NewCampaignResourceModel(*campaign, planModel) + createdModel.CheckIds = planModel.CheckIds + tflog.Trace(ctx, "created a campaign resource") + resp.Diagnostics.Append(resp.State.Set(ctx, &createdModel)...) +} + +func (r *CampaignResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + stateModel := read[CampaignResourceModel](ctx, &resp.Diagnostics, req.State) + if resp.Diagnostics.HasError() { + return + } + + campaign, err := r.client.GetCampaign(opslevel.ID(stateModel.Id.ValueString())) + if err != nil || campaign == nil { + if (campaign == nil || campaign.Id == "") && opslevel.IsOpsLevelApiError(err) { + resp.State.RemoveResource(ctx) + return + } + title, detail := formatOpslevelError("read campaign", err) + resp.Diagnostics.AddError(title, detail) + return + } + + readModel := NewCampaignResourceModel(*campaign, stateModel) + readModel.CheckIds = r.readCampaignCheckIds(ctx, &resp.Diagnostics, campaign.Id, stateModel.CheckIds) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &readModel)...) +} + +func (r *CampaignResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + planModel := read[CampaignResourceModel](ctx, &resp.Diagnostics, req.Plan) + stateModel := read[CampaignResourceModel](ctx, &resp.Diagnostics, req.State) + if resp.Diagnostics.HasError() { + return + } + + campaignId := opslevel.ID(stateModel.Id.ValueString()) + + updateInput := opslevel.CampaignUpdateInput{ + Id: campaignId, + } + + nameVal := planModel.Name.ValueString() + updateInput.Name = &nameVal + + updateInput.OwnerId = opslevel.RefOf(opslevel.ID(planModel.OwnerId.ValueString())) + + updateInput.FilterId = nullableID(planModel.FilterId.ValueStringPointer()) + + if !planModel.ProjectBrief.IsNull() { + brief := planModel.ProjectBrief.ValueString() + updateInput.ProjectBrief = &brief + } + + campaign, err := r.client.UpdateCampaign(updateInput) + if err != nil { + title, detail := formatOpslevelError("update campaign", err) + resp.Diagnostics.AddError(title, detail) + return + } + + planHasDates := !planModel.StartDate.IsNull() && !planModel.TargetDate.IsNull() + stateHasDates := !stateModel.StartDate.IsNull() && !stateModel.TargetDate.IsNull() + + if planHasDates { + startDate, sdErr := iso8601.ParseString(planModel.StartDate.ValueString() + "T00:00:00Z") + targetDate, tdErr := iso8601.ParseString(planModel.TargetDate.ValueString() + "T00:00:00Z") + if sdErr != nil || tdErr != nil { + resp.Diagnostics.AddError("invalid date", "start_date and target_date must be valid dates (YYYY-MM-DD)") + return + } + scheduled, err := r.client.ScheduleCampaign(opslevel.CampaignScheduleUpdateInput{ + Id: campaignId, + StartDate: iso8601.Time{Time: startDate}, + TargetDate: iso8601.Time{Time: targetDate}, + }) + if err != nil { + title, detail := formatOpslevelError("schedule campaign", err) + resp.Diagnostics.AddError(title, detail) + return + } + campaign = scheduled + } else if stateHasDates && !planHasDates { + unscheduled, err := r.client.UnscheduleCampaign(campaignId) + if err != nil { + title, detail := formatOpslevelError("unschedule campaign", err) + resp.Diagnostics.AddError(title, detail) + return + } + campaign = unscheduled + } + + r.reconcileCampaignChecks(ctx, &resp.Diagnostics, campaignId, stateModel, planModel) + if resp.Diagnostics.HasError() { + return + } + + updatedModel := NewCampaignResourceModel(*campaign, planModel) + updatedModel.CheckIds = planModel.CheckIds + tflog.Trace(ctx, "updated a campaign resource") + resp.Diagnostics.Append(resp.State.Set(ctx, &updatedModel)...) +} + +func (r *CampaignResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + stateModel := read[CampaignResourceModel](ctx, &resp.Diagnostics, req.State) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteCampaign(opslevel.ID(stateModel.Id.ValueString())) + if err != nil { + title, detail := formatOpslevelError("delete campaign", err) + resp.Diagnostics.AddError(title, detail) + return + } + tflog.Trace(ctx, "deleted a campaign resource") +} + +func (r *CampaignResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +// readCampaignCheckIds queries the campaign's actual checks from the API and +// returns only the rubric check IDs (from priorCheckIds) whose corresponding +// campaign check still exists. This enables drift detection when checks are +// removed outside Terraform. +func (r *CampaignResource) readCampaignCheckIds( + ctx context.Context, + diags *diag.Diagnostics, + campaignId opslevel.ID, + priorCheckIds types.List, +) types.List { + if priorCheckIds.IsNull() || priorCheckIds.IsUnknown() { + return types.ListNull(types.StringType) + } + + var priorIds []string + diags.Append(priorCheckIds.ElementsAs(ctx, &priorIds, false)...) + if diags.HasError() { + return types.ListNull(types.StringType) + } + if len(priorIds) == 0 { + return priorCheckIds + } + + campaignChecks, err := r.client.ListCampaignChecks(campaignId) + if err != nil { + title, detail := formatOpslevelError("list campaign checks for read", err) + diags.AddError(title, detail) + return types.ListNull(types.StringType) + } + + campaignCheckNames := make(map[string]int, len(campaignChecks)) + for _, cc := range campaignChecks { + campaignCheckNames[cc.Name]++ + } + for name, count := range campaignCheckNames { + if count > 1 { + tflog.Warn(ctx, "multiple campaign checks share the same name; matching may be unreliable", + map[string]any{"check_name": name, "count": count}) + } + } + + var verified []string + for _, rubricID := range priorIds { + check, err := r.client.GetCheck(opslevel.ID(rubricID)) + if err != nil { + tflog.Warn(ctx, "could not look up rubric check during read, keeping in state", + map[string]any{"rubric_check_id": rubricID, "error": err.Error()}) + verified = append(verified, rubricID) + continue + } + if campaignCheckNames[check.Name] > 0 { + verified = append(verified, rubricID) + } else { + tflog.Info(ctx, "rubric check no longer present in campaign, removing from state", + map[string]any{"rubric_check_id": rubricID, "check_name": check.Name}) + } + } + + if len(verified) == 0 { + return types.ListValueMust(types.StringType, []attr.Value{}) + } + + vals := make([]attr.Value, len(verified)) + for i, id := range verified { + vals[i] = types.StringValue(id) + } + return types.ListValueMust(types.StringType, vals) +} + +// DiffCheckIds computes which IDs to add and remove given two sets of IDs. +func DiffCheckIds(stateIds, planIds map[string]bool) (toAdd []string, toRemove []string) { + for id := range planIds { + if !stateIds[id] { + toAdd = append(toAdd, id) + } + } + for id := range stateIds { + if !planIds[id] { + toRemove = append(toRemove, id) + } + } + return toAdd, toRemove +} + +func (r *CampaignResource) reconcileCampaignChecks( + ctx context.Context, + diags *diag.Diagnostics, + campaignId opslevel.ID, + stateModel CampaignResourceModel, + planModel CampaignResourceModel, +) { + stateIds := extractCheckIdSet(ctx, diags, stateModel.CheckIds) + planIds := extractCheckIdSet(ctx, diags, planModel.CheckIds) + if diags.HasError() { + return + } + + added, toRemove := DiffCheckIds(stateIds, planIds) + + toAdd := make([]opslevel.ID, len(added)) + for i, id := range added { + toAdd[i] = opslevel.ID(id) + } + + if len(toAdd) == 0 && len(toRemove) == 0 { + return + } + + if len(toRemove) > 0 { + rubricNamesByID := make(map[string]string, len(toRemove)) + for _, rubricID := range toRemove { + check, err := r.client.GetCheck(opslevel.ID(rubricID)) + if err != nil { + diags.AddWarning( + "could not look up rubric check", + fmt.Sprintf("Could not fetch rubric check %s to match for removal: %s", rubricID, err), + ) + continue + } + rubricNamesByID[rubricID] = check.Name + } + + campaignChecks, err := r.client.ListCampaignChecks(campaignId) + if err != nil { + title, detail := formatOpslevelError("list campaign checks", err) + diags.AddError(title, detail) + return + } + + campaignCheckByName := make(map[string]opslevel.ID, len(campaignChecks)) + seen := make(map[string]int, len(campaignChecks)) + for _, cc := range campaignChecks { + seen[cc.Name]++ + campaignCheckByName[cc.Name] = cc.Id + } + for name, count := range seen { + if count > 1 { + tflog.Warn(ctx, "multiple campaign checks share the same name; deletion may target the wrong check", + map[string]any{"check_name": name, "count": count}) + } + } + + for _, name := range rubricNamesByID { + ccID, ok := campaignCheckByName[name] + if !ok { + tflog.Warn(ctx, "campaign check not found for removal", map[string]any{"check_name": name}) + continue + } + if err := r.client.DeleteCheck(ccID); err != nil { + title, detail := formatOpslevelError("delete campaign check", err) + diags.AddError(title, detail) + return + } + tflog.Info(ctx, "removed campaign check", map[string]any{"check_name": name, "campaign_check_id": string(ccID)}) + } + } + + if len(toAdd) > 0 { + _, err := r.client.CopyChecksToCampaign(opslevel.ChecksCopyToCampaignInput{ + CampaignId: campaignId, + CheckIds: toAdd, + }) + if err != nil { + title, detail := formatOpslevelError("copy checks to campaign", err) + diags.AddError(title, detail) + return + } + tflog.Info(ctx, "added checks to campaign", map[string]any{"count": len(toAdd)}) + } +} + +func extractCheckIdSet(ctx context.Context, diags *diag.Diagnostics, list types.List) map[string]bool { + if list.IsNull() || list.IsUnknown() { + return map[string]bool{} + } + var ids []string + diags.Append(list.ElementsAs(ctx, &ids, false)...) + if diags.HasError() { + return nil + } + set := make(map[string]bool, len(ids)) + for _, id := range ids { + set[id] = true + } + return set +} + +func extractCheckIds(ctx context.Context, diags *diag.Diagnostics, list types.List) []opslevel.ID { + var ids []string + diags.Append(list.ElementsAs(ctx, &ids, false)...) + if diags.HasError() { + return nil + } + result := make([]opslevel.ID, len(ids)) + for i, id := range ids { + result[i] = opslevel.ID(id) + } + return result +} diff --git a/opslevel/resource_opslevel_campaign_test.go b/opslevel/resource_opslevel_campaign_test.go new file mode 100644 index 00000000..1cd4bfb5 --- /dev/null +++ b/opslevel/resource_opslevel_campaign_test.go @@ -0,0 +1,120 @@ +package opslevel_test + +import ( + "sort" + "testing" + + opsleveltf "github.com/opslevel/terraform-provider-opslevel/opslevel" +) + +func TestDiffCheckIds_NoChange(t *testing.T) { + state := map[string]bool{"a": true, "b": true} + plan := map[string]bool{"a": true, "b": true} + toAdd, toRemove := opsleveltf.DiffCheckIds(state, plan) + if len(toAdd) != 0 { + t.Errorf("expected no additions, got %v", toAdd) + } + if len(toRemove) != 0 { + t.Errorf("expected no removals, got %v", toRemove) + } +} + +func TestDiffCheckIds_AddOnly(t *testing.T) { + state := map[string]bool{"a": true} + plan := map[string]bool{"a": true, "b": true, "c": true} + toAdd, toRemove := opsleveltf.DiffCheckIds(state, plan) + sort.Strings(toAdd) + if len(toAdd) != 2 || toAdd[0] != "b" || toAdd[1] != "c" { + t.Errorf("expected [b c], got %v", toAdd) + } + if len(toRemove) != 0 { + t.Errorf("expected no removals, got %v", toRemove) + } +} + +func TestDiffCheckIds_RemoveOnly(t *testing.T) { + state := map[string]bool{"a": true, "b": true, "c": true} + plan := map[string]bool{"a": true} + toAdd, toRemove := opsleveltf.DiffCheckIds(state, plan) + sort.Strings(toRemove) + if len(toAdd) != 0 { + t.Errorf("expected no additions, got %v", toAdd) + } + if len(toRemove) != 2 || toRemove[0] != "b" || toRemove[1] != "c" { + t.Errorf("expected [b c], got %v", toRemove) + } +} + +func TestDiffCheckIds_AddAndRemove(t *testing.T) { + state := map[string]bool{"a": true, "b": true} + plan := map[string]bool{"b": true, "c": true} + toAdd, toRemove := opsleveltf.DiffCheckIds(state, plan) + if len(toAdd) != 1 || toAdd[0] != "c" { + t.Errorf("expected [c], got %v", toAdd) + } + if len(toRemove) != 1 || toRemove[0] != "a" { + t.Errorf("expected [a], got %v", toRemove) + } +} + +func TestDiffCheckIds_EmptyPlan(t *testing.T) { + state := map[string]bool{"a": true, "b": true} + plan := map[string]bool{} + toAdd, toRemove := opsleveltf.DiffCheckIds(state, plan) + sort.Strings(toRemove) + if len(toAdd) != 0 { + t.Errorf("expected no additions, got %v", toAdd) + } + if len(toRemove) != 2 || toRemove[0] != "a" || toRemove[1] != "b" { + t.Errorf("expected [a b], got %v", toRemove) + } +} + +func TestDiffCheckIds_EmptyState(t *testing.T) { + state := map[string]bool{} + plan := map[string]bool{"a": true, "b": true} + toAdd, toRemove := opsleveltf.DiffCheckIds(state, plan) + sort.Strings(toAdd) + if len(toAdd) != 2 || toAdd[0] != "a" || toAdd[1] != "b" { + t.Errorf("expected [a b], got %v", toAdd) + } + if len(toRemove) != 0 { + t.Errorf("expected no removals, got %v", toRemove) + } +} + +func TestDiffCheckIds_BothEmpty(t *testing.T) { + state := map[string]bool{} + plan := map[string]bool{} + toAdd, toRemove := opsleveltf.DiffCheckIds(state, plan) + if len(toAdd) != 0 { + t.Errorf("expected no additions, got %v", toAdd) + } + if len(toRemove) != 0 { + t.Errorf("expected no removals, got %v", toRemove) + } +} + +func TestDiffCheckIds_NilState(t *testing.T) { + var state map[string]bool + plan := map[string]bool{"a": true} + toAdd, toRemove := opsleveltf.DiffCheckIds(state, plan) + if len(toAdd) != 1 || toAdd[0] != "a" { + t.Errorf("expected [a], got %v", toAdd) + } + if len(toRemove) != 0 { + t.Errorf("expected no removals, got %v", toRemove) + } +} + +func TestDiffCheckIds_NilPlan(t *testing.T) { + state := map[string]bool{"a": true} + var plan map[string]bool + toAdd, toRemove := opsleveltf.DiffCheckIds(state, plan) + if len(toAdd) != 0 { + t.Errorf("expected no additions, got %v", toAdd) + } + if len(toRemove) != 1 || toRemove[0] != "a" { + t.Errorf("expected [a], got %v", toRemove) + } +} diff --git a/submodules/opslevel-go b/submodules/opslevel-go index 3c769115..57952879 160000 --- a/submodules/opslevel-go +++ b/submodules/opslevel-go @@ -1 +1 @@ -Subproject commit 3c769115003cee34a8d40c46b699988fb2269dff +Subproject commit 57952879dd2750cea7eabefdada16eb30d608ac6 diff --git a/tests/local/data_sources.tf b/tests/local/data_sources.tf index e0731dba..4ae27b96 100644 --- a/tests/local/data_sources.tf +++ b/tests/local/data_sources.tf @@ -1,3 +1,9 @@ +# Campaign data sources + +data "opslevel_campaign" "mock_campaign" { + identifier = "Z2lkOi8vb3BzbGV2ZWwvQ2FtcGFpZ24vMTIz" +} + # Domain data sources data "opslevel_domain" "mock_domain" { diff --git a/tests/local/datasource_campaign.tftest.hcl b/tests/local/datasource_campaign.tftest.hcl new file mode 100644 index 00000000..5b9f2392 --- /dev/null +++ b/tests/local/datasource_campaign.tftest.hcl @@ -0,0 +1,55 @@ +mock_provider "opslevel" { + alias = "fake" + source = "./mock_datasource" +} + +run "datasource_campaign_mocked_fields" { + providers = { + opslevel = opslevel.fake + } + + assert { + condition = data.opslevel_campaign.mock_campaign.filter_id == "Z2lkOi8vb3BzbGV2ZWwvRmlsdGVyLzEyMw" + error_message = "wrong filter_id in opslevel_campaign mock" + } + + assert { + condition = data.opslevel_campaign.mock_campaign.html_url == "https://app.opslevel.com/campaigns/mock" + error_message = "wrong html_url in opslevel_campaign mock" + } + + assert { + condition = data.opslevel_campaign.mock_campaign.id == "Z2lkOi8vb3BzbGV2ZWwvQ2FtcGFpZ24vMTIz" + error_message = "wrong id in opslevel_campaign mock" + } + + assert { + condition = data.opslevel_campaign.mock_campaign.name == "mock-campaign-name" + error_message = "wrong name in opslevel_campaign mock" + } + + assert { + condition = data.opslevel_campaign.mock_campaign.owner_id == "Z2lkOi8vb3BzbGV2ZWwvVGVhbS8xMjM" + error_message = "wrong owner_id in opslevel_campaign mock" + } + + assert { + condition = data.opslevel_campaign.mock_campaign.project_brief == "mock-project-brief" + error_message = "wrong project_brief in opslevel_campaign mock" + } + + assert { + condition = data.opslevel_campaign.mock_campaign.start_date == "2026-07-01" + error_message = "wrong start_date in opslevel_campaign mock" + } + + assert { + condition = data.opslevel_campaign.mock_campaign.status == "scheduled" + error_message = "wrong status in opslevel_campaign mock" + } + + assert { + condition = data.opslevel_campaign.mock_campaign.target_date == "2026-09-30" + error_message = "wrong target_date in opslevel_campaign mock" + } +} diff --git a/tests/local/mock_datasource/campaign.tfmock.hcl b/tests/local/mock_datasource/campaign.tfmock.hcl new file mode 100644 index 00000000..cd009837 --- /dev/null +++ b/tests/local/mock_datasource/campaign.tfmock.hcl @@ -0,0 +1,13 @@ +mock_data "opslevel_campaign" { + defaults = { + filter_id = "Z2lkOi8vb3BzbGV2ZWwvRmlsdGVyLzEyMw" + html_url = "https://app.opslevel.com/campaigns/mock" + id = "Z2lkOi8vb3BzbGV2ZWwvQ2FtcGFpZ24vMTIz" + name = "mock-campaign-name" + owner_id = "Z2lkOi8vb3BzbGV2ZWwvVGVhbS8xMjM" + project_brief = "mock-project-brief" + start_date = "2026-07-01" + status = "scheduled" + target_date = "2026-09-30" + } +} diff --git a/tests/local/mock_resource/campaign.tfmock.hcl b/tests/local/mock_resource/campaign.tfmock.hcl new file mode 100644 index 00000000..29704d70 --- /dev/null +++ b/tests/local/mock_resource/campaign.tfmock.hcl @@ -0,0 +1,6 @@ +mock_resource "opslevel_campaign" { + defaults = { + html_url = "https://app.opslevel.com/campaigns/test" + status = "draft" + } +} diff --git a/tests/local/resource_campaign.tftest.hcl b/tests/local/resource_campaign.tftest.hcl new file mode 100644 index 00000000..f006e79a --- /dev/null +++ b/tests/local/resource_campaign.tftest.hcl @@ -0,0 +1,86 @@ +mock_provider "opslevel" { + alias = "fake" + source = "./mock_resource" +} + +run "resource_campaign_big" { + providers = { + opslevel = opslevel.fake + } + + assert { + condition = opslevel_campaign.big.name == "Big Campaign" + error_message = "wrong name in opslevel_campaign.big" + } + + assert { + condition = opslevel_campaign.big.owner_id == var.test_id + error_message = "wrong owner_id in opslevel_campaign.big" + } + + assert { + condition = opslevel_campaign.big.filter_id == var.test_id + error_message = "wrong filter_id in opslevel_campaign.big" + } + + assert { + condition = opslevel_campaign.big.project_brief == "This is a big campaign" + error_message = "wrong project_brief in opslevel_campaign.big" + } + + assert { + condition = opslevel_campaign.big.start_date == "2026-07-01" + error_message = "wrong start_date in opslevel_campaign.big" + } + + assert { + condition = opslevel_campaign.big.target_date == "2026-09-30" + error_message = "wrong target_date in opslevel_campaign.big" + } + + assert { + condition = can(opslevel_campaign.big.id) + error_message = "id attribute missing from opslevel_campaign.big" + } + + assert { + condition = opslevel_campaign.big.status == "draft" + error_message = "wrong status in opslevel_campaign.big" + } + + assert { + condition = opslevel_campaign.big.html_url == "https://app.opslevel.com/campaigns/test" + error_message = "wrong html_url in opslevel_campaign.big" + } + + assert { + condition = length(opslevel_campaign.big.check_ids) == 1 + error_message = "wrong number of check_ids in opslevel_campaign.big" + } + + assert { + condition = opslevel_campaign.big.check_ids[0] == var.test_id + error_message = "wrong check_ids[0] in opslevel_campaign.big" + } +} + +run "resource_campaign_small" { + providers = { + opslevel = opslevel.fake + } + + assert { + condition = opslevel_campaign.small.name == "Small Campaign" + error_message = "wrong name in opslevel_campaign.small" + } + + assert { + condition = opslevel_campaign.small.owner_id == var.test_id + error_message = "wrong owner_id in opslevel_campaign.small" + } + + assert { + condition = can(opslevel_campaign.small.id) + error_message = "id attribute missing from opslevel_campaign.small" + } +} diff --git a/tests/local/resources.tf b/tests/local/resources.tf index 9c2ff416..b13a0fe6 100644 --- a/tests/local/resources.tf +++ b/tests/local/resources.tf @@ -19,6 +19,23 @@ resource "opslevel_component_type" "api" { } } +# Campaign resources + +resource "opslevel_campaign" "big" { + name = "Big Campaign" + owner_id = var.test_id + filter_id = var.test_id + project_brief = "This is a big campaign" + start_date = "2026-07-01" + target_date = "2026-09-30" + check_ids = [var.test_id] +} + +resource "opslevel_campaign" "small" { + name = "Small Campaign" + owner_id = var.test_id +} + # Domain resources resource "opslevel_domain" "fancy" { diff --git a/tests/remote/campaign.tftest.hcl b/tests/remote/campaign.tftest.hcl new file mode 100644 index 00000000..1e044b36 --- /dev/null +++ b/tests/remote/campaign.tftest.hcl @@ -0,0 +1,97 @@ +variables { + name = "TF Test Campaign" + owner_id = "Z2lkOi8vb3BzbGV2ZWwvVGVhbS8x" # replace with a valid team ID in your org + project_brief = "Integration test campaign created by Terraform" +} + +run "resource_campaign_create_draft" { + variables { + name = var.name + owner_id = var.owner_id + project_brief = var.project_brief + } + + module { + source = "./campaign" + } + + assert { + condition = alltrue([ + can(opslevel_campaign.test.id), + can(opslevel_campaign.test.html_url), + can(opslevel_campaign.test.status), + ]) + error_message = "expected campaign to have id, html_url, and status" + } + + assert { + condition = opslevel_campaign.test.name == var.name + error_message = "campaign name does not match" + } + + assert { + condition = opslevel_campaign.test.status == "draft" + error_message = "new campaign should be in draft status" + } + + assert { + condition = opslevel_campaign.test.project_brief == var.project_brief + error_message = "campaign project_brief does not match" + } +} + +run "resource_campaign_schedule" { + variables { + name = var.name + owner_id = var.owner_id + project_brief = var.project_brief + start_date = "2026-08-01" + target_date = "2026-12-31" + } + + module { + source = "./campaign" + } + + assert { + condition = opslevel_campaign.test.start_date == "2026-08-01" + error_message = "campaign start_date does not match" + } + + assert { + condition = opslevel_campaign.test.target_date == "2026-12-31" + error_message = "campaign target_date does not match" + } + + assert { + condition = opslevel_campaign.test.status == "scheduled" + error_message = "campaign should be in scheduled status after setting dates" + } +} + +run "resource_campaign_unschedule" { + variables { + name = "TF Test Campaign Updated" + owner_id = var.owner_id + project_brief = "Updated project brief" + } + + module { + source = "./campaign" + } + + assert { + condition = opslevel_campaign.test.name == "TF Test Campaign Updated" + error_message = "campaign name was not updated" + } + + assert { + condition = opslevel_campaign.test.project_brief == "Updated project brief" + error_message = "campaign project_brief was not updated" + } + + assert { + condition = opslevel_campaign.test.status == "draft" + error_message = "campaign should be back in draft status after removing dates" + } +} diff --git a/tests/remote/campaign/main.tf b/tests/remote/campaign/main.tf new file mode 100644 index 00000000..d623a327 --- /dev/null +++ b/tests/remote/campaign/main.tf @@ -0,0 +1,7 @@ +resource "opslevel_campaign" "test" { + name = var.name + owner_id = var.owner_id + project_brief = var.project_brief + start_date = var.start_date + target_date = var.target_date +} diff --git a/tests/remote/campaign/variables.tf b/tests/remote/campaign/variables.tf new file mode 100644 index 00000000..ce7bfb7e --- /dev/null +++ b/tests/remote/campaign/variables.tf @@ -0,0 +1,22 @@ +variable "name" { + type = string +} + +variable "owner_id" { + type = string +} + +variable "project_brief" { + type = string + default = null +} + +variable "start_date" { + type = string + default = null +} + +variable "target_date" { + type = string + default = null +}