fix(certificate-authority): preserve Daily schedule variant on read/update#182
fix(certificate-authority): preserve Daily schedule variant on read/update#182spbsoluble wants to merge 8 commits into
Conversation
spbsoluble
left a comment
There was a problem hiding this comment.
Automated high-effort code review (workflow-backed, 4 finders + independent verification per candidate) surfaced 2 confirmed correctness defects, both in the schedule variant-handling logic this PR is meant to fix. Details inline — these should block merge pending a fix, even though GitHub won't let me formally request changes on my own PR. A separate compliance pass (SOX/SOC2/SOC1) also flagged a High-priority logging gap in the same preserve/override path — noted below for visibility even though it's not blocking.
Compliance finding (High, not inline-commented): preserveCAUpdateFields/preserveSchedule (~lines 931-961, called at ~1088) emits no tflog call for which of the three outcomes occurred (kept plan value / preserved prior Interval / preserved prior Daily) per schedule — this is the exact code path that caused the original incident, so there's still no audit trail for schedule changes from logs alone. Delete() already logs its schedule-clearing decision (~line 1157); recommend matching that pattern here. Medium: ValidateConfig's rejection paths only raise Terraform diagnostics, no tflog.Warn, so rejected config leaves no trace in TF_LOG.
| preserveSchedule := func(planInterval *types.Int64, planDaily *types.String, stateInterval types.Int64, stateDaily types.String) { | ||
| planIntervalKnown := !planInterval.Null && !planInterval.Unknown | ||
| planDailyKnown := !planDaily.Null && !planDaily.Unknown | ||
| if planIntervalKnown || planDailyKnown { |
There was a problem hiding this comment.
[CONFIRMED, correctness] preserveSchedule's "either variant known → bail" guard is defeated by this same schema's UseStateForUnknown() plan modifiers on the paired *_interval_minutes/*_daily_time attributes (also relevant: line 941, buildSchedule's Interval-wins precedence at ~line 669, and the schema modifier at ~line 366).
Failure scenario: A CA has full_scan_interval_minutes = 60 in state (full_scan_daily_time unset/Null). User edits config to remove full_scan_interval_minutes and add full_scan_daily_time = "2026-07-17T15:46:00Z" to switch the schedule to Daily. Because full_scan_interval_minutes is Optional+Computed with UseStateForUnknown(), Terraform's plan reverts that field to the old Known value 60 instead of Null. Update() then sees both plan.FullScanIntervalMinutes=60 (Known) and plan.FullScanDailyTime="2026-07-17T15:46:00Z" (Known). The if planIntervalKnown || planDailyKnown { return } guard fires and preserves nothing useful, then buildSchedule's Interval-precedence branch wins — the outbound PUT still sends the old Interval schedule and silently drops the user's Daily config. Either the CA's live schedule never actually switches, or Terraform raises "Provider produced inconsistent result after apply" because planned full_scan_daily_time (Known) doesn't match the Null value scheduleToState derives from the server's still-Interval response.
This defeats the core purpose of this PR in the variant-switch case — worth a regression test alongside the existing TestUnitCAUpdateScheduleVariantSwitchDoesNotResurrectOther, which appears not to cover the case where the old variant's attribute is still Known in the plan due to UseStateForUnknown.
| interval = types.Int64{Value: int64(sched.Interval.GetMinutes())} | ||
| } | ||
| if sched.Daily != nil && sched.Daily.Time != nil { | ||
| daily = types.String{Value: sched.Daily.Time.UTC().Format(time.RFC3339)} |
There was a problem hiding this comment.
[CONFIRMED, correctness] scheduleToState unconditionally reformats a returned Daily schedule time via .UTC() (sched.Daily.Time.UTC().Format(time.RFC3339)), so a user-configured *_daily_time with a non-UTC offset can never round-trip back to the same string.
Failure scenario: User sets full_scan_daily_time = "2026-07-17T15:46:00-04:00" — accepted as valid by validateCAScheduleAttributes, which only calls time.Parse without requiring UTC. buildSchedule sends that instant to Command; on read-back, scheduleToState formats it as "2026-07-17T19:46:00Z" — a different string than the planned/config value. Since full_scan_daily_time is Optional (its planned value is the user's literal Known string, not Unknown), this mismatch triggers "Provider produced inconsistent result after apply" on the apply that sets it, or a perpetual diff/re-apply loop on every subsequent plan.
Fix options: either normalize/require UTC in validateCAScheduleAttributes (reject non-Z offsets with a clear error) or preserve the original offset through the round-trip instead of forcing .UTC().
1f29bf8 to
73cd698
Compare
…pdate Command represents each of the CA's full_scan/incremental_scan/ threshold_check schedules as a tagged union that can be Interval-shaped or Daily-shaped. The provider only understood Interval, so a Daily-shaped schedule fell through to Null on Read -- indistinguishable from "no schedule configured." Since the update endpoint is a full-replace PUT, an omitted schedule field clears it server-side, so any CA using a Daily schedule had it silently wiped on every apply. Confirmed live against the lab's real CA (id 1, "Sub-CA"): an ordinary import + apply previously cleared its live daily full_scan schedule. Adds full_scan_daily_time / incremental_scan_daily_time / threshold_check_daily_time (RFC3339, mutually exclusive with the existing *_interval_minutes attribute per schedule), fixes caResponseToState/buildCARequest via new scheduleToState/buildSchedule helpers, extends preserveCAUpdateFields so an undeclared update preserves whichever variant prior state holds without resurrecting the other, adds ValidateConfig to reject conflicting/malformed schedule attributes, and updates Delete()'s schedule-clearing path to null out the new attributes too. Regenerated docs/resources/certificate_authority.md for the new attributes.
The roles attribute description was updated in a prior commit (fix(security-identity): keep declared role spelling on Read) but the generated docs were never regenerated to match, leaving docs/resources/identity.md stale. Run make tfdocs to catch it up.
F182-2: *_daily_time was stored/parsed as a full RFC3339 timestamp, but live verification against Command (PUT a Daily schedule with an explicit anchor date, re-GET) shows the server echoes the UTC time-of-day exactly while silently rewriting the anchor date to the current date. A full-timestamp wire format can therefore never round-trip -- every Read would see a "changed" date component even though nothing about the schedule actually changed, and the RFC3339 offset was pure noise Command ignores entirely. Switch the wire format to a bare UTC time-of-day, "HH:MM:SS" (Go layout caDailyTimeLayout = "15:04:05"): - scheduleToState formats with .UTC().Format(caDailyTimeLayout). - buildSchedule parses caDailyTimeLayout and anchors the result to a fixed, arbitrary date (2000-01-01) -- deterministic for tests, and irrelevant to what Command actually applies since it rewrites the date server-side regardless of what is sent. - validateCAScheduleAttributes validates against the same layout, and the schema descriptions and generated docs now document "HH:MM:SS" (e.g. "07:00:00") instead of the old RFC3339 example. Regression coverage: reworked the existing Daily-schedule tests to the new format, added TestUnitCAValidateConfigRejectsRFC3339DailyTime to guard against the old format being silently re-accepted, and added TestUnitCAScheduleDailyTimeRoundTrip / TestUnitCAScheduleDailyTimeNormalizesNonUTCOffset to cover the round-trip and non-UTC-offset-normalization cases. Also fixes two config-literal gaps surfaced while merging with the config-keyed preserveCAUpdateFields from the prior branch tip: two tests left full_scan_daily_time unset in their `config` struct literal, which defaults to a known-empty (not Null) types.String and made declaredInConfig() misclassify the pair as "declared", masking the schedule preservation the tests were meant to exercise.
…iants F182-1: full_scan_interval_minutes/full_scan_daily_time (and the incremental_scan/threshold_check equivalents) each used a bare tfsdk.UseStateForUnknown(), which resurrects the attribute's PRIOR STATE value as Known on every plan -- including a plan where the config just switched the schedule to its sibling variant. Command's schedule is a tagged union (Interval XOR Daily); resurrecting the old Interval value alongside a newly-declared Daily value meant the PUT request either sent both, or the server echoed something the plan didn't predict, either way tripping "Provider produced inconsistent result after apply". Replace UseStateForUnknown with pairedWith(sibling) (added in attribute_contract.go on the prior branch tip) on all six schedule attributes: it plans an attribute explicitly Null the moment its sibling is declared in config, so a variant switch produces a truthful diff (e.g. full_scan_interval_minutes: 60 -> null) instead of resurrecting the old value. F182-4: buildSchedule also now rejects the case where both intervalMinutes and dailyTime are Known, as defense-in-depth for a case ValidateConfig cannot observe at plan time (two Unknown Config references that both resolve to Known values by apply time), rather than silently letting intervalMinutes take precedence and discarding dailyTime. preserveCAUpdateFields gets matching defense-in-depth: when a schedule pair IS declared in config (a variant switch), it now explicitly forces the undeclared half to Null on the plan, rather than leaving it untouched. This does not rely solely on the plan modifier having run correctly -- a hand-built plan (or a modifier regression) can't resurrect a value that this function actively nulls out. Red/green regression: TestUnitCAUpdateVariantSwitchSendsDailyNotInterval is framework-realistic -- it drives resourceCertificateAuthority.Update through a real httptest-backed SDK client (newCAMockSDKClient, modeled on newOAuthRoleClaimAssocMockClient) with a plan hand-built to be exactly what the OLD UseStateForUnknown modifier would have produced (the old Interval value resurrected as Known alongside the newly-declared Daily value), and asserts the captured PUT body carries Daily and no Interval. Confirmed red against the pre-fix preserveCAUpdateFields (temporarily reverted): "interval and daily time are both set but are mutually exclusive" from the new buildSchedule guard. The pairedWith modifier itself is already covered generically by the existing TestUnitPairedVariantModifier_* suite in attribute_contract_test.go. Also fixes a latent test-data gap the new buildSchedule dual-known guard surfaced: several existing CA unit tests left schedule attributes at their Go zero value in config/plan/state literals instead of explicit Null, which is a known-empty (not Null) value and made every schedule pair look "declared" with Interval=0 and Daily=""; introduced the shared caAllSchedulesNull base value (now also used for the new framework-realistic test) so tests only spell out the attributes they actually care about.
…hedule variants F182-3: Command's KeyfactorSchedule is a tagged union with more variants (Weekly, Monthly, ExactlyOnce, Immediate) than this provider has attribute pairs for (only Interval and Daily). scheduleToState collapses any of those unmodeled variants to (Null, Null) in state, indistinguishable from "no schedule configured at all" -- so preserveCAUpdateFields, which only ever has a prior STATE value to fall back to, has nothing to preserve for those variants either. An Update() that leaves a schedule pair entirely undeclared in config would have buildCARequest omit the field, and Command's full-replace PUT semantics would silently clear a live Weekly/Monthly/ExactlyOnce/ Immediate schedule. Update() now does a fresh GET of the CA immediately before building the PUT. A GET error is fatal (AddError + return) rather than falling back to a blind PUT, since blindly PUTing an undeclared schedule pair is exactly the failure mode this is meant to prevent. After buildCARequest constructs the request from plan, applyUndeclaredScheduleFallback overwrites each of FullScan/IncrementalScan/ThresholdCheck with the server's CURRENT schedule verbatim, for any pair config declares neither variant of -- request and response share the same v1.KeyfactorCommonSchedulingKeyfactorSchedule type, so this is a transparent no-op wherever a config-declared Interval/Daily pair already built the same shape from plan. Documented the accepted `-refresh=false` edge: the GET happens moments before the PUT, not at Terraform Core's plan phase, so an out-of-band schedule change racing between the last Read and this Update can still produce a one-time inconsistent-result on that narrow window; the next Read/plan reconciles it, and this is strictly better than trusting in-memory state for a schedule variant this provider cannot represent at all. Red/green: TestUnitCAUpdatePreservesWeeklyScheduleVerbatim seeds the mock GET with a Weekly-shaped FullScan, declares full_scan entirely undeclared in config/plan, and asserts the PUT carries the Weekly object through byte-for-byte with no diagnostics and full_scan_interval_minutes/full_scan_daily_time both staying Null in the post-apply state (scheduleToState still has no pair to represent Weekly in, even though the wire-level schedule was correctly preserved). Confirmed red by temporarily removing the applyUndeclaredScheduleFallback call: the captured PUT body's FullScan was nil, i.e. omitted -- which would have cleared the live schedule server-side. The existing TestUnitCAUpdateVariantSwitchSendsDailyNotInterval mock server gained a GET handler for the same CA id, since Update() now requires it before the PUT it was already asserting against.
G2: there was previously no way to declaratively clear a scan/
threshold schedule -- omitting both *_interval_minutes and
*_daily_time means "leave it unmanaged" (preserveCAUpdateFields /
applyUndeclaredScheduleFallback both treat that as "don't touch it"),
so a practitioner who wants to actually remove a schedule had no
config-driven way to express that.
Adds sentinels: 0 for *_interval_minutes, "" for *_daily_time, each
meaning "clear this schedule."
- validateCAScheduleAttributes allows the sentinels (skips the
time-of-day parse for ""), rejects genuinely negative intervals,
and still rejects declaring both variants of a pair regardless of
whether either side is a sentinel -- a sentinel is a real, declared
value like any other, so declaring a sentinel alongside a real
value (or two sentinels) is exactly as ambiguous as two real
values.
- buildSchedule treats a sentinel input as contributing nothing --
the pair returns (nil, nil), same as fully undeclared, so the field
is omitted from the PUT and Command clears it.
- Because declaredInConfig treats 0/"" as declared (non-null), a
config-declared sentinel correctly makes
preserveCAUpdateFields/applyUndeclaredScheduleFallback treat the
pair as user-managed and NOT fall back to preserving/re-copying the
still-live server value -- the omission this produces is the
clearing PUT, not an accident.
- New keepScheduleSentinels(newState, prior) implements sentinel
stability: when a Read/Create/Update response reports no schedule
at all for a pair (collapsed to Null,Null by scheduleToState -- the
same shape as "never configured" or an unmodeled Weekly/Monthly/
etc. variant) and the prior value (state on Read, plan on Create/
Update) was a declared sentinel, the sentinel is carried forward
into the new state instead of surfacing Null. A real value reported
by the server is always left alone -- that's genuine drift (or the
schedule just set) and must never be masked by a stale sentinel.
Wired into Create/Read/Update; kept CA-specific (not
attribute_contract.go) since it operates on the three concrete
schedule pairs rather than a single generic attr.Value.
- Schema descriptions for all six schedule attributes document the
full contract ("omit to leave unmanaged... set 0/"" to clear").
Red/green:
- TestUnitCAScheduleSentinelClears (framework-realistic, via the
Update() mock SDK client): config declares full_scan_interval_minutes
= 0 against a CA whose mock GET still reports a live 60-minute
Interval; asserts the PUT omits FullScan entirely (not copying the
still-live server value back in via
applyUndeclaredScheduleFallback) and the post-apply state keeps
full_scan_interval_minutes = 0. Confirmed red by temporarily
removing the keepScheduleSentinels call from Update: post-apply
state showed Null instead of 0.
- TestUnitCAReadKeepsScheduleSentinel / TestUnitCAReadSurfacesDriftOverSentinel
exercise keepScheduleSentinels directly: a nil server FullScan with
a prior sentinel keeps the sentinel; a real server Interval (30)
with a prior sentinel (0) surfaces the real drift.
…ions F182-5: adds tflog.Debug at every per-schedule decision point across the three schedule-handling helpers introduced in this branch, naming the schedule (full_scan/incremental_scan/threshold_check) and which branch was taken, so a debug-level provider log gives a legible trail of what happened to each schedule pair on every Update/Read/Create: - preserveCAUpdateFields: "declared in config -- managed" vs "undeclared -- preserving prior state value" (plus one for allowed_requesters). - applyUndeclaredScheduleFallback: "undeclared -- copying current server schedule verbatim onto the request". - keepScheduleSentinels: "reported no schedule -- carrying forward the declared ... clear sentinel". ValidateConfig now also tflog.Warns alongside every AddAttributeError validateCAScheduleAttributes produces, mirroring the tflog.Warn used in the Delete retry-failure path elsewhere in this resource: the diagnostic itself is what Terraform Core surfaces to the practitioner, but a provider-log line makes the same failure visible when only CLI output is being captured (e.g. CI). preserveCAUpdateFields/applyUndeclaredScheduleFallback/ keepScheduleSentinels all gained a leading ctx context.Context parameter to support this; updated all call sites (production and existing unit tests) accordingly.
Adds v2.9.1 entries for the CA schedule work: Daily variant attributes (*_daily_time, UTC HH:MM:SS), the pair-aware plan modifier fixing a variant-switch inconsistent-result bug, read-modify-write preservation of unmodeled schedule variants (Weekly/Monthly/ExactlyOnce/Immediate, with a note on the separate #185 Weekly-read SDK bug), declarative clear sentinels, and schedule-decision debug/warn logging.
73cd698 to
a6c41ab
Compare
Summary
full_scan/incremental_scan/threshold_checkschedules can beInterval-shaped orDaily-shaped server-side; the provider only understoodInterval, so aDaily-shaped schedule read back asNull(indistinguishable from "no schedule").full_scan_daily_time/incremental_scan_daily_time/threshold_check_daily_time(RFC3339, mutually exclusive per-schedule with the existing*_interval_minutesattribute), newscheduleToState/buildSchedulehelpers, extendspreserveCAUpdateFieldsso an update declaring neither variant preserves whichever the prior state holds, addsValidateConfigto reject conflicting/malformed schedule config, and updatesDelete()'s schedule-clearing path to null out the new attributes too.Verification
go build ./...clean; fullTestUnitsuite: 0 failures, 1 skip (expected baseline).TestUnitCAReadPreservesDailySchedule,TestUnitCAUpdatePreservesDailySchedule,TestUnitCAUpdateScheduleVariantSwitchDoesNotResurrectOther,TestUnitCAValidateConfigRejectsConflictingScheduleAttributes,TestUnitCAValidateConfigRejectsMalformedDailyTime,TestUnitCAValidateConfigAllowsEitherVariantAlone.full_scan_daily_time— schedule survives.docs/resources/certificate_authority.mdregenerated for the three new attributes.Base branch
Targets
fix/template-role-binding-pagination(#173), notv2— stacking on top of the pending pagination fix per current release sequencing.