Skip to content

fix: valid Kubernetes identifiers from any policy name - #152

Merged
hardbyte merged 13 commits into
mainfrom
claude/design-review-corner-cases-9kp83u
Aug 2, 2026
Merged

fix: valid Kubernetes identifiers from any policy name#152
hardbyte merged 13 commits into
mainfrom
claude/design-review-corner-cases-9kp83u

Conversation

@hardbyte

@hardbyte hardbyte commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Replaces the #147#149#151 stack, which GitHub's stacked-PR preview has wedged (see below). Same eleven commits, same tree — verified byte-identical to the stack tip — now as one PR off main, plus a changelog entry and a review fix.

Originates from @aarons-afk's report and fix in #146: a sanitised label value truncated at 63 bytes can land on a separator, producing an invalid label and a rejected plan ConfigMap. His fix is absorbed into LabelValue::sanitize, and he is credited as co-author on that commit.

What it does

A single source of truth for identifier rules (k8s_names.rs). Label values, DNS-1123 labels, and resource names each had their rules restated inline at the call site, and they disagreed. Three latent bugs fell out of unifying them:

  • generate_plan_name cut the policy-name prefix without trimming an exposed . or -, so the appended -plan-… began a DNS label with a separator.
  • is_valid_secret_name rejected names beginning with a digit, which Kubernetes permits.
  • default_generated_secret_name open-coded its own segment sanitising.

truncate_name_prefix respects UTF-8 char boundaries — an earlier attempt trimmed all non-alphanumerics and emptied a prefix of multi-byte characters.

Identity by controller-owner UID, not by label (plan.rs, main.rs). The pgroles.io/policy label is truncated at 63 bytes while a policy name may be 253, so two policies sharing a 63-byte prefix selected each other's plans and SQL ConfigMaps, and the plan→policy watch silently stopped waking the reconciler for any longer name. The label is now a server-side prefilter only; is_owned_by_policy compares UIDs at all six lookup sites, including the 409-conflict adopt path.

Ownership on the SQL ConfigMap paths too. create_plan_sql_configmap adopts an existing ConfigMap on a 409, and originally checked only the SQL hash — so two colliding policies with identical SQL could adopt each other's artifact, tying its lifetime to the wrong policy's garbage collection. Adoption is now gated on is_owned_by_another_policy, deliberately narrower than !is_owned_by_policy: only a live claim by a different policy blocks it, so an unowned orphan stays adoptable and --cascade=orphan recovery still works. Rollback deletes only a ConfigMap this reconcile actually created, matching the created_new_plan guard the plan itself already had.

Property tests (identifier_properties.rs). Eight properties over a hostile alphabet — NUL, /, =, separators, é, — checking each builder against the API server's rules restated independently of the implementation. The secret-name property drives the real builder rather than re-composing the format, so a change to the separator or length budget can't pass vacuously.

E2E coverage. A 253-byte policy name whose 215th byte is a dot, landing exactly on the truncation cut; and two policies with colliding 63-byte label prefixes, asserting UID-isolated lookup, approval, and cleanup.

Notes for review

  • No CRD length cap. An earlier revision (feat: reject over-long policy names with a CRD CEL rule #148) added a CEL rule capping names at 63. Once identity is the UID, a lossy label is harmless, so the cap restricted users for no benefit — Kubernetes permits 253. Dropped, along with the now-obsolete limitation docs. Worth recording that CRD validation ratcheting only exists on Kubernetes ≥1.30; below that, a new rule rejects every write to an existing object including the operator's own status patches.
  • The stack gate stays. stack-gate skips the six expensive jobs for non-top stack entries. It is fail-safe — stack is null for pushes and non-stacked PRs, so this PR runs the full suite — and it costs nothing to keep. Given what the stack feature just did, treat its savings as untrusted until the preview stabilises.
  • One review finding declined: the max_prefix_len subtraction in generate_plan_name is flagged as a potential underflow. generate_plan_name_has_expected_format pins the timestamp suffix at 28 characters, so a format change fails in CI long before it could underflow in production; saturating_sub would instead silently emit a name starting with -plan-, which the API server rejects.
  • Pre-existing, not fixed here: generated Secret names still collide when two policies differ only by a character that sanitises to - (a.b and a-b). Present on main today; out of scope.

Why the stack was abandoned

refs/pull/149/merge froze at Merge 738b17e into 257c666 and never recomputed, so CI kept building a tree containing crd_cel_rules.rs and KubeSchema — files on no branch head. Six approaches failed: repointing the base (422, "part of a stack"), force-pushing the base branch to match its parent, closing the intermediate PR, no-op amends on both downstream heads, poking mergeability, and closing #149 to eject it (the base-change lock survives closure). A fresh PR off main resolved its merge ref immediately — stack membership is a property of the PR, not the branch.

Verification

All 13 CI checks green on 5203180, including the three kind-based E2E suites. cargo fmt --check, clippy --all-targets -D warnings, the full workspace suite, and check-crd-drift.sh also pass locally.

https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4

hardbyte and others added 11 commits August 2, 2026 05:45
The operator had five independent builders for Kubernetes names and label
values, each with its own truncation and character rules. Three were
wrong, in three different ways, and `password.rs` already contained the
trailing-separator fix that `plan.rs` was missing — the knowledge existed
but was not shared.

Add `k8s_names` as the one place those rules live, covering both shapes
the operator produces: label values (63 chars, alphanumerics/`.`/`-`/`_`,
alphanumeric at each end) and resource names (RFC 1123 DNS subdomains).
Rewire every call site through it:

- `sanitize_label_value` delegates to `LabelValue::sanitize`, which trims
  leading separators *before* the 63-char cut so they no longer consume
  budget that is then discarded, and trims the separator a cut exposes.
- `generate_plan_name` uses `truncate_name_prefix`. It previously cut the
  policy name at 215 chars and appended `-plan-{ts}-{hash}` without
  trimming, so a legal 253-char name with a `.` on the cut produced
  `...a.-plan-...`, whose segment after the dot starts with `-`. The API
  server rejects that, and the policy stops reconciling.
- `is_valid_secret_name` delegates to `is_valid_resource_name`. It
  required a lowercase *letter* first, but RFC 1123 permits a leading
  digit, so a legal Secret such as `9db-creds` was rejected. Validation
  is now per-DNS-label, so `db..creds` is correctly rejected too.
- `default_generated_secret_name` shares the same truncation helper.

Replace the string-formatted label selectors with `kube`'s typed
`Selector`/`Expression` and `ListParams::labels_from`, so selector
construction cannot go wrong by string manipulation.

Both bugs were of a shape that is easy to miss by example and immediate
to catch by property: an input long enough to truncate, with a separator
landing exactly on the cut. `tests/identifier_properties.rs` restates the
rules independently of the implementation and asserts every builder
satisfies them for arbitrary input, over an alphabet biased towards the
characters that break things (NUL, `=`, `/`, separators, multi-byte
UTF-8). It also pins two invariants the selectors depend on: sanitising
is idempotent, and an already-valid value is returned unchanged — either
failing would make label lookups disagree with label writes.

The composition property walks every cut position for each generated name
rather than sampling a random budget. A random budget almost never lands
on a separator, so the dangerous case would be sampled too rarely to
catch a regression; checked against the pre-fix code, the sampled version
passed while the exhaustive one failed. The checked-in regressions file
holds the minimal counterexamples that broke the old code: `"_"`,
`"0.0a"`, and a trailing-dash truncation.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
Co-authored-by: Aaron Shewkani <aaron.shewkani@partly.com>
Centralising only the final truncation left `sanitize_secret_name_segment`
as a second, unshared source of Kubernetes naming rules — the exact thing
this module exists to prevent — and it sat outside the property suite.

Move it in as `sanitize_dns_label_segment` and property-test both it and
the composition `default_generated_secret_name` actually performs, so the
generated-Secret path is covered by the same invariants as the rest.

Also cover the plan-SQL ConfigMap identifiers, which are derived by
appending `-sql` to the plan name and carry three user-derived label
values. `generate_plan_name` reserves exactly 4 bytes for that suffix, so
a boundary-length policy name is where the reservation would be wrong.
This is a unit test rather than an E2E assertion because plan SQL only
spills to a ConfigMap above MAX_INLINE_SQL_BYTES, and generating 16 KiB
of SQL in a cluster to re-verify string composition buys nothing the pure
test does not already pin.

Reported by Codex review on #147 and #149.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
`cargo run --bin crdgen` without `--output-dir` prints *both* CRDs to
stdout separated by `---`, so redirecting it into `k8s/crd.yaml` writes a
two-document file. `check-crd-drift.sh` compares that path against the
single-document `postgrespolicies.pgroles.io.yaml`, so following the
documented steps produced drift rather than resolving it.

Document the `--output-dir` form the drift check actually models, and
both copies it compares.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
The property re-composed `{policy}-pgr-{role}` itself instead of calling
the production builder, so a change to the separator, segment order, or
length budget could break real Secret names while the property stayed
green. Call `generated_secret_name` with no `secretName` override, which
is the path that derives the name.

Reported by CodeRabbit review on #147.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
Unit and property tests check the identifier rules as this repository
restates them. The API server is the only complete oracle: it decides
what it actually accepts, including anything the restated rules model
wrongly. The kind cluster is already running in the operator E2E job, so
exercise both directions there.

A name one character over the label limit must be refused at admission,
and the failure must carry the CEL rule's message — proving the rule is
not merely present in the CRD but compiled and enforced by the API server.

A dotted name exactly at the limit must reconcile and its plan must be
discoverable through the label the operator wrote, which is the property
the plan lookup, approval, and cleanup paths all depend on. The scenario
then asserts the derived plan name is a name the API server accepted and
that both label values it wrote satisfy the label rules, so a regression
in either the truncation or the sanitising surfaces here.

The sample uses manual approval because that, not `mode: plan`, is what
makes the operator create a PostgresPolicyPlan and its SQL ConfigMap.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
… stack

Add an E2E scenario for policy-name identifiers. Unit and property tests
check the rules as this repository restates them; the API server is the
only complete oracle for what it actually accepts. A name one character
over the limit must be refused at admission *with the CEL rule's message*,
which proves the rule is compiled and enforced rather than merely present
in the CRD. A dotted name exactly at the limit must reconcile with its
plan discoverable by the label the operator wrote, and the derived plan
name and label values are then checked against the rules.

The plan SQL stays inline at this size, so no plan-SQL ConfigMap is
created; the sample and the scenario say so rather than implying coverage
they do not have. That path is unit-tested instead.

Gate the expensive jobs on stack position. In a stacked pull request the
top entry contains the full changeset, so the integration matrix, the
Docker and example smoke tests, and the three kind-based E2E jobs now run
once per stack instead of once per entry, while lint and unit tests stay
ungated so every entry keeps fast feedback. On a three-entry stack that is
cheaper than the current behaviour, not just cheaper than removing the
`branches` filter — which is unnecessary anyway, since GitHub now triggers
workflows as if every entry targeted the stack's base.

The gate is deliberately fail-safe: `stack` is null for pushes and for
ordinary non-stacked pull requests, and anything not positively identified
as a non-top stack entry runs the full suite, so a wrong guess costs CI
time rather than coverage. Centralised in one job so the condition exists
once instead of being copied into six.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
…ones

The previous scenario asserted a 64-character name was refused at
admission, which only held while the CRD carried a CEL length rule. That
rule is gone: identifying plans by owner UID makes a lossy label harmless,
so capping names at 63 restricted users for no remaining benefit.

Test the boundary that still matters instead. The fixture is the maximum
legal 253-byte DNS subdomain, and its 215th byte is a dot — exactly where
`generate_plan_name` cuts the policy-name prefix. Without trimming the
separator the cut exposes, the appended `-plan-...` begins a DNS label
with `-` and the API server rejects the plan. This is a sharper test of
`truncate_name_prefix` than the old 63-character fixture, which never
reached the truncation path at all.

The scenario now also asserts the plan's controller-owner UID matches the
policy, and that the policy label really is truncated to 63 bytes, so the
case cannot quietly stop being a boundary if the fixture changes.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
`pgroles.io/policy` was the only link between a policy and its plans and
plan-SQL ConfigMaps, but the label value is truncated at 63 characters
while a policy name may be 253. Two policies sharing a 63-character prefix
therefore selected each other's objects, and that selector result drives
deletion in `cleanup_old_plans` and `cleanup_orphan_sql_configmaps` as
well as approval and deduplication in the three lookup paths. The module
documentation for `LabelValue::sanitize` warns against exactly this use.

Filter every one of those six paths by controller-owner UID. The label
still narrows server-side, which keeps the list cheap; the UID decides.
Owner references are already written on both plans and ConfigMaps, so
this needs no new label and no migration for existing objects.

The ConfigMap path is why this has to be done as one change rather than
plan-side only. `is_orphan_sql_configmap` treats a ConfigMap whose plan
label is absent from the known set as an orphan and deletes it, so
exact-filtering the plans while leaving the ConfigMap list
truncation-matched would have made a colliding policy's ConfigMap look
*more* like an orphan — turning a lookup bug into a deletion bug.

Apply the same fix to the plan-to-policy watch mapping in `main.rs`, which
compared the truncated label against `metadata.name` and so silently never
matched for any name over 63 characters, leaving plan status changes
unable to wake the policy reconciler.

UID rather than `spec.policyRef.name` because it is exact per object
*lifetime*: a replacement policy with the same name is a different object
and must not inherit the deleted one's plans. An absent UID is treated as
owning nothing rather than as a wildcard.

Reported by CodeRabbit review on #147.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
Self-review follow-ups on the identifier stack.

The 409 create-conflict path adopted whatever plan held the colliding name
and patched its status without an ownership check, making it the one
mutation site the owner-UID discipline did not cover. Reaching it across
policies needs two names sharing a 215-byte truncated prefix colliding in
the same second, so this is defence in depth rather than a live bug, but
the check belongs there for the same reason as the other six.

Harden the stack gate. Stacked pull requests are in public preview, and
if `position`/`size` semantics ever changed then `position == size` would
be silently false for the real top entry — the stack would merge with the
expensive suite never having run. Assert the documented 1-based range and
fail loudly instead of skipping wrongly.

Document two migration hazards that CI cannot catch, since the E2E kind
cluster runs a Kubernetes version where neither applies:

- Before Kubernetes 1.30 there is no CRD validation ratcheting, so an
  existing policy whose name exceeds the new 63-character limit has every
  write rejected, including the operator's own status updates. It stops
  making progress with nothing in its status to explain why.
- `--cascade=orphan` strips the owner references that both re-adoption and
  garbage collection now depend on, so orphaned plans and plan-SQL
  ConfigMaps persist until deleted by hand.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
…match

The stack-metadata guard could be bypassed by the thing it guards against.
`[ non-numeric -lt 1 ]` writes "integer expression expected" to stderr and
evaluates *false*, and a failing command inside an `if` condition does not
trip `bash -e`, so the step continued and the expensive suite could still
be skipped — exactly the silent-skip the guard was added to prevent.
Reproduced it, then validated both values as non-negative integers before
comparing, forcing decimal with `10#` so a zero-padded value is never read
as octal. Verified against non-numeric, negative, exponent-form,
zero-padded, partial, in-range and out-of-range inputs; the unstacked
empty case still fails open as intended.

The ownership-mismatch arm of the plan-creation conflict path returned
without deleting the SQL ConfigMap it had already created, unlike the
sibling error arm. The orphan reaper would collect it, since it carries
this policy's UID, but the leak is pointless. Clean it up.

Reported by CodeRabbit review on #151.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
The unit tests cover `is_owned_by_policy` directly, but nothing exercised
two genuinely colliding policies against a real API server — which is the
scenario the whole layer exists for.

Two policies sharing a 63-byte prefix produce an identical
`pgroles.io/policy` label, and the scenario asserts that collision holds
before relying on it, so the fixture cannot silently stop colliding. It
then checks the three things the label previously decided and the owner
UID now decides: each plan is owned by its own policy's UID, approving one
policy's plan leaves the other's Pending with its role uncreated, and
deleting one policy garbage-collects only its own plan while the other
stays discoverable and still applies cleanly afterwards.

That last step is the important one. `is_orphan_sql_configmap` deletes
objects whose plan label is absent from the known set, so a half-applied
ownership filter would have turned this collision into cross-policy
deletion rather than merely a confused lookup.

Also drop the policy-name limit from the limitations page, which described
a CRD rule that no longer exists, keeping only the `--cascade=orphan`
note, which is a genuine consequence of matching by owner UID.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The operator adds shared Kubernetes identifier handling, uses controller-owner UIDs to isolate plans and ConfigMaps, adds boundary and property tests, documents orphan cleanup, and expands E2E coverage. CI now gates expensive jobs for stacked pull requests.

Changes

Operator naming and ownership

Layer / File(s) Summary
Kubernetes identifier contracts
crates/pgroles-operator/src/k8s_names.rs, crates/pgroles-operator/src/lib.rs, crates/pgroles-operator/Cargo.toml, crates/pgroles-operator/tests/*, AGENTS.md
The operator adds shared validators, sanitizers, truncation helpers, validated identifier types, and property-based tests.
Resource-name generation integration
crates/pgroles-operator/src/crd.rs, crates/pgroles-operator/src/password.rs, crates/pgroles-operator/src/plan.rs, k8s/samples/max-length-name-policy.yaml
Secret, plan, and ConfigMap naming uses shared Kubernetes limits and sanitization rules. Boundary tests and a maximum-length policy sample cover the generated names.
Controller-owner UID isolation
crates/pgroles-operator/src/main.rs, crates/pgroles-operator/src/plan.rs, .github/workflows/ci.yml, docs/src/pages/docs/limitations.md
Plan and ConfigMap lookup, collision handling, cleanup, and reconciliation filter by controller-owner UID. Unit and E2E tests cover colliding labels, recreated policies, long names, approvals, and cleanup. The documentation describes orphaned resources.
Stack-gated CI execution
.github/workflows/ci.yml, AGENTS.md
The stack-gate job validates stacked pull-request metadata and exposes run_heavy. Integration, Docker, example, operator E2E, load, and plan-lifecycle jobs use this output to skip non-top stack entries.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PostgresPolicy
  participant Plan
  participant SQLConfigMap
  participant Reconciler
  PostgresPolicy->>Plan: create plan with controller-owner UID
  Plan->>SQLConfigMap: persist plan SQL
  Reconciler->>Plan: list plans by policy label
  Reconciler->>Plan: filter by controller-owner UID
  Reconciler->>SQLConfigMap: clean up owned resources
Loading

Possibly related PRs

  • hardbyte/pgroles#147: Adds the related Kubernetes identifier refactor across the same operator code.
  • hardbyte/pgroles#101: Modifies related plan and ConfigMap labeling, lookup, and cleanup behavior.
  • hardbyte/pgroles#74: Provides related plan lifecycle behavior extended by the owner-UID changes.

Poem

A rabbit checks each name with care,
Then guards each plan by UID there.
Long labels hop, bad dashes flee,
While stacked CI rests selectively.
Clean plans bloom, collisions cease—
The burrow runs in ordered peace.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: generating valid Kubernetes identifiers from arbitrary policy names.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The two user-visible behaviour changes in this series had no changelog
entry: valid identifiers from any policy name, and owner-UID identity
replacing the truncated label. Both affect anyone running a policy whose
name exceeds 63 characters, and the second carries an upgrade note about
--cascade=orphan.

Credits @aarons-afk for the original report and fix in #146.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ac381f0e1a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/pgroles-operator/src/plan.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
crates/pgroles-operator/src/k8s_names.rs (1)

122-143: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

sanitize_dns_label_segment trusts fallback without validating it.

The function guarantees a valid DNS label for every input except when fallback is returned. fallback is passed through unchecked. Current callers in password.rs pass "policy" and "role", so the behavior is correct today. A future caller that passes an empty or non-conforming fallback silently breaks the documented guarantee. Consider sanitizing the fallback too, or state the caller contract in the doc comment.

♻️ Proposed doc/contract tightening
 /// Input is lowercased; every run of characters outside `[a-z0-9]` collapses to
 /// a single `-`; leading and trailing `-` are dropped. `fallback` is returned if
 /// nothing survives, so the result is always a usable segment.
+///
+/// The caller must pass a `fallback` that is already a valid DNS 1123 label.
+/// The function does not sanitize it.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/pgroles-operator/src/k8s_names.rs` around lines 122 - 143, Update
sanitize_dns_label_segment so its fallback path also returns a valid DNS label:
sanitize the fallback using the same normalization rules and provide a safe
non-empty default if the fallback is empty or invalid. Preserve the existing
behavior for valid inputs and avoid recursive fallback handling.
crates/pgroles-operator/src/plan.rs (1)

1109-1121: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The 215 byte budget is derived from an unstated timestamp.len() assumption.

max_prefix_len subtracts timestamp.len() at runtime, but the comment at line 355 and the reserved -4 both assume the 15-byte YYYYMMDD-HHMMSS shape. The subtraction chain would underflow and panic if format_timestamp_compact() ever returned a longer string. Consider a debug assertion or saturating_sub so a future change to the timestamp format fails loudly rather than panicking in the reconcile loop.

♻️ Proposed hardening
     let max_name_len = crate::k8s_names::MAX_RESOURCE_NAME_LENGTH - 4; // 249
-    let max_prefix_len = max_name_len - "-plan-".len() - timestamp.len() - "-".len() - suffix.len();
+    let reserved = "-plan-".len() + timestamp.len() + "-".len() + suffix.len();
+    debug_assert!(reserved < max_name_len, "plan-name suffix budget exhausted");
+    let max_prefix_len = max_name_len.saturating_sub(reserved);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/pgroles-operator/src/plan.rs` around lines 1109 - 1121, Harden
generate_plan_name against a timestamp longer than the assumed format by
preventing max_prefix_len arithmetic from underflowing. Add an explicit
invariant check that timestamp matches the expected compact format/length and
fails loudly, or use saturating subtraction while preserving the resulting
valid-name behavior; update the nearby budget assumption if needed.
.github/workflows/ci.yml (1)

396-406: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the duplicated limit_name literal.

limit_name copies metadata.name from k8s/samples/max-length-name-policy.yaml, so an unrelated manifest rename makes wait_for_ready_status_reason wait for a non-existent policy. Derive the name from the manifest instead of duplicating it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 396 - 406, Replace the hardcoded
limit_name value in the CI workflow with the metadata.name read from
k8s/samples/max-length-name-policy.yaml, then pass that derived value to
wait_for_ready_status_reason. Keep the manifest application and readiness check
behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 408-413: Validate that policy_uid and plan_owner_uid are non-empty
before comparing them in the long-name ownership check at
.github/workflows/ci.yml lines 408-413. Apply the same non-empty validation to
uid_a, uid_b, owner_a, and owner_b before the equality assertion at
.github/workflows/ci.yml lines 878-940; retain the existing failure behavior
when any value is missing or mismatched.

In `@AGENTS.md`:
- Around line 28-32: Update the neighboring CRD drift description in AGENTS.md
to say that CI enforces all four committed CRD copies match crdgen output,
aligning it with the “four committed copies” wording near the cargo run and copy
commands.

In `@k8s/samples/max-length-name-policy.yaml`:
- Around line 22-28: Resolve the contradiction between the policy’s mode and the
role comment: either change mode from apply to plan to preserve “Preview-only,”
or reword the comment under hostile_name_preview_user to state that the role is
created after manual approval.

---

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 396-406: Replace the hardcoded limit_name value in the CI workflow
with the metadata.name read from k8s/samples/max-length-name-policy.yaml, then
pass that derived value to wait_for_ready_status_reason. Keep the manifest
application and readiness check behavior unchanged.

In `@crates/pgroles-operator/src/k8s_names.rs`:
- Around line 122-143: Update sanitize_dns_label_segment so its fallback path
also returns a valid DNS label: sanitize the fallback using the same
normalization rules and provide a safe non-empty default if the fallback is
empty or invalid. Preserve the existing behavior for valid inputs and avoid
recursive fallback handling.

In `@crates/pgroles-operator/src/plan.rs`:
- Around line 1109-1121: Harden generate_plan_name against a timestamp longer
than the assumed format by preventing max_prefix_len arithmetic from
underflowing. Add an explicit invariant check that timestamp matches the
expected compact format/length and fails loudly, or use saturating subtraction
while preserving the resulting valid-name behavior; update the nearby budget
assumption if needed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e3fde8df-8d6b-4b74-bbc8-d1b4190b5e8b

📥 Commits

Reviewing files that changed from the base of the PR and between 4bbc955 and ac381f0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • .github/workflows/ci.yml
  • AGENTS.md
  • crates/pgroles-operator/Cargo.toml
  • crates/pgroles-operator/src/crd.rs
  • crates/pgroles-operator/src/k8s_names.rs
  • crates/pgroles-operator/src/lib.rs
  • crates/pgroles-operator/src/main.rs
  • crates/pgroles-operator/src/password.rs
  • crates/pgroles-operator/src/plan.rs
  • crates/pgroles-operator/tests/identifier_properties.proptest-regressions
  • crates/pgroles-operator/tests/identifier_properties.rs
  • docs/src/pages/docs/limitations.md
  • k8s/samples/max-length-name-policy.yaml

Comment thread .github/workflows/ci.yml
Comment thread AGENTS.md
Comment thread k8s/samples/max-length-name-policy.yaml Outdated
Codex caught a real gap in the cleanup added on the plan-name conflict
path. create_plan_sql_configmap adopts an existing ConfigMap on 409 after
checking the SQL hash alone, and the name embeds a truncated plan name,
so two policies with colliding prefixes and identical SQL adopt each
other's artifact. The rollback then deleted a ConfigMap belonging to
another policy, breaking a plan already pending approval.

Two changes, matching the discipline already applied elsewhere:

- Check ownership before adopting. is_owned_by_another_policy is
  deliberately narrower than !is_owned_by_policy: only a live claim by a
  different policy blocks adoption, so an unowned orphan stays adoptable
  and recovery from --cascade=orphan still works. Fails closed when our
  own UID is absent.
- Track whether this invocation created the ConfigMap, and roll back only
  then. The plan itself already had this guard via created_new_plan; the
  ConfigMap did not, and that asymmetry was the bug.

Also from review:

- Guard the E2E UID assertions against empty jsonpath output. Not vacuous
  today, since a live policy always has a UID, but  =  must never read
  as proof of ownership in a test whose whole purpose is that comparison.
- Read the max-length policy name back from the applied object instead of
  repeating the 253-byte literal in the workflow.
- Correct AGENTS.md: check-crd-drift.sh compares four committed copies,
  not two.
- sanitize_dns_label_segment returns fallback verbatim, so document that
  it must already be a valid DNS label and assert it in debug builds.
- Drop the Preview-only role comment in the sample; with mode: apply the
  role is created once the plan is approved.

Not changed: the max_prefix_len subtraction in generate_plan_name is
flagged as a potential underflow, but generate_plan_name_has_expected_format
pins the timestamp suffix at 28 characters, so a format change fails in CI
long before it could underflow. saturating_sub would instead emit a name
starting with '-plan-', which the API server rejects.

Claude-Session: https://claude.ai/code/session_01KKdSeCxJEVcrH4MPLgwUG4
@hardbyte
hardbyte merged commit 34f2ced into main Aug 2, 2026
14 checks passed
@hardbyte hardbyte mentioned this pull request Aug 2, 2026
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