Skip to content

feat(flux): bootstrap verified OCI source - #2164

Open
jfroy wants to merge 1 commit into
oci-releasefrom
oci-bootstrap
Open

feat(flux): bootstrap verified OCI source#2164
jfroy wants to merge 1 commit into
oci-releasefrom
oci-bootstrap

Conversation

@jfroy

@jfroy jfroy commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

  • bootstrap OCIRepository/flux-system alongside the existing Git source
  • require the cluster artifact to match the keyless signing identity of cluster-release.yaml on main
  • replace the push webhook with a generic-oidc Receiver restricted to the exact repository, ref, and workflow
  • notify Flux only after the signed artifact is promoted
  • retry notification during the initial Receiver rollout
  • remove the obsolete shared webhook ExternalSecret

The bootstrap source carries kustomize.toolkit.fluxcd.io/prune: Disabled so it remains available when FluxInstance takes over ownership in the final migration PR.

Rollout gate

Merge only after the artifact-publisher PR has completed successfully. Before advancing the stack, confirm:

  • OCIRepository/flux-system is Ready and SourceVerified
  • Receiver/cluster-release is Ready
  • a manual Cluster Release run successfully reaches the Notify Flux step

The deployed Receiver CRD was checked and supports generic-oidc without secretRef.

Stack

  1. Publish and seed the signed artifact
  2. This PR: bootstrap the verified OCI source and Receiver
  3. Migrate default-namespace Kustomizations
  4. Complete the source migration and transfer source ownership to FluxInstance

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3356bb29-0451-4a37-88b1-097fe5c1592a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@daddy-ro

daddy-ro Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

konflate — summary

Note

+2 added · 0 changed · −3 removed — 5 resources · 1 app

Caution

1 render failure

View the full rendered diff →

konflate · rendered 329acf6 · advisory, not a gate

Comment on lines 19 to 22
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dropping GitRepository/flux-system (and Kustomization/flux-system) from resources leaves the cluster with no push-triggered reconciliation at all for the duration of the stack.

After this PR:

  • Receiver/github-webhook is gone, so the webhook path registered in GitHub repo settings (sha256(github-webhook + flux-system + token)) 404s, and the new Receiver can't accept it anyway — it's generic-oidc, not github/HMAC.
  • The only resource this Receiver pokes is OCIRepository/flux-system, which nothing consumes yet: kubernetes/cluster/ks.yaml (cluster-apps, cluster-vap) and the FluxInstance sync (kind: GitRepository, interval: 1h) all still source from Git.

So from merge until stack step 4 lands, every change to main waits on the 1h sync interval plus each child Kustomization's own 1h interval instead of reconciling in seconds.

Cheap fix — keep the Git source in the list until the source migration completes, then remove it:

Suggested change
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
name: flux-system
# TODO: drop once the sync source migrates to OCIRepository (stack step 4).
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system

# Keep the source in place when FluxInstance takes over its management.
kustomize.toolkit.fluxcd.io/prune: Disabled
spec:
interval: 5m

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

interval: 5m deviates from every other OCIRepository in this repo (app-template, flux-instance, flux-operator all use 1h) and from the FluxInstance sync interval. Because ref.tag: latest is mutable, this re-resolves the tag against GHCR 12×/hour, and each resolution runs keyless cosign verification against Fulcio/Rekor. The Receiver added in this PR is the push path, so the poll interval is only a fallback — 1h matches convention and keeps sigstore traffic down.

Suggested change
interval: 5m
interval: 1h

Comment on lines +113 to +121
for attempt in $(seq 1 30); do
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The retry loop treats every non-2xx the same, so a permanent rejection burns the full 5 minutes and then fails with no useful signal. That is the likely failure mode here: if any of the three CEL validations doesn't match, notification-controller returns 403, and if the hardcoded path drifts from status.webhookPath it returns 404 forever. Both are indistinguishable from "Receiver not rolled out yet".

Consider capturing the status code and only retrying on connection errors / 5xx (and 404 for the first few attempts), failing fast and loudly on 403:

code="$(curl --silent --output /dev/stderr --write-out '%{http_code}' \
  --request POST --header "Authorization: Bearer ${token}" "${FLUX_RECEIVER_URL}")"
case "${code}" in
  2*) exit 0 ;;
  403) echo "Receiver rejected the OIDC token (CEL validation failed)"; exit 1 ;;
esac

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

Verified independently:

  • kubernetes/cluster/ has no kustomization.yaml, so ocirepository.yaml is picked up by kustomize-controller's generated kustomization the same way ks.yaml is — no wiring needed. namespace: flux-system is set explicitly, matching ks.yaml (that directory gets no NamespaceTransformer).
  • The hardcoded webhook path checks out: sha256("cluster-release" + "flux-system") = ef1ac25e…1692ab5, which is what notification-controller derives when there is no token. It will break silently on any rename, but the comment above it flags the coupling.
  • The cosign identity in matchOIDCIdentity matches the --certificate-identity asserted in the Sign and verify artifact step, and signing happens against the digest before flux tag artifact … --tag latest, so latest resolves to a signed digest.
  • claims.repository / claims.ref / claims.workflow_ref are all real GitHub OIDC claims and the audience matches the &audience=notification-controller request. The if: github.ref == 'refs/heads/main' job guard is consistent with the claims.ref validation.
  • Webhook-secret cleanup is complete — no dangling references to github-webhook, github-webhook-token-secret, or FLUX_GITHUB_WEBHOOK_TOKEN remain anywhere in the tree. The flux-webhook.kantai.xyz hostname and webhook-receiver backend are unchanged, so the public route survives the HTTPRoute rename.

Three comments inline. The one that matters is the Receiver's resources list: this PR removes the last thing that pokes the Git source while the Git source is still what actually drives the cluster, so the intermediate state of the stack has no push-triggered reconciliation — only the 1h intervals.

Two things I could not verify from this environment (no cluster or network access), and which your rollout gate should cover explicitly:

  1. spec.type: generic-oidc with oidcProviders[].validations — I could not confirm this against the deployed Receiver CRD schema. You state in the description that you checked it; worth confirming Receiver/cluster-release reaches Ready and that a real dispatch passes all three CEL validations (a schema-valid Receiver that rejects every token looks identical to a healthy one until the first release runs).
  2. Whether ghcr.io/jfroy/flatops/cluster is actually public. The Verify anonymous pull access step covers it in CI, but the OCIRepository has no secretRef, so if the package is private the source never goes Ready.

Neither is blocking on its own — nothing depends on OCIRepository/flux-system yet, and the FluxInstance sync Kustomization doesn't wait on health, so a non-Ready source here won't wedge reconciliation. It will just sit unhealthy until step 3.

Comment on lines 19 to 22
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This drops push-triggered reconciliation of the Git source, and nothing consumes OCIRepository/flux-system yet.

Every Kustomization in the repo still has sourceRef: {kind: GitRepository, name: flux-system}cluster-apps and cluster-vap (kubernetes/cluster/ks.yaml), flux-instance, and the FluxInstance sync itself (sync.kind: GitRepository, interval: 1h in instance/ks/helm-values.yaml). The old github-webhook Receiver poked GitRepository/flux-system and Kustomization/flux-system; the new one pokes only the OCI source. So from the moment this merges until PRs 3 and 4 land, every merge to main waits on the 1h GitRepository interval.

That also creates a bootstrap gap for this PR itself: Receiver/cluster-release only exists after flux-instance reconciles the renamed manifest, which is now up to an hour after merge — well past the 5-minute retry window in the workflow. The Notify Flux step on this PR's own merge commit will almost certainly exhaust its 30 attempts unless you flux reconcile source git flux-system && flux reconcile ks flux-instance manually.

Since the release workflow runs on push to main, keeping the Git resources in the trigger list restores the old behaviour for free and can be removed in PR 4:

Suggested change
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
name: flux-system
# Transitional: most Kustomizations still source from Git until the
# source migration completes. Remove once the migration lands.
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
name: flux-system

Comment on lines +24 to +25
# Must match status.webhookPath for Receiver cluster-release/flux-system.
FLUX_RECEIVER_URL: https://flux-webhook.kantai.xyz/hook/ef1ac25eeff8057bf82070418a3eabb755c4821b29ff32377c75863bc1692ab5

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This digest can't be validated from Git, and this PR changes both inputs that feed it.

status.webhookPath is computed by notification-controller from the Receiver object itself, and this PR renames it (github-webhookcluster-release) and removes secretRef — so the path will not be whatever the currently-deployed github-webhook Receiver reports, and it isn't the sha256 of the old token either. git log -S shows this hash is new in this PR, so it wasn't read off a live object.

Please confirm it against the live object after the Receiver reconciles, before relying on it:

kubectl -n flux-system get receiver cluster-release -o jsonpath='{.status.webhookPath}'

Two follow-ons worth noting since the value is a literal:

  • notification-controller derives the path partly from Receiver-instance identity, so any prune-and-recreate of Receiver/cluster-release (namespace rebuild, moving the manifest between Kustomizations, spec immutable-field churn) rotates the path and silently breaks releases with no signal in Git. A PrometheusRule on the workflow failing, or a kubectl lookup at the start of the Notify Flux step, would catch it.
  • there is no Receiver/github-webhookcluster-release migration for the GitHub-side webhook. The repo/org webhook still points at the old /hook/<old-digest> path and will start returning 404 on every push once the old Receiver is pruned. It should be deleted in the GitHub UI as part of this rollout.

Comment on lines +93 to +103
for attempt in $(seq 1 30); do
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done
exit 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The retry loop can't distinguish "Receiver not rolled out yet" from "permanently misconfigured", so both cost 5 minutes and produce the same misleading log line.

--fail-with-body makes curl fail on any 4xx/5xx. A wrong FLUX_RECEIVER_URL digest → 404, a rejected OIDC token (bad audience, or a validations expression that doesn't match) → 401/403. None of those become truthy on retry, but each burns 30 attempts and prints Flux Receiver unavailable, which points the reader at the cluster rather than at the token or the URL.

Capturing the status code lets you retry only what's actually transient and surface the CEL rejection message from the Receiver on the terminal cases:

Suggested change
for attempt in $(seq 1 30); do
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done
exit 1
for attempt in $(seq 1 30); do
body="$(mktemp)"
code="$(curl --silent --show-error \
--output "${body}" --write-out '%{http_code}' \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}")"
case "${code}" in
2*)
exit 0
;;
404|502|503|504)
echo "Flux Receiver unavailable (HTTP ${code}, attempt ${attempt}/30); retrying in 10 seconds"
;;
*)
echo "Flux Receiver rejected the notification (HTTP ${code}):"
cat "${body}"
exit 1
;;
esac
sleep 10
done
exit 1

Note that 404 stays in the retry set on purpose — notification-controller returns 404 both for an unknown path and for a Receiver it hasn't indexed yet, so it's ambiguous. That makes the status.webhookPath check in my other comment the thing that actually distinguishes them.

Comment on lines +8 to +15
annotations:
# Keep the source in place when FluxInstance takes over its management.
kustomize.toolkit.fluxcd.io/prune: Disabled
spec:
interval: 5m
ref:
tag: latest
url: oci://ghcr.io/jfroy/flatops/cluster

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two things to confirm on this source, both of which the rollout gate would catch but are cheaper to check up front:

No pull credentials. There's no secretRef / serviceAccountName / provider, and there is no registry secret anywhere under kubernetes/apps/flux-system/ — every other OCIRepository in the repo targets a public upstream (bjw-s-labs, controlplaneio-fluxcd). This only works if the jfroy/flatops/cluster GHCR package is public. Packages published with GITHUB_TOKEN inherit repo visibility, so it probably is, but worth verifying on the package settings page since a private package fails both the pull and the cosign signature lookup with a 401 that reads like a verification failure.

prune: Disabled needs the file removed in PR 4, not just left in place. The annotation stops kustomize-controller from deleting the object, but it does not stop it from applying it. Once FluxInstance switches to sync.kind: OCIRepository it will manage OCIRepository/flux-system too, and if this file is still in kubernetes/cluster/ both controllers will apply competing specs on every reconcile. Deleting the file in the final PR is what makes the annotation do its job — the object survives unowned and flux-operator adopts it.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

The signing/verification chain lines up: cosign sign attaches to the digest, flux tag artifact moves latest to that same digest, and the matchOIDCIdentity subject regex matches the ${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF} identity the workflow signs with (https://github.com/jfroy/flatops/.github/workflows/cluster-release.yaml@refs/heads/main). The three validations expressions agree with the claims a push-or-workflow_dispatch-on-main run actually presents, the requested audience matches oidcProviders[0].audience, and id-token: write is already in permissions. ocirepository.yaml gets picked up because kubernetes/cluster/ has no kustomization.yaml, so kustomize-controller auto-generates one over the directory — same way ks.yaml is picked up today. Dropping github-webhook-token is clean; nothing else in the repo references it or FLUX_GITHUB_WEBHOOK_TOKEN.

Four things flagged inline. The two that gate the rollout:

  • The hardcoded FLUX_RECEIVER_URL digest is unverified. Renaming the Receiver and dropping secretRef both change what notification-controller derives status.webhookPath from, and git log -S shows this hash first appears in this PR — so it wasn't read off a live object. Check it against the reconciled Receiver before trusting it.
  • Push-triggered reconciliation of the Git source disappears. Every Kustomization still sources from GitRepository/flux-system, including the FluxInstance sync, and the new Receiver pokes only the OCI source. Until PRs 3 and 4 land, merges to main wait on the 1h interval. That also means Receiver/cluster-release itself may not exist until an hour after this merges, which is longer than the 5-minute retry window — so this PR's own Notify Flux step will likely need a manual flux reconcile to succeed.

The other two are smaller: the retry loop can't tell a 404/403 misconfiguration from a slow rollout, and the new OCIRepository has no pull credentials (fine only if the GHCR package is public).

One addition to the rollout gate in the description: the GitHub-side repo webhook pointing at the old /hook/<digest> path has no migration here and should be deleted manually, otherwise it 404s on every push once the old Receiver is pruned.

Comment on lines +22 to +23
# Must match status.webhookPath for Receiver cluster-release/flux-system.
FLUX_RECEIVER_URL: https://flux-webhook.kantai.xyz/hook/ef1ac25eeff8057bf82070418a3eabb755c4821b29ff32377c75863bc1692ab5

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This literal path can't be right yet, and it's the one thing here that can't be validated before merge.

status.webhookPath is generated by notification-controller as a SHA-256 digest that includes the Receiver's UID (plus the token, empty here). This PR renames the Receiver — github-webhookcluster-release — so kustomize-controller will create a brand-new object with a brand-new UID, and its path will not be whatever was read out of the cluster to produce this value. The only way this digest is already correct is if Receiver/cluster-release was applied to the cluster out-of-band and Flux then adopts that same object (same UID); if it ever gets pruned and recreated, the path silently changes again.

Failure mode is quiet: the merge run publishes and promotes the artifact fine, then Notify Flux POSTs to a path notification-controller doesn't serve, gets 404 for 30 attempts, and fails the job after 5 minutes — while the cluster keeps running old state until the 1h Git interval fires.

Please confirm after this reconciles:

kubectl -n flux-system get receiver cluster-release -o jsonpath='{.status.webhookPath}'

And consider moving the URL out of the tree into a repository variable (vars.FLUX_RECEIVER_URL), so a Receiver recreation is a settings change rather than a commit that re-triggers the very workflow it's fixing.

Comment on lines +91 to +104
token="$(curl --fail-with-body --silent --show-error \
--header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=notification-controller" \
| jq -er '.value')"
for attempt in $(seq 1 30); do
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The ID token is minted once, outside a loop that can run for 5 minutes. GitHub Actions OIDC tokens are short-lived (exp - iat is on the order of minutes), so in exactly the scenario this retry exists for — Receiver not yet reconciled — the later attempts can start failing 401 on an expired token rather than 404 on a missing path, and the log line will still say "Flux Receiver unavailable". Minting inside the loop is cheap and removes the ambiguity:

Suggested change
token="$(curl --fail-with-body --silent --show-error \
--header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=notification-controller" \
| jq -er '.value')"
for attempt in $(seq 1 30); do
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done
run: |
get_token() {
curl --fail-with-body --silent --show-error \
--header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=notification-controller" \
| jq -er '.value'
}
for attempt in $(seq 1 30); do
token="$(get_token)"
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done

Separately: a 403 from a failed CEL validation is permanent, and retrying it 30 times buys nothing but 5 minutes of confusing logs. Worth breaking out of the loop on 4xx-other-than-404 if you want the misconfiguration to surface fast.

Comment on lines 19 to 22
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dropping GitRepository/flux-system (and Kustomization/flux-system) from resources leaves the Git source with no notification path for the duration of the migration.

Everything still consuming Git today — the FluxInstance sync GitRepository (interval: 1h), cluster-apps, cluster-vap, flux-instance — only picks up a merge on its 1h poll once the old github Receiver is pruned. That's the window in which stack PRs 3 and 4 land, so those two merges go from near-instant to up-to-an-hour reconcile unless you flux reconcile kustomization cluster-apps --with-source by hand each time.

Cheap fix while the stack is in flight — the release workflow already fires on every kubernetes/** push to main, so one notification can poke both sources:

Suggested change
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
name: flux-system
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system

Then drop the GitRepository entry in PR 4 when the Git source goes away.

Comment on lines +11 to +15
spec:
interval: 5m
ref:
tag: latest
url: oci://ghcr.io/jfroy/flatops/cluster

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No secretRef, so source-controller pulls anonymously. GHCR packages created by a GITHUB_TOKEN push default to private — unlike the other ghcr.io/jfroy/* images in this repo, which are all pulled without pull secrets because they've been flipped to public. If ghcr.io/jfroy/flatops/cluster was auto-created by the artifact-publisher run and nobody changed its visibility, this will sit in 401 Unauthorized rather than Ready.

Your rollout gate ("OCIRepository/flux-system is Ready and SourceVerified") catches it, but worth confirming the package visibility before merging rather than after.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Reviewed the OCI bootstrap. The pieces that matter check out: kubernetes/cluster/ has no kustomization.yaml, so kustomize-controller auto-generates one and picks up ocirepository.yaml — the explicit namespace: flux-system is needed there (no NamespaceTransformer on that path) and is present. prune: Disabled correctly keeps the source alive through the FluxInstance ownership transfer. The cosign matchOIDCIdentity subject regex is properly anchored and matches the --certificate-identity the workflow verifies against; latest resolves to the signed digest, so promotion does not break verification. The Receiver CEL claims (repository, ref, workflow_ref) line up with the OIDC audience requested in the workflow, and the artifact layout (kubernetes/ under the artifact root) keeps existing ./kubernetes/... Kustomization paths valid after the source switch.

Four things flagged inline, one of which I would resolve before merging:

  • FLUX_RECEIVER_URL is a hardcoded status.webhookPath, and this PR renames the Receiver. The digest includes the object UID, so a freshly created cluster-release Receiver will not have the path baked into the workflow unless it was applied out-of-band and Flux adopts that exact object. Silent failure mode: artifact publishes and promotes, Notify Flux 404s for 5 minutes and fails, cluster stays on old state until the 1h interval.
  • OIDC token minted once outside a 5-minute retry loop — can expire mid-loop and produce 401s labelled "Receiver unavailable".
  • GitRepository/flux-system dropped from the Receiver resources, so Git-sourced reconciliation has no webhook for the rest of the migration (affects stack PRs 3 and 4 themselves).
  • OCIRepository pulls anonymously; confirm the new GHCR package is public.

Two bits of housekeeping the diff implies but does not cover: the GitHub repository webhook pointing at the old /hook/... path becomes dead once the github Receiver is pruned (GitHub will start reporting delivery failures and may auto-disable it), and FLUX_GITHUB_WEBHOOK_TOKEN in the 1Password flux item is now unused after removing the ExternalSecret.

namespace: flux-system
annotations:
# Keep the source in place when FluxInstance takes over its management.
kustomize.toolkit.fluxcd.io/prune: Disabled

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Disabled is capitalized here. Flux's documented value for this annotation is disabled, and kustomize-controller passes kustomizev1.DisabledValue ("disabled") as an exact-match exclusion when computing the prune set — every other occurrence in this repo uses lowercase (kubernetes/components/common/kustomization.yaml:15, the eight objectbucketclaim.yaml files).

If the match is case-sensitive, this annotation is a no-op, which defeats its stated purpose: when PR 4 deletes this file, cluster-apps/root flux-system will garbage-collect OCIRepository/flux-system — the source the root Kustomization is by then reading from — instead of leaving it for FluxInstance to adopt.

Suggested change
kustomize.toolkit.fluxcd.io/prune: Disabled
kustomize.toolkit.fluxcd.io/prune: disabled

Comment on lines 19 to 22
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This drops push-triggered reconciliation for the Git source, which nothing consumes the OCI source yet to replace.

After this merges, OCIRepository/flux-system is the only notified resource, but FluxInstance.spec.sync is still kind: GitRepository (instance/ks/helm-values.yaml) and every Kustomization — cluster-vap, cluster-apps, and all app ks.yaml — still has sourceRef.kind: GitRepository. The old github-webhook Receiver and its path are gone, so for the duration of PRs 3–4 a push to main only reconciles at interval: 1h instead of within seconds.

Since the Cluster Release workflow already fires on the same push: main + kubernetes/** trigger, adding the Git source to this Receiver restores that for free until the migration completes:

Suggested change
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
name: flux-system
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system

Comment on lines +99 to +113
token="$(curl --fail-with-body --silent --show-error \
--header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=notification-controller" \
| jq -er '.value')"
for attempt in $(seq 1 30); do
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done
exit 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The OIDC token is minted once, outside a loop that can run for 5 minutes (30 × 10s). GitHub Actions ID tokens are short-lived, so the later attempts can start failing on an expired exp rather than on the Receiver being unavailable — and since every non-2xx is treated identically, the step would burn the full window and then fail for a reason the log message misattributes.

Minting inside the loop keeps the retry meaningful:

Suggested change
token="$(curl --fail-with-body --silent --show-error \
--header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=notification-controller" \
| jq -er '.value')"
for attempt in $(seq 1 30); do
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done
exit 1
- name: Notify Flux
run: |
for attempt in $(seq 1 30); do
token="$(curl --fail-with-body --silent --show-error \
--header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=notification-controller" \
| jq -er '.value')"
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done
exit 1

Comment on lines +15 to +20
url: oci://ghcr.io/jfroy/flatops/cluster
verify:
provider: cosign
matchOIDCIdentity:
- issuer: ^https://token[.]actions[.]githubusercontent[.]com$
subject: ^https://github[.]com/jfroy/flatops/[.]github/workflows/cluster-release[.]yaml@refs/heads/main$

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two things to confirm before merging, both of which fail as "not Ready" on the root source rather than anywhere diagnosable:

  1. No secretRef. There is no image pull secret on any OCIRepository in this repo, so this only works if ghcr.io/jfroy/flatops/cluster is public. GHCR packages published by GITHUB_TOKEN are created private by default and do not inherit repository visibility — the package's visibility has to be flipped manually once. Worth adding to the rollout gate.

  2. --new-bundle-format=true in the sign step (line 84). That stores the signature as a sigstore bundle rather than the classic sha256-<digest>.sig tag. Please confirm the deployed source-controller's cosign verifier resolves it; cosign verify in the same job passing does not prove Flux can, since it uses a pinned cosign library version. If SourceVerified doesn't go true, dropping --new-bundle-format from both cosign sign and cosign verify is the fallback.

The identity regexes themselves match what the job asserts via --certificate-identity "${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF}".

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md. Four inline comments; the annotation casing one is the only thing I'd call a bug.

Verified as correct:

  • FLUX_RECEIVER_URL's path matches sha256("cluster-release" + "flux-system") = ef1ac25e…1692ab5, i.e. notification-controller's name + namespace + token derivation with an empty token — consistent with a generic-oidc Receiver carrying no secretRef.
  • kubernetes/cluster/ has no kustomization.yaml, so kustomize-controller auto-generates one over every YAML in the directory; ocirepository.yaml needs no explicit wiring and its namespace: flux-system is set explicitly rather than relying on a transformer.
  • source.toolkit.fluxcd.io/v1 is the right apiVersion for OCIRepository in both the new source and the Receiver's resources entry, matching instance/ks/ocirepository.yaml.
  • The claims.ref / claims.workflow_ref validations hold for both push and workflow_dispatch on main, and the workflow's Require main ref guard makes them consistent.
  • Dropping Kustomization/flux-system from the Receiver's resources is fine on its own — kustomize-controller watches its source and reconciles on revision change, so notifying the source is sufficient.
  • No plaintext secrets; removing github-webhook-token ExternalSecret and its kustomization.yaml entry is consistent, and the omission of install/upgrade boilerplate matches the global-defaults pattern.

Housekeeping once the old Receiver is gone: the GitHub repository webhook pointing at the old /hook/<hash> path will 404 and should be deleted, and flux.FLUX_GITHUB_WEBHOOK_TOKEN in 1Password becomes unused.

Comment on lines 19 to 22
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This replaces the push trigger before its replacement is consumed by anything. After this PR, the only Receiver in the repo notifies OCIRepository/flux-system — but nothing sources from it yet: cluster-apps and cluster-vap (kubernetes/cluster/ks.yaml), flux-instance (kubernetes/apps/flux-system/instance/ks.yaml), and every other ks.yaml still use sourceRef.kind: GitRepository.

Net effect for the window between this PR and stack step 4:

  • GitRepository/flux-system and Kustomization/flux-system lose their webhook, so a push to main reconciles only on the FluxInstance sync interval: 1h (ks/helm-values.yaml).
  • The GitHub repo webhook still points at the old /hook/<sha256(github-webhook+flux-system+token)> path, which no longer exists → 404s on every delivery.

Since Receivers are additive, consider keeping the github-webhook Receiver (and its externalsecret.yaml) in place alongside this one and deleting both in step 4, once the Kustomizations actually consume the OCI source.

Comment on lines +101 to +115
token="$(curl --fail-with-body --silent --show-error \
--header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=notification-controller" \
| jq -er '.value')"
for attempt in $(seq 1 30); do
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done
exit 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two issues with the retry loop:

  1. The ID token is minted once, outside the loop, but the loop spans ~5 minutes (30 × 10s). GitHub Actions OIDC tokens are short-lived, and notification-controller validates exp, so the later attempts can fail on token expiry rather than on Receiver availability — i.e. the retry never actually helps in the tail of the window. Move the token request inside the loop.

  2. It retries indiscriminately. A permanent rejection — a CEL validation failure (403) from a claim mismatch, or a 404 from a webhook-path drift — burns the full 5 minutes before failing, which reads as "Receiver unavailable" rather than "misconfigured". Capturing the HTTP status (--write-out '%{http_code}' / -o /dev/null) and bailing out on 4xx other than 404-during-rollout would make the failure mode legible.

Comment on lines +15 to +20
url: oci://ghcr.io/jfroy/flatops/cluster
verify:
provider: cosign
matchOIDCIdentity:
- issuer: ^https://token[.]actions[.]githubusercontent[.]com$
subject: ^https://github[.]com/jfroy/flatops/[.]github/workflows/cluster-release[.]yaml@refs/heads/main$

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No secretRef/serviceAccountName, so this relies on ghcr.io/jfroy/flatops/cluster being an anonymously pullable package — same as ghcr.io/jfroy/charts/zfs-static-csi. GHCR packages published via GITHUB_TOKEN are private on first publish regardless of repository visibility; visibility has to be flipped manually once. Worth adding to the rollout gate explicitly, since the symptom is OCIRepository stuck on a 401 with a UNAUTHORIZED message rather than an obvious auth-config error.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Wiring and Flux plumbing check out on the mechanical points:

  • kubernetes/cluster/ has no kustomization.yaml, so kustomize-controller auto-generates one from the directory and ocirepository.yaml is picked up by the FluxInstance sync Kustomization (path: kubernetes/cluster) without any registration change. prune: Disabled is the right escape hatch for the step-4 ownership transfer.
  • ks/kustomization.yaml drops ./externalsecret.yaml in lockstep with the file deletion, and FLUX_GITHUB_WEBHOOK_TOKEN / github-webhook-token-secret have no other references in the repo.
  • The hardcoded FLUX_RECEIVER_URL path is correct: sha256("cluster-release" + "flux-system") = ef1ac25e…92ab5, which also confirms the no-secretRef (empty token) assumption is self-consistent.
  • Audience matches on both sides (notification-controller), &audience= is correctly appended to the already-query-stringed ACTIONS_ID_TOKEN_REQUEST_URL, and id-token: write is present on the job.
  • The matchOIDCIdentity.subject regex matches exactly what cosign sign/cosign verify --certificate-identity "${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF}" produces for this workflow on main, and the [.] escaping is stricter than the existing OCIRepositories in the repo.
  • Notify-after-promote ordering is correct — the latest tag is moved before the Receiver is poked.

Three comments inline. The ordering one on receiver.yaml is the substantive one: the git-push webhook is retired in this PR, but nothing consumes the OCI source until step 3/4, so main pushes fall back to the 1h sync interval in the meantime and the existing GitHub webhook starts 404ing.

I could not independently verify the generic-oidc / oidcProviders.validations schema against the deployed notification-controller CRD from this environment — taking the PR description's statement that it was checked at face value.

Comment on lines 19 to 22
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dropping GitRepository/flux-system here leaves the cluster with no push-triggered git reconciliation for the whole duration of the migration.

Every ks.yaml in the repo — plus the FluxInstance-generated flux-system Kustomization itself (helm-values.yaml: sync.kind: GitRepository, interval: 1h) — still resolves sourceRef to GitRepository/flux-system. Steps 3 and 4 of the stack are what move those over. Until then, the only thing that poked the git source was the old github-webhook Receiver, and this PR deletes it, so the GitHub push webhook configured in repo settings starts hitting an unknown /hook/<old-hash> path (404). Net effect after merge: kubernetes/** changes land on the cluster up to an hour late, and the new Receiver only refreshes an OCIRepository nothing consumes yet.

Since the release workflow runs on every push to main that touches kubernetes/**, listing both sources keeps the fast path alive through the migration window:

Suggested change
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system
- apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
kind: OCIRepository
name: flux-system
resources:
- apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
name: flux-system
- apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
name: flux-system

The GitRepository entry can then be removed in step 4 along with the git source itself.

Comment on lines +111 to +124
token="$(curl --fail-with-body --silent --show-error \
--header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=notification-controller" \
| jq -er '.value')"
for attempt in $(seq 1 30); do
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The OIDC token is minted once, outside a loop that can run for ~5 minutes (30 × 10s). GitHub Actions ID tokens are short-lived, so in exactly the scenario this retry exists for — a Receiver that stays unavailable through the initial rollout — the later attempts can start failing on an expired exp rather than on the Receiver, turning a recoverable wait into a permanent failure with a misleading Flux Receiver unavailable message. Minting per attempt is cheap and removes the coupling between the retry budget and the token lifetime.

Suggested change
token="$(curl --fail-with-body --silent --show-error \
--header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=notification-controller" \
| jq -er '.value')"
for attempt in $(seq 1 30); do
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
done
for attempt in $(seq 1 30); do
token="$(curl --fail-with-body --silent --show-error \
--header "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \
"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=notification-controller" \
| jq -er '.value')"
if curl --fail-with-body --silent --show-error \
--request POST \
--header "Authorization: Bearer ${token}" \
"${FLUX_RECEIVER_URL}"; then
exit 0
fi
if [[ "${attempt}" -lt 30 ]]; then
echo "Flux Receiver unavailable (attempt ${attempt}/30); retrying in 10 seconds"
sleep 10
fi
done
exit 1

(The -lt 30 guard also drops the pointless 10s sleep before the final exit 1.)

Separately, worth considering: the loop retries indiscriminately, so a genuine misconfiguration — a CEL validation that never matches, or a stale FLUX_RECEIVER_URL — burns the full five minutes as 401/404 before failing. A fast-fail on those statuses would make a broken Receiver config much more obvious than a wall of "unavailable" lines.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Reviewed the OCI bootstrap wiring. The pieces that are easy to get wrong here check out:

  • kubernetes/cluster/ has no kustomization.yaml, so kustomize-controller auto-generates one over the directory — ocirepository.yaml is picked up by the FluxInstance flux-system sync (path: kubernetes/cluster) without any wiring change. The explicit namespace: flux-system is correct, since the global NamespaceTransformer only covers kubernetes/apps.
  • prune: Disabled is the right escape hatch for the ownership handoff, and OCIRepository/flux-system does not collide with the existing GitRepository/flux-system (different kind).
  • The keyless identity lines up end to end: the workflow verifies against ${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF}, and matchOIDCIdentity.subject (^https://github[.]com/jfroy/flatops/[.]github/workflows/cluster-release[.]yaml@refs/heads/main$) matches that exactly for both push and workflow_dispatch on main. Escaping dots as [.] is stricter than the existing OCIRepositories in instance/ks/ocirepository.yaml — a fine direction, just inconsistent with them for now.
  • FLUX_RECEIVER_URL is correct: sha256("cluster-release" + "flux-system") is ef1ac25e…92ab5, i.e. it matches the empty-token path notification-controller derives for a generic-oidc Receiver with no secretRef. The comment pinning it to status.webhookPath is worth keeping, since renaming the Receiver silently breaks this.
  • Removing the github-webhook-token ExternalSecret is clean — github-webhook-token-secret has no other referents in the tree. The now-unused FLUX_GITHUB_WEBHOOK_TOKEN field in the 1Password flux item and the push webhook in GitHub repo settings are both manual cleanups outside the repo.

Two inline comments, one of which I would treat as a merge blocker for the migration window: the Receiver no longer pokes GitRepository/flux-system, and every sourceRef in the repo (including the FluxInstance sync itself) still points there until steps 3 and 4 land.

One forward-looking note on kubernetes/cluster/ocirepository.yaml, not a defect in this diff: interval: 5m here will disagree with FluxInstance sync.interval (currently 1h) once step 4 hands ownership over. FluxInstance.spec.sync has no verify field, so flux-operator will generate an OCIRepository without the cosign block — spec.verify should survive as an unowned field, but spec.interval is set by both managers and can flip-flop between the flux-system Kustomization re-applying this file and flux-operator re-applying its own. Either align the interval with the FluxInstance sync value now, or plan to delete this file in step 4 rather than leave it pruned-disabled.

Also flagging that the rollout gate is doing real work here: nothing consumes OCIRepository/flux-system yet, so a failed SourceVerified (bad signature, or a private GHCR package needing a pull secret this spec does not have) surfaces only as a NotReady source and will not fail any Kustomization. Worth confirming Ready/SourceVerified manually as the PR description says, rather than inferring it from a green cluster.

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