Skip to content

fix(certificate-authority): preserve Daily schedule variant on read/update#182

Open
spbsoluble wants to merge 8 commits into
fix/template-role-binding-paginationfrom
fix/ca-schedule-daily-variant
Open

fix(certificate-authority): preserve Daily schedule variant on read/update#182
spbsoluble wants to merge 8 commits into
fix/template-role-binding-paginationfrom
fix/ca-schedule-daily-variant

Conversation

@spbsoluble

Copy link
Copy Markdown
Collaborator

Summary

  • full_scan/incremental_scan/threshold_check schedules can be Interval-shaped or Daily-shaped server-side; the provider only understood Interval, so a Daily-shaped schedule read back as Null (indistinguishable from "no schedule").
  • Because the update endpoint is a full-replace PUT, an omitted schedule field clears it server-side — so any CA using a Daily schedule was silently wiped on every apply, not just the first one.
  • Adds full_scan_daily_time / incremental_scan_daily_time / threshold_check_daily_time (RFC3339, mutually exclusive per-schedule with the existing *_interval_minutes attribute), new scheduleToState/buildSchedule helpers, extends preserveCAUpdateFields so an update declaring neither variant preserves whichever the prior state holds, adds ValidateConfig to reject conflicting/malformed schedule config, and updates Delete()'s schedule-clearing path to null out the new attributes too.

Verification

  • go build ./... clean; full TestUnit suite: 0 failures, 1 skip (expected baseline).
  • New regression tests: TestUnitCAReadPreservesDailySchedule, TestUnitCAUpdatePreservesDailySchedule, TestUnitCAUpdateScheduleVariantSwitchDoesNotResurrectOther, TestUnitCAValidateConfigRejectsConflictingScheduleAttributes, TestUnitCAValidateConfigRejectsMalformedDailyTime, TestUnitCAValidateConfigAllowsEitherVariantAlone.
  • Confirmed live against the lab's real CA (id 1, "Sub-CA"): pre-fix, an ordinary import + apply wiped its live daily full_scan schedule. Post-fix, the same import + apply shows 0 changes and correctly populates full_scan_daily_time — schedule survives.
  • docs/resources/certificate_authority.md regenerated for the three new attributes.

Base branch

Targets fix/template-role-binding-pagination (#173), not v2 — stacking on top of the pending pagination fix per current release sequencing.

@spbsoluble spbsoluble left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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().

…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.
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.

1 participant