Skip to content

fix(gcp): harden Artifact Registry cleanup policies, and test the fix itself - #384

Merged
Cre-eD merged 4 commits into
mainfrom
fix/ar-cleanup-policy-hardening
Aug 20, 2026
Merged

fix(gcp): harden Artifact Registry cleanup policies, and test the fix itself#384
Cre-eD merged 4 commits into
mainfrom
fix/ar-cleanup-policy-hardening

Conversation

@Cre-eD

@Cre-eD Cre-eD commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #379, which merged with known defects. A multi-model review panel found its central claim was false and that several declared-retention shapes delete far more than they appear to.

Exposure today is narrow but real: only the IgnoreChanges branch is live for existing users, and that path is non-destructive. Every defect below fires only when someone writes cleanupPolicies: in config — which nobody has yet, and which is exactly what the first adopter will do.

The claim, first

#379 said its guards were mutation-tested. They were not, in the way that mattered.

  • Deleting the whole fix left the suite green. Removing the else { opts = append(opts, sdk.IgnoreChanges(...)) } branch — reverting to the behaviour that silently cleared retention — passed, because every test exercised the helpers in isolation and nothing observed the ResourceOption that is the fix.
  • TestCleanupPolicyFieldsCoverDryRun asserted a literal against itself, so changing the call site to ignore only cleanupPolicies dropped dry-run from the list with no failure.
  • The happy-path tests asserted only HaveLen, which survives dropping the policies entirely, hardcoding the action, or dropping KeepCount so a KEEP 20 becomes a KEEP 0.

MockResourceArgs.RegisterRPC exposes both the inputs and the ignore list under WithMocks, so the real provisioning function is now rendered and asserted. Six mutants verified to fail, including deleting the fix and the call-site dry-run mutation that previously survived.

Correctness, ordered by how much they delete

shape was now
condition: {} passed the guard (nil-only check); tagState defaults to ANYevery version rejected, with the message noting a typo produces this
DELETE with only tagState or newerThan accepted; newerThan alone targets the images currently running must narrow by olderThan or a prefix
cleanupPolicyDryRun unset explicit falseenforced on first provision, and flipped an out-of-band dry-run repo into enforcing defaults to dry run; enforcing is an explicit false, and both paths log which
mostRecentVersions with no keepCount KEEP protecting nothing while reading as protective keepCount >= 1 required
condition + mostRecentVersions accepted; API rejects mid-provision rejected up front
tagPrefixes without TAGGED accepted; API rejects rejected
empty prefix "" matched everything; also what an unresolved placeholder collapses to rejected
>10 policies failed at apply rejected

Duration validation was wrong in both directions. The bare "s" suffix admitted "30days", "abcs", "-5s" and "0s" — the last matching every version — while rejecting "30d", the form the provider's own acceptance tests use (older_than = "30d", newer_than = "7d"). Both forms are accepted now; non-positive values are not.

Design: retention could not be removed

Deleting the config block fell back to the ignore branch and left the live policy deleting. The field is now *[]ArtifactRegistryCleanupPolicy, because the placeholder resolver calls reflect.MakeSlice for every slice kind and destroys nil-vs-empty, while its pointer branch returns early on nil.

So absent = unmanaged, cleanupPolicies: [] = managed-and-empty. That also reverts the fixture edit #379 needed.

Also

IgnoreChanges was appended to the shared opts slice and attached to eight unrelated resources; now scoped to the repository. Validation errors wrapped with registry and environment. YAML decode tests added — the fields are only reachable from YAML, and a wrong struct tag would have shipped green with declared retention silently becoming unmanaged. Surface documented in supported-resources.md.

Still open

Whether IgnoreChanges suppresses the diff at all. The engine copies old inputs over new inputs, and this field has never been in inputs, so suppression rests on the terraform bridge honouring DiffRequest.ignore_changes for a schema.TypeSet (pulumi-terraform-bridge#1756, open).

These tests prove SC asks for the right thing. Only a pulumi preview against a repository carrying live out-of-band policies can prove GCP keeps them, and that is worth doing before anyone relies on the preservation path.

… itself

Follow-up to #379. A multi-model review found that its central claim was false
and that several declared-retention shapes delete far more than they appear to.

The claim first, because it is the reason the rest shipped. #379 said its guards
were mutation-tested. They were not, in the way that mattered: deleting the
whole IgnoreChanges branch, reverting to the behaviour that silently cleared
retention policies, left the suite green, because every test exercised the
helpers in isolation and nothing observed the ResourceOption that IS the fix.
TestCleanupPolicyFieldsCoverDryRun asserted a literal against itself, so
changing the CALL SITE to ignore only cleanupPolicies dropped dry-run from the
ignore list with no failure. The happy-path tests asserted only HaveLen, which
survives dropping the policies entirely, hardcoding the action, or dropping
KeepCount so a KEEP 20 becomes a KEEP 0.

MockResourceArgs.RegisterRPC exposes both the inputs and the ignore list under
WithMocks, so the real provisioning function is now rendered and asserted. Six
mutants are verified to fail it, including deleting the fix and the call-site
dry-run mutation that previously survived.

Correctness, in rough order of how much they delete:

An empty condition passed the unconditional-DELETE guard, which only checked
for nil. tagState defaults to ANY server-side, so `condition: {}` matched every
version in the repository. Config decoding is non-strict yaml.Unmarshal, so a
mistyped key produces exactly that shape and the error message now says so. A
DELETE narrowed only by tagState or newerThan is rejected for the same reason:
newerThan alone targets the most recently pushed images, which are the ones
currently running.

cleanupPolicyDryRun unset became an explicit false, so declaring policies
enforced deletion on the first provision and silently flipped a repository
someone had put in dry-run out of band. Unset now means dry run; enforcing is
an explicit false, and both paths log which one they took.

mostRecentVersions with keepCount unset or zero built a KEEP that protects
nothing while reading as protective, and KEEP is what outranks a companion
DELETE. keepCount >= 1 is now required.

condition and mostRecentVersions are a union field; setting both was accepted
here and rejected by the API mid-provision. tagPrefixes requires tagState
TAGGED. Empty prefixes match everything and are what an unresolved placeholder
collapses to. The 10-policy limit is enforced before apply.

Duration validation was wrong in both directions: a bare "s" suffix admitted
"30days", "abcs", "-5s" and "0s" (which matches every version), while rejecting
"30d", the form the provider's own acceptance tests use. Both the day and
seconds forms are accepted now and non-positive values are not.

Design: retention could not be removed through configuration. Deleting the
block fell back to the ignore branch and left the live policy deleting. The
field is now *[]ArtifactRegistryCleanupPolicy, because the placeholder resolver
calls reflect.MakeSlice for every slice kind and destroys nil-vs-empty, while
its pointer branch returns early on nil. So absent means unmanaged, and an
explicit empty list means managed-and-empty. That also reverts the fixture edit
#379 needed.

IgnoreChanges was appended to the shared opts slice and attached to eight
unrelated resources; it is now scoped to the repository. Validation errors are
wrapped with the registry and environment like every other failure here.

Adds the YAML decode tests the fields never had. They are only reachable from
YAML, and a wrong struct tag would have shipped green with declared retention
silently becoming unmanaged.

Documents the surface in supported-resources.md, including that omitting the
field preserves out-of-band retention, that an empty list is how retention is
removed, and that dry run is the default.

Still open, and not answerable from this repo: whether IgnoreChanges suppresses
the diff at all. The engine copies old INPUTS over new inputs and this field has
never been in inputs, so suppression rests on the terraform bridge honouring
DiffRequest.ignore_changes for a TypeSet. These tests prove SC asks for the
right thing; only a preview against a repository carrying live out-of-band
policies can prove GCP keeps them.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Semgrep Scan Results

Repository: api | Commit: 11a4aa3

Check Status Details
⚠️ Semgrep Warning 2 warning(s), 6 total

Scanned at 2026-08-20 05:46 UTC

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Security Scan Results

Repository: api | Commit: 11a4aa3

Check Status Details
✅ Secret Scan Pass No secrets detected
⚠️ Dependencies (Trivy) High 2 high, 4 total
⚠️ Dependencies (Grype) High 2 high, 4 total
📦 SBOM Generated 523 components (CycloneDX)

Scanned at 2026-08-20 05:46 UTC

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

📊 Statement coverage

Measured on the documented included set (see docs/TESTING.md → Coverage scope). Observe-only — no regression gate is enforced yet.

Scope This PR main baseline Δ
Included set (Gold-tier denominator) 89.6% 89.6% +0.0 pp
Full set (whole repo, transparency) 28.9% 28.5% +0.4 pp

Baseline: main @ e1f5c9d

Cre-eD added 3 commits August 19, 2026 22:53
…sories

govulncheck reads the stdlib version from the `go` directive in go.mod and
the job runs with GOTOOLCHAIN=local, so staying on 1.26.5 reported seven
reachable stdlib vulnerabilities: GO-2026-6218 (net/url quadratic
resolvePath), GO-2026-6091 (html/template JS regexp context), GO-2026-6090
(crypto/tls post-handshake message limit), GO-2026-6089 (net/http H2
ReadHeaderTimeout), GO-2026-6088 (encoding/xml recursion depth),
GO-2026-5972 (encoding/asn1 recursion depth) and GO-2026-5026 (idna
ASCII-only Punycode labels). All seven are fixed in go1.26.6.

No source change: the advisories landed in the vuln DB on 2026-08-18 and
hit every branch whose go directive predates 1.26.6.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
…dded

Second review round on this branch. Two classes of problem, both mine.

The narrowing guard permitted the destructive shape and rejected the safe one.
It accepted a prefix list as narrowing a DELETE, but a prefix selects which
PACKAGES a policy covers, not which ages. So DELETE with
packageNamePrefixes: ["web-app"] deleted every version of that package
including the deployed digest, and for a Docker repository versionNamePrefixes
is the digest itself, so ["sha256:"] matched the whole repository. Both passed.
Meanwhile DELETE with tagState UNTAGGED, which cannot touch a tagged deployed
image, was rejected, and the error text named "a prefix list" as the fix, so
anyone blocked on the safe shape was told in-band to switch to the unsafe one.
That also rejected the provider's own reference configuration, whose first
policy is delete-untagged with tag_state = "UNTAGGED"; a user who deleted the
failing policy to get past validation would be left keep-only, with retention
that is declared, green and deletes nothing.

Now a DELETE condition must set olderThan or target UNTAGGED, and prefix-only
shapes are rejected with an error that says why.

Durations are integer-only. The provider expands the m/h/d forms with
strconv.Atoi, so "1.5h" passed validation and failed at registration, and the
test asserted it as valid.

The rest were coverage gaps in code this PR introduced. The five
prefix/newerThan passthrough branches had no test and each survived deletion,
which means a DELETE could silently lose its narrowing and widen to the whole
repository. The YAML decode test covered 5 of 11 keys, so a dead struct tag on
any prefix field dropped it with no error at all, and both the empty-condition
and narrowing guards still passed. Rewriting the table also deleted the
"unknown tagState" case that existed before, leaving that enum guard unkilled;
restored. The new mostRecentVersions empty-prefix guard had no test either.

Two assertions could not fail for what they claimed. The error-context one
survived dropping both the registry name and the environment, and
TestCleanupDurationAcceptsBothProviderForms re-derived the regex under test
against itself, so it could not have caught the fractional-duration bug. Both
now assert the real values, and the duration test is pinned to evidence: the
day form appears verbatim in terraform-provider-google's generated acceptance
test, the API reports seconds, and fractional quantities are rejected because
of the Atoi expansion.

Fixed four comments and two doc claims that were wrong. hasEmpty said an
unresolved placeholder collapses to ""; it stays literal, and the reachable
path is an ${env:VAR} with the variable unset. The docs said config is
validated before provisioning, but validation runs after the services API has
already been enabled. The docs said deleting the block returns a repository to
unmanaged and leaves live policies in place; once SC has managed the field,
ignoreChanges carries the previous value forward from state, so retention
freezes at the last declared list. Also documented that the bare key
"cleanupPolicies:" decodes as absent while "[]" removes every policy, which
look nearly identical in review.

The empty-list path logged the most reassuring message on the most destructive
branch: it removes every live policy and said "DRY RUN, nothing is deleted",
but dryRun gates deletion of versions by policies, not removal of the policies
themselves. It now warns.

Adds a policy-name length check, prefix-error messages that name which block
they came from, deterministic ordering so two empty lists cannot produce a
random field name, and an index for a nameless policy.

Eleven mutants verified to fail: the four passthrough branches, the tagState
enum widened to accept RELEASED, the mostRecentVersions empty-prefix guard, the
error wrap, and four YAML struct tags. Full suite green apart from two
pre-existing failures unrelated to this change: TestDetectRootDir fails at HEAD
in this worktree, and the OIDC retry test is timing-flaky and passes 3/3 on
re-run.

Signed-off-by: Dmitrii Creed <creeed22@gmail.com>
@Cre-eD

Cre-eD commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Second review round applied (05a08f8). A panel of three lenses plus two outside models went over this; the headline finding was in my own guard.

The narrowing rule was backwards

It accepted a prefix list as narrowing a DELETE. A prefix selects which packages a policy covers, not which ages:

  • DELETE + packageNamePrefixes: ["web-app"] deleted every version of that package, including the deployed digest. Passed.
  • DELETE + versionNamePrefixes: ["sha256:"] — for a Docker repository the version name is the digest, so this matched the whole repository. Passed.
  • DELETE + tagState: UNTAGGED, which cannot touch a tagged deployed image, was rejected — and the error named "a prefix list" as the fix, steering anyone blocked on the safe shape toward the unsafe one.

It also rejected the provider's own reference configuration, whose first policy is delete-untagged with tag_state = "UNTAGGED". Anyone deleting the failing policy to get past validation would be left keep-only: retention that is declared, green, and deletes nothing.

A DELETE condition must now set olderThan or target UNTAGGED, with an error that explains why prefixes do not count.

Durations are integer-only

The provider expands the m/h/d forms with strconv.Atoi, so 1.5h passed validation and failed at registration — and the test asserted it as valid.

Coverage gaps in code this PR added

The five prefix/newerThan passthrough branches had no test and each survived deletion, meaning a DELETE could silently lose its narrowing. The YAML decode test covered 5 of 11 keys, so a dead struct tag dropped a prefix with no error while both the empty-condition and narrowing guards still passed. Rewriting the table also deleted the "unknown tagState" case that existed before, leaving that enum guard unkilled; restored. The new mostRecentVersions empty-prefix guard had no test either.

Two assertions could not fail for what they claimed

The error-context assertion survived dropping both the registry name and the environment. TestCleanupDurationAcceptsBothProviderForms re-derived the regex under test against itself, so it could not have caught the fractional bug. Both now assert real values, and the duration test is pinned to evidence rather than to itself.

Wrong statements corrected

Four comments and two doc claims. hasEmpty said an unresolved placeholder collapses to "" — it stays literal; the reachable path is an ${env:VAR} with the variable unset. The docs said configuration is validated before provisioning, but validation runs after the services API is enabled. The docs said deleting the block returns a repository to unmanaged and leaves live policies in place — once SC has managed the field, ignoreChanges carries the previous value forward from state, so retention freezes at the last declared list. Also documented that a bare cleanupPolicies: decodes as absent while [] removes every policy, which look nearly identical in review.

And the most destructive branch carried the most reassuring log: cleanupPolicies: [] removes every live policy and said "DRY RUN, nothing is deleted". dryRun gates deletion of versions by policies, not removal of the policies themselves. It now warns.

Verification

Eleven mutants verified to fail: the four passthrough branches, the tagState enum widened to accept RELEASED, the mostRecentVersions empty-prefix guard, the error wrap, and four YAML struct tags. Earlier round: 53 mutants, 38 killed — the survivors are what this commit addresses.

Two suite failures are unrelated and pre-existing: TestDetectRootDir fails at HEAD in a /tmp worktree, and the OIDC retry test is timing-flaky (3/3 on re-run).

Upgrade note for the release description

cleanupPolicies: [] changed meaning. In 2026.8.6 an empty list was len() == 0 and therefore unmanaged, so out-of-band policies survived. It now means managed-and-empty and removes them. Same-release window, so real exposure is near zero, but the semantics are not additive.

Still open and not answerable from this repo: whether IgnoreChanges suppresses the diff at all for a schema.TypeSet. A preview against a repository carrying live out-of-band policies is the only thing that settles it.

@Cre-eD
Cre-eD merged commit ec0f628 into main Aug 20, 2026
23 checks passed
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.

3 participants