Skip to content

Add opslevel_campaign resource with full CRUD and check management#643

Open
jamescarr wants to merge 21 commits intoOpsLevel:mainfrom
jamescarr:jamescarr/add-campaign-support
Open

Add opslevel_campaign resource with full CRUD and check management#643
jamescarr wants to merge 21 commits intoOpsLevel:mainfrom
jamescarr:jamescarr/add-campaign-support

Conversation

@jamescarr
Copy link
Copy Markdown

@jamescarr jamescarr commented Apr 9, 2026

Closes #644

Depends on: OpsLevel/opslevel-go#611

Implements the opslevel_campaign Terraform resource for managing OpsLevel campaigns as code.

Problem

There is no way to manage OpsLevel campaigns as code. Campaign creation, scheduling, check association, and updates must be done manually through the OpsLevel UI, which makes it difficult to version-control campaign rollouts, reproduce them across environments, or compose them alongside the checks and scorecards they reference.

Solution

New resource opslevel_campaign following the same CommonResourceClient / plugin-framework pattern as opslevel_scorecard.

Managed attributes: name, owner_id, filter_id, project_brief, check_ids, start_date, target_date.

Computed attributes: id, status, html_url.

CRUD lifecycle:

  • Create: Creates a draft campaign via campaignCreate. If both start_date and target_date are set, immediately schedules it via campaignScheduleUpdate. If check_ids is set, copies rubric checks into the campaign via checksCopyToCampaign.

  • Read: Fetches campaign by ID using account.campaign(id:). Queries the campaign's actual checks from the API via ListCampaignChecks, cross-references them by name against the configured rubric check IDs, and updates state to reflect only checks that still exist in the campaign. This enables drift detection — if checks are removed outside Terraform (e.g. via the UI), the next plan will show them as needing to be re-added.

  • Update: Updates campaign fields via campaignUpdate, then manages schedule lifecycle:

    • Dates added → calls ScheduleCampaign
    • Dates removed → calls UnscheduleCampaign (reverts to draft)
    • Dates changed → calls ScheduleCampaign with new values

    Reconciles check_ids — computes a diff between state and plan, adds new checks via CopyChecksToCampaign, and removes stale checks by looking up the rubric check name, finding the corresponding campaign check instance by name, and deleting it via DeleteCheck.

  • Delete: Deletes the campaign via campaignDelete.

  • Import: Supports terraform import by campaign ID.

Data sources:

  • opslevel_campaign — look up a single campaign
  • opslevel_campaigns — list all campaigns

Check reconciliation detail:

The OpsLevel API copies checks into campaigns as separate instances (different IDs, same name). There is no direct "remove check from campaign" mutation. The provider handles this by:

  1. Looking up the rubric check's name via GetCheck
  2. Listing all campaign checks via ListCampaignChecks
  3. Matching by name to find the campaign-side instance
  4. Deleting the campaign check via DeleteCheck

The diffing logic is extracted into a pure DiffCheckIds function with full unit test coverage (9 test cases covering add-only, remove-only, mixed, empty, and nil inputs).

Example

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,
  ]

  start_date  = "2026-07-01"
  target_date = "2026-09-30"

  project_brief = <<-EOT
    All Tier 1 services must pass SOC2 checks by end of Q3.
  EOT
}

Tests

  • Local mock tests (tests/local/resource_campaign.tftest.hcl) — attribute passthrough on create
  • Remote integration tests (tests/remote/campaign.tftest.hcl) — full lifecycle against live API
  • Go unit tests (opslevel/resource_opslevel_campaign_test.go) — 9 test cases for DiffCheckIds

Checklist

  • I have run this code, and it appears to resolve the stated issue.
  • This PR does not reduce total test coverage
  • This PR has no user interface changes or has already received
    approval from product management to change the interface.
  • Make a changie
    entry that explains the customer facing outcome of this change

jamescarr added 16 commits April 9, 2026 11:43
Implements the Terraform resource for managing OpsLevel campaigns:
- Create: creates a draft campaign, optionally schedules it
- Read: fetches campaign by ID from the API
- Update: updates campaign fields and manages schedule lifecycle
- Delete: deletes the campaign
- ImportState: supports terraform import by campaign ID

Managed attributes: name, owner_id, filter_id, project_brief,
start_date, target_date. Computed: id, status, html_url.

Setting both start_date and target_date triggers scheduling;
removing them unschedules the campaign back to draft.

Made-with: Cursor
Wire up NewCampaignResource in the provider's resource list and
enable the local replace directive for opslevel-go development.

Made-with: Cursor
Add resource documentation with usage examples for both draft and
scheduled campaigns, schema reference, and import instructions.

Made-with: Cursor
Add single-campaign lookup by ID (opslevel_campaign) and list-all
with optional status filter (opslevel_campaigns). Follows the same
pattern as opslevel_scorecard / opslevel_scorecards.

Made-with: Cursor
Add ValidateConfig to opslevel_campaign that ensures both start_date
and target_date are set together or both omitted. Prevents confusing
API errors from setting only one schedule field.

Made-with: Cursor
Add mock provider tests that validate schema attributes, defaults,
and plan-time behavior for both full (scheduled) and minimal (draft)
campaign configurations.

Made-with: Cursor
Add end-to-end test module that creates, updates, and destroys a
campaign against a live OpsLevel environment. Also exercises the
opslevel_campaigns data source.

Made-with: Cursor
Points submodule to jamescarr/add-campaign-crud branch which adds
GetCampaign, CreateCampaign, UpdateCampaign, DeleteCampaign,
ScheduleCampaign, and UnscheduleCampaign.

Made-with: Cursor
campaignSchedule mutation doesn't exist in the OpsLevel API.
Dates are passed in campaignCreate and campaignUpdate inputs
instead. Unschedule via campaignUnschedule remains unchanged.

Made-with: Cursor
OpsLevel API requires separate mutations: campaignCreate (no dates)
followed by campaignScheduleUpdate (sets start/target dates). Same
two-step flow for updates. Unschedule still via campaignUnschedule.

Made-with: Cursor
Passes check IDs via checkIdsToCopy on campaign creation to
associate existing checks with the campaign.

Made-with: Cursor
Replace the broken CheckIdsToCopy field on CampaignCreateInput with
the dedicated checksCopyToCampaign GraphQL mutation. Checks are now
copied to the campaign after creation (and optional scheduling),
ensuring they actually appear in the campaign.

Changes:
- Call CopyChecksToCampaign after create if check_ids is set
- Call CopyChecksToCampaign on update when check_ids changes
- Add extractCheckIds helper for type conversion
- Update local mock tests to assert on check_ids
- Update SDK submodule to v2026.3.7-campaign.2

Made-with: Cursor
The checksCopyToCampaign mutation is additive — there is no API to
remove checks from a campaign. Calling it on update causes duplicates.
Remove the update-path copy and document check_ids as create-only.

Made-with: Cursor
@jamescarr
Copy link
Copy Markdown
Author

Tested this out in our sandbox:

Creation

resource "opslevel_campaign" "demo_may_rollout" {
  name      = "May 2026 Demo Checks Rollout"
  owner_id  = data.opslevel_team.staff.id
  filter_id = data.opslevel_filter.tier1.id

  start_date  = "2026-05-15"
  target_date = "2026-06-30"

  check_ids = [
    opslevel_check_manual.demo_secret_rotation.id,
    opslevel_check_manual.demo_dependency_scanning.id,
    opslevel_check_manual.demo_error_budget.id,
    opslevel_check_manual.demo_graceful_degradation.id,
    opslevel_check_repository_file.demo_adr_docs.id,
  ]

  project_brief = <<-EOT
    ## Overview

    Five new checks are being enabled on **May 15, 2026** as part of the
    sandbox demo rollout. All Tier 1 services must pass by **June 30, 2026**.

    ## Checks Included

    | Check | Category | What it verifies |
    |-------|----------|-----------------|
    | DEMO-001 | Security | Secrets rotated every 90 days |
    | DEMO-002 | Security | Dependency vulnerability scanning |
    | DEMO-003 | Reliability | Error budget defined and tracked |
    | DEMO-004 | Reliability | Graceful degradation under failure |
    | DEMO-005 | Quality | Architecture decision records exist |

    ## What you need to do

    1. Review each check's notes for pass criteria
    2. Implement any missing requirements
    3. Mark manual checks as passing with a comment explaining evidence

    ## Resources

    - [OpsLevel Checks as Code](https://internal.docs)
    - Questions? Reach out in #service-opslevel
  EOT
}

Terraform runs:

  • plan
  • apply

Campaign created!
Overview
Checks

Deletion

terraform plan and apply to delete campaign
Screenshot of campaign deleted

Modifying

Terraform runs

  • Plan
  • Apply

Opslevel

The Update function now diffs plan vs state check_ids and:
- Copies newly added rubric checks to the campaign
- Deletes campaign checks matching removed rubric checks (by name)

Requires opslevel-go v2026.3.7-campaign.4 for ListCampaignChecks.

Made-with: Cursor
Read now queries the campaign's actual checks via ListCampaignChecks
and verifies each rubric check ID in state still has a corresponding
campaign check. This enables drift detection (UI deletions are noticed)
and proper reconciliation on update (adds and removes work correctly).

NewCampaignResourceModel no longer handles CheckIds -- each CRUD path
sets it explicitly: Create/Update from plan, Read from API verification.

Made-with: Cursor
Extracts the check ID diffing logic into a pure, exported function
for testability. Adds 9 unit tests covering all edge cases (no
change, add-only, remove-only, mixed, empty/nil inputs).

Made-with: Cursor
@jamescarr jamescarr changed the title WIP: Add opslevel_campaign resource with full CRUD Add opslevel_campaign resource with full CRUD and check management Apr 13, 2026
- Add Computed:true to check_ids schema for proper drift handling
- Fix provider registration ordering (alphabetical)
- Use ComputedStringValue in data source model (convention)
- Collapse dead-code date branches in NewCampaignResourceModel
- Add check_ids to resource docs with name-matching caveat
- Add duplicate-name detection warnings in read and reconcile
- Add data source mock test (campaign.tfmock.hcl + tftest)
- Expand remote tests to cover schedule/unschedule lifecycle
- Add TODO comment on go.mod replace directive for upstream merge
- Bump opslevel-go submodule (GetCampaign guard + recursive pagination)

Made-with: Cursor
Sends GraphQL null instead of an empty string when filter_id is
removed from config. Aligns with how all check resources handle
nullable filter IDs.

Made-with: Cursor
@jamescarr jamescarr force-pushed the jamescarr/add-campaign-support branch from b7a9084 to b2fd449 Compare April 16, 2026 03:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add opslevel_campaign resource for managing campaigns as code

1 participant