Skip to content

feat(flux): complete cluster OCI migration - #2161

Open
jfroy wants to merge 1 commit into
oci-refs-1from
oci-everything
Open

feat(flux): complete cluster OCI migration#2161
jfroy wants to merge 1 commit into
oci-refs-1from
oci-everything

Conversation

@jfroy

@jfroy jfroy commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • migrate the remaining Flux Kustomizations and root cluster sources to OCIRepository/flux-system
  • switch FluxInstance sync from GitHub Git to oci://ghcr.io/jfroy/flatops/cluster:latest
  • preserve strict keyless Cosign identity verification on the FluxInstance-generated source
  • remove the temporary bootstrap OCI source after FluxInstance takes ownership
  • remove obsolete GitRepository alert coverage and update repository documentation

Prerequisites

Merge only after the first three PRs have landed and the bootstrap OCI source is Ready and SourceVerified. At that point all default applications already consume OCI, while the temporary source and existing Git root keep this final ownership transfer recoverable.

Flate/Konflate explicitly aliases bootstrap OCIRepository sources to the PR working tree, so rendered PR diffs continue to use each PR head rather than the mutable latest artifact.

Stack

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

@coderabbitai

coderabbitai Bot commented Aug 7, 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: a37cb12e-c76d-4623-bb11-49edcff7fbf9

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 7, 2026

Copy link
Copy Markdown
Contributor

konflate — summary

Note

+541 added · 126 changed · −1 removed — 668 resources · 101 apps · 168 not shown

Blast radius

  • Kustomization default/immich — 1 dependent (Kustomization default/immich-pet-tagger)
  • Kustomization default/netbox-valkey — 1 dependent (Kustomization default/netbox)
  • Kustomization flux-system/immich — 1 dependent (Kustomization flux-system/immich-pet-tagger)
  • Kustomization flux-system/netbox-valkey — 1 dependent (Kustomization flux-system/netbox)

Warning

⚠ Cautions

  • Deployment default/buildkit-amd64 — a container runs with securityContext.privileged: true
  • Deployment default/buildkit-arm64 — a container runs with securityContext.privileged: true
  • ClusterRoleBinding crd-schema-publisher — new ClusterRoleBinding — grants cluster-wide permissions
  • ClusterRoleBinding homepage — new ClusterRoleBinding — grants cluster-wide permissions
  • 668 resources · 101 apps — large change set — more ground to cover than a typical PR; review with extra care

Caution

51 render failures

Image changes

image from to
docker.io/busybox 1.38.0
docker.io/curlimages/curl sha256:7c12af72ceb3…
docker.io/dpage/pgadmin4 sha256:2f4ce946ddf8…
docker.io/electh/nextflux sha256:310f230fc975…
docker.io/getmeili/meilisearch sha256:d36e713e8f89…
docker.io/library/couchdb sha256:b80216f643e9…
docker.io/rancher/kubectl v1.36.2
ghcr.io/arabcoders/ytptube sha256:95e0be28fefb…
ghcr.io/autobrr/qui sha256:3285c52f0258…
ghcr.io/av1155/houndarr sha256:475cf515388a…
ghcr.io/bookorbit/bookorbit sha256:e131834be597…
ghcr.io/browserless/chromium sha256:8f6a3937c574…
ghcr.io/calibrain/shelfmark sha256:bd314405a9ca…
ghcr.io/connorgallopo/tracearr sha256:3d57d9b032b4…
ghcr.io/dgtlmoon/changedetection.io sha256:5438423d5e90…
ghcr.io/diced/zipline sha256:bfd5b0f7b5b8…
ghcr.io/donkie/spoolman sha256:f17489666719…
ghcr.io/eznix86/docker-registry-ui sha256:f30a167cd060…
ghcr.io/flaresolverr/flaresolverr sha256:139dfee1c6f8…
ghcr.io/gethomepage/homepage sha256:a0b71c8e7572…
ghcr.io/gotson/komga sha256:c4f9885fc077…
ghcr.io/home-operations/esphome sha256:b23f64c1a975…
ghcr.io/home-operations/plex sha256:235402dacb4c…
ghcr.io/home-operations/postgres-init sha256:ebd9d30add17…
ghcr.io/home-operations/prowlarr sha256:ce5d6bdd5be6…
ghcr.io/home-operations/qbittorrent sha256:4fcf15b7f265…
ghcr.io/home-operations/radarr sha256:260469d70761…
ghcr.io/home-operations/sabnzbd sha256:457be5fad7b8…
ghcr.io/home-operations/sonarr sha256:01db3e6a923f…
ghcr.io/immich-app/immich-machine-learning v3.1.0-cuda
ghcr.io/immich-app/immich-server v3.1.0
ghcr.io/immichframe/immichframe sha256:edae2c0b9ab6…
ghcr.io/instrumentisto/rsync-ssh alpine3.22
ghcr.io/janpuc/browserr sha256:3ba025efc6dd…
ghcr.io/jellyfin/jellyfin sha256:45f648c382a0…
ghcr.io/jellyfin/jellyfin-vue sha256:49d8694bb84e…
ghcr.io/jfroy/etampe sha256:cc5ac861aa0e…
ghcr.io/jfroy/gluetun sha256:970fdef9eedb…
ghcr.io/jfroy/stash v0.31.1-cudajellyfin
ghcr.io/jfroy/vuetorrent sha256:7fe6d12a1d0b…
ghcr.io/karakeep-app/karakeep sha256:5467873df817…
ghcr.io/mealie-recipes/mealie sha256:36c28f0642fb…
ghcr.io/miniflux/miniflux 2.3.3
ghcr.io/netbox-community/netbox v4.6.7
ghcr.io/paperless-ngx/paperless-ngx sha256:65a4cabf0169…
ghcr.io/project-zot/zot v2.1.18
ghcr.io/readur/readur sha256:bb6356cf930f…
ghcr.io/recyclarr/recyclarr sha256:73303ba5ee64…
ghcr.io/samuelloranger/labby sha256:424a06937118…
ghcr.io/seerr-team/seerr v3.4.0
ghcr.io/sholdee/crd-schema-publisher v2026.721.161952
ghcr.io/skier233/stash-ai-server 0.9.3
ghcr.io/sysadminsmedia/homebox sha256:b1ad7e3c63f7…
ghcr.io/tedornitier/immich-pet-tagger sha256:bae28707d3dd…
ghcr.io/usememos/memos sha256:71a5b4738d1b…
ghcr.io/valkey-io/valkey sha256:3acc0687f2a2…
mirror.gcr.io/freikin/dawarich 1.11.0
mirror.gcr.io/freikin/dawarich sha256:4e3c55c4cd57…
moby/buildkit sha256:28a898719c18…
registry.kantai.xyz/jfroy/photon-docker sha256:8a516fc9ecdb…
registry.kantai.xyz/nams sha256:2215b72a676b…
registry.kantai.xyz/pocket-id/pocket-id sha256:72808e68b4c7…
registry.kantai.xyz/sais-frontend sha256:eac1c7c1aee0…
registry.kantai.xyz/stash/bulk-transcode sha256:ef916bda7158…

View the full rendered diff →

konflate · rendered 59d108c · advisory, not a gate

Comment on lines 13 to 16
sourceRef:
kind: GitRepository
kind: OCIRepository
name: flux-system
namespace: 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.

Flipping cluster-apps/cluster-vap to OCIRepository in the same commit that flips the FluxInstance sync creates a bootstrap deadlock on merge.

At merge time the live Flux is still syncing from GitRepository/flux-system. The root sync Kustomization applies this file, so cluster-apps and cluster-vap are updated to sourceRef.kind: OCIRepository — but OCIRepository/flux-system does not exist yet, because the only thing that creates it is the updated flux-instance HelmRelease values, which are delivered through cluster-apps. Both root Kustomizations stall on "OCIRepository/flux-system not found" and the FluxInstance change is never applied. The 148 child Kustomizations keep running off their currently-applied (Git-sourced) specs, so the cluster silently freezes at the pre-merge revision until someone intervenes by hand.

There's also a race even if the source did exist: cluster-release.yaml only runs on push to main, so the artifact is being built at the same moment Flux is reconciling the switch.

Suggested sequencing:

  1. Land cluster-release.yaml alone, run it (or workflow_dispatch on main), and confirm oci://ghcr.io/jfroy/flatops/cluster:latest exists, is signed, and is pullable by source-controller.
  2. Land the FluxInstance sync switch so OCIRepository/flux-system is created and Ready.
  3. Only then land the 148 sourceRef flips.

If the plan is instead to do step 2 by hand before merging, please note that in the PR description — as written this PR is not self-applying.

Comment on lines 11 to +16
sync:
kind: GitRepository
url: https://github.com/jfroy/flatops
ref: refs/heads/main
kind: OCIRepository
url: oci://ghcr.io/jfroy/flatops/cluster
ref: latest
path: kubernetes/cluster
interval: 1h
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.

No pull credentials are configured for the new source. spec.sync has no pullSecret, and the only patch on OCIRepository/flux-system adds spec.verify — nothing adds secretRef/serviceAccountName.

GHCR packages are private on first push, and ghcr.io/jfroy/flatops/cluster is new (the existing ghcr.io/jfroy/flatops/* packages don't cover it). If the package inherits private visibility, source-controller gets 401 Unauthorized on every poll and the entire cluster loses its source of truth — same failure mode as the ordering issue in kubernetes/cluster/ks.yaml, but permanent.

Either set the package to public (the artifact only contains manifests and ExternalSecret references, no plaintext secrets, so that's viable) or add a pullSecret with a GHCR read token. Worth stating which one was chosen, since it's cluster-fatal and not visible from the repo.

Also note the signature verification is doing double duty here: with a public package, spec.verify is what keeps an unrelated pusher from being trusted, so it's load-bearing rather than defence-in-depth. Please confirm kustomize.patches actually reach the generated sync source in your flux-operator version (i.e. the live OCIRepository shows spec.verify after rollout) — kustomize silently no-ops a patches entry whose target doesn't match, so a wrong target here fails open rather than loudly.

Comment on lines +5 to +6
on:
workflow_dispatch:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

workflow_dispatch is unguarded, so this can be dispatched from any branch. The run will flux push artifact a :<sha> tag and then cosign sign it with that branch's OIDC identity before cosign verify (pinned to @refs/heads/main) fails the job. Promotion to latest is correctly not reached, so the cluster is safe, but you end up with signed non-main artifacts and Rekor entries plus a red run.

Cheap fix — guard the job:

jobs:
  release:
    if: github.ref == 'refs/heads/main'

Comment thread .github/workflows/cluster-release.yaml Outdated
Comment on lines +24 to +25
# The generic-oidc Receiver path is SHA-256(receiver name + namespace).
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 hash is a magic constant duplicated from live cluster state, and the Receiver was just renamed (github-webhookcluster-release), so it had to be recomputed. Please confirm it matches status.webhookPath on the reconciled Receiver — a stale value makes every release fail at the Notify Flux step (curl --fail-with-body on a 404) even though the artifact published fine, and the failure is indistinguishable from a real problem.

Longer term the coupling is unfortunate: renaming the Receiver silently breaks releases. Not blocking, but the comment should probably also say "must match status.webhookPath of Receiver cluster-release in flux-system" rather than restating the formula, since the formula is an implementation detail of notification-controller.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Two things I think need resolving before this merges, both left as inline comments:

  • Merge-time deadlock (kubernetes/cluster/ks.yaml): the OCIRepository/flux-system source is created by the FluxInstance change, but that change is delivered through cluster-apps, which this commit repoints at that not-yet-existing source. Landing all three layers at once wedges the root Kustomizations and freezes the cluster at the pre-merge revision.
  • GHCR pull auth (instance/ks/helm-values.yaml): no pullSecret and no secretRef patch, against a brand-new package that GHCR creates private by default.

Smaller items:

  • konflate: it renders changed Kustomizations from github://jfroy/flatops. Does its source resolution special-case sourceRef.kind: GitRepository for the flux-system source? If so, every rendered diff in this repo breaks after the flip, and the PR-diff status checks go with it. Worth a manual konflate render against this branch before merging, since konflate is also how you would normally have caught the two issues above.
  • Receiver type: I could not verify type: generic-oidc and the oidcProviders schema offline. secretRef has historically been required on Receiver v1 — please confirm the CRD in the deployed notification-controller accepts this object with no secretRef (a flux build/server dry-run against receiver.yaml is enough). If it is rejected, the Receiver never becomes Ready and releases fall back to the 5m poll.
  • Leftover state not covered by the diff: the GitHub repo webhook now points at a /hook/ path that no longer exists, and FLUX_GITHUB_WEBHOOK_TOKEN in the 1Password flux item is unused now that the ExternalSecret is gone.
  • .github/instructions/flux.instructions.md still describes GitRepository as the source pattern, and the error.*lookup.*github entry in components/common/alerts/alertmanager/alert.yaml exclusionList is now dead (it was there to mute github.com lookup blips; ghcr.io will not match it). Cosmetic.

What I did verify:

  • The 148 sourceRef flips are uniformly mechanical and complete; no kind: GitRepository remains anywhere under kubernetes/ outside Grafana dashboard queries, including the commented-out tailscale-operator/connector block.
  • The artifact layout is right: ARTIFACT_DIR/kubernetes/ puts kubernetes/ at the artifact root, so the existing ./kubernetes/apps/... and ./kubernetes/cluster paths resolve unchanged. The tar -tzf | grep -q sanity check is a good guard on exactly that.
  • Alert.eventSources already listed OCIRepository, so dropping GitRepository does not lose coverage.
  • Sign-then-verify-then-promote is the correct ordering, the matchOIDCIdentity subject regex matches the workflow's own --certificate-identity, the receiver audience matches the OIDC token request, and signing by digest means latest verifies since flux tag artifact reuses the digest.
  • concurrency.cancel-in-progress: true is safe here — a superseding run from main is always a content superset, and a cancelled run can only leave latest briefly stale.

@jfroy jfroy changed the title feat: migrate cluster to OCI feat(flux): complete cluster OCI migration Aug 8, 2026
@jfroy
jfroy changed the base branch from main to oci-refs-1 August 8, 2026 04:37
@jfroy

jfroy commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

Split the migration into a staged stack to remove the bootstrap dependency and keep every review below 100 files:

  1. ci: publish signed cluster OCI artifact #2163 — publish/sign/promote the artifact and prove anonymous pull access
  2. feat(flux): bootstrap verified OCI source #2164 — bootstrap the verified OCI source and OIDC Receiver alongside Git
  3. refactor(flux): migrate default apps to OCI source #2165 — migrate default-namespace Kustomizations
  4. This PR — migrate the remainder and transfer source ownership to FluxInstance

This PR now targets oci-refs-1 and contains 86 changed files. The live Receiver CRD was also confirmed to support generic-oidc; flate has explicit bootstrap OCIRepository aliasing for PR working trees.

Comment on lines +19 to +31
# Only reconcile cluster state from artifacts signed by this workflow on main.
- patch: |-
- op: add
path: /spec/verify
value:
provider: cosign
matchOIDCIdentity:
- issuer: ^https://token[.]actions[.]githubusercontent[.]com$
subject: ^https://github[.]com/jfroy/flatops/[.]github/workflows/cluster-release[.]yaml@refs/heads/main$
target:
group: source.toolkit.fluxcd.io
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.

Moving spec.verify from the deleted kubernetes/cluster/ocirepository.yaml into a JSON6902 patch on the flux-operator-generated sync source makes signature enforcement fail open: kustomize silently no-ops a patch whose target matches nothing, so if flux-operator ever changes the generated resource's name/group (or this stanza gets a typo), the cluster keeps reconciling unsigned artifacts with no error anywhere.

Worth confirming after rollout that the field actually landed:

kubectl -n flux-system get ocirepository flux-system -o jsonpath='{.spec.verify}'

(and ideally asserting it in the release workflow or a VAP, since this is the only thing standing between a GHCR push and cluster-wide apply).

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Source flip looks complete and consistent — no GitRepository sourceRefs left in kubernetes/, the alertmanager Alert already had OCIRepository in eventSources, no dangling secretRef after the receiver ExternalSecret removal, and konflate renders from github:// so it is unaffected. A few things on the release path (inline comments could not be posted on .github/workflows/cluster-release.yaml — the review token cannot touch workflow files):

Verify anonymous pull access runs after Promote artifact, so it cannot gate anything. GHCR packages are private on first publish, and this is the first run of this workflow. If the package is private, latest has already been moved by the time the check fails, and the sync OCIRepository has no pullSecret — so source-controller is left pointing at an artifact it cannot pull, with no Git fallback anymore. Verifying anonymous access against ${OCI_REPOSITORY}@${DIGEST} before the promote step turns it into a real gate.

Nothing reconciles until the first successful run of this workflow. cluster-release.yaml is added in this PR, so oci://ghcr.io/jfroy/flatops/cluster:latest does not exist yet, while the same merge rewrites every ks.yaml to source from it. Reconciliation stalls (non-destructively) until the workflow finishes, and stays stalled if it fails — and kubernetes/cluster/ocirepository.yaml is gone, so recovery is manual. Worth doing a workflow_dispatch run on main and confirming latest is anonymously pullable before/immediately after merge.

FLUX_RECEIVER_URL hardcodes the receiver hash. ef1ac25… has to equal Receiver/cluster-release status.webhookPath, but the Receiver is new in this PR so the value cannot have been read from a live object, and only a comment ties the two together. If it is wrong (or drifts later on a rename), Notify Flux burns 5 minutes of retries and then fails the whole release even though publish/sign/promote all succeeded. Since the OCIRepository interval is 5m, the notify is purely an optimisation — continue-on-error: true on that step would keep a webhook-path mismatch from reporting a successful release as red.

The artifact-content guard only checks one path. grep -q 'kubernetes/cluster/ks.yaml$' catches a wholesale layout mistake but not selective drop-out. flux build artifact applies default source-ignore patterns, and the tree ships non-YAML ConfigMap sources (immich.sql, telegraf.conf, buildkitd.toml, *.sh, custom.css/custom.js, config.ini). None of those hit the current default excludes, but comparing tar -tzf … | wc -l against git ls-files kubernetes | wc -l would make a future silent exclusion fail the build instead of half-populating a ConfigMap in-cluster.

Comment on lines +12 to +16
kind: OCIRepository
url: oci://ghcr.io/jfroy/flatops/cluster
ref: latest
path: kubernetes/cluster
interval: 1h
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.

spec.sync has no pullSecret, so ghcr.io/jfroy/flatops/cluster must be anonymously pullable. Konflate's check on this PR says it isn't (yet):

OCIRepository flux-system/flux-system resolve latest:
HEAD "https://ghcr.io/v2/jfroy/flatops/cluster/manifests/latest": ...
403: denied: requested access to the resource is denied

That's the same anonymous path source-controller will take. GHCR packages are private by default and cluster-release.yaml never sets visibility or links the package to the repo, so if this 403 is package visibility rather than "artifact not published from main yet", the root source goes NotReady the moment FluxInstance takes over and there is no in-band way to fix it (see the deadlock note on the deleted kubernetes/cluster/ocirepository.yaml).

Please confirm ghcr.io/jfroy/flatops/cluster is public — and that a Konflate re-run is green — before merging, or add pullSecret here.

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.

Removing this file is only safe if the live object's prune-disable annotation is actually honored, and the value here is Disabled with a capital D. Every other use in this repo is lowercase:

kubernetes/components/common/kustomization.yaml:15:      kustomize.toolkit.fluxcd.io/prune: disabled
kubernetes/apps/database/cnpg/pg18vc/objectbucketclaim.yaml:8:    kustomize.toolkit.fluxcd.io/prune: disabled
... (7 more)

kustomize-controller passes kustomize.toolkit.fluxcd.io/prune: disabled as a delete exclusion and compares the annotation value against that exact string, so Disabled likely does not match.

If it doesn't match, this is not a benign leftover — it's a deadlock:

  1. flux-system (still Git-sourced at this point) applies kubernetes/cluster, and prunes OCIRepository/flux-system because the file is gone from the artifact.
  2. cluster-vap / cluster-apps — repointed to OCIRepository/flux-system in this same commit — now have no source.
  3. The FluxInstance values change that would recreate that OCIRepository is delivered by flux-system/flux-instance, which sits under cluster-apps. It never reconciles, so the source is never recreated.

Recovery would require a manual kubectl apply, which AGENTS.md forbids without explicit authorization.

Suggested sequencing: land a one-line fix to lowercase disabled (and confirm kubectl get ocirepository flux-system -n flux-system -o jsonpath='{.metadata.annotations}' reflects it) before dropping the file, or keep the file for one more release and delete it only after spec.verify and FluxInstance ownership are confirmed on the live object.

Comment on lines +19 to +31
# Only reconcile cluster state from artifacts signed by this workflow on main.
- patch: |-
- op: add
path: /spec/verify
value:
provider: cosign
matchOIDCIdentity:
- issuer: ^https://token[.]actions[.]githubusercontent[.]com$
subject: ^https://github[.]com/jfroy/flatops/[.]github/workflows/cluster-release[.]yaml@refs/heads/main$
target:
group: source.toolkit.fluxcd.io
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 moves the cluster's only signature gate from a first-class manifest into a Kustomize patch, which fails open: Kustomize silently no-ops a patch whose target selects nothing. If flux-operator ever renames or re-kinds the generated sync source, spec.verify just disappears and the FluxInstance still reports Ready — nothing surfaces the loss.

Two things worth adding:

  • Confirm after rollout that the generated object really carries it: kubectl get ocirepository flux-system -n flux-system -o jsonpath='{.spec.verify}' (and that SourceVerified is on the status).
  • Consider a kubernetes/vap/ ValidatingAdmissionPolicy requiring spec.verify.provider == "cosign" on OCIRepository/flux-system in flux-system, so the gate can't be dropped silently. cluster-vap already runs before cluster-apps, so it's the right place for this invariant.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Mechanical part checks out: all 174 sourceRef entries now point at OCIRepository/flux-system in flux-system, grep -rn GitRepository over kubernetes/ comes back empty, every app ks.yaml diff is a balanced 1:1 line swap with no collateral edits, the alertmanager Alert already carried an OCIRepository eventSource so dropping the GitRepository one loses no coverage, the Grafana dashboards already match on GitRepository|HelmRepository|Bucket|OCIRepository, cluster-release.yaml copies into ${ARTIFACT_DIR}/kubernetes/ so path: kubernetes/cluster and every ./kubernetes/apps/... path still resolve inside the artifact, and Receiver/cluster-release already targets OCIRepository/flux-system. Verification is preserved on paper too: flux tag artifact retags the signed digest, so cosign discovery on latest still lands on the signed manifest.

Three things I would want resolved before this lands, all of them about the ownership handoff rather than the mechanical swap. Details inline:

  1. Konflate is red on this PR with 51 render failures, all 403: denied while resolving oci://ghcr.io/jfroy/flatops/cluster:latest. spec.sync has no pullSecret, so that package has to be anonymously pullable by source-controller — and GHCR packages are private by default, with nothing in cluster-release.yaml setting visibility. If this is just "artifact not published from main yet" per the stated prerequisites, a green Konflate re-run after PR 1 lands is the evidence I would want. If it is package visibility, the migration hard-fails at cutover.

  2. The deleted kubernetes/cluster/ocirepository.yaml used prune: Disabled, capital D, where all nine other uses in this repo and the value kustomize-controller matches against are lowercase disabled. If the annotation is not honored, pruning that object severs the source that cluster-apps now depends on, and the FluxInstance change that would recreate it is delivered from underneath cluster-apps — an unrecoverable loop needing a manual apply.

  3. spec.verify now arrives via a Kustomize patch, which no-ops silently if the target ever stops matching. Worth a post-rollout assertion and, ideally, a kubernetes/vap/ policy so the signature gate cannot be dropped without a rejection.

One more question, not blocking: the description says Flate/Konflate aliases bootstrap OCIRepository sources to the PR working tree, but the failure text shows it doing a real registry HEAD against the mutable latest tag instead. With this file gone, no manifest in the repo declares OCIRepository/flux-system at all. If that aliasing does not actually key off the source name, every future PR silently loses its rendered manifest diff — which is the main pre-merge safety net this repo has, given there is no test suite.

Comment on lines +19 to +31
# Only reconcile cluster state from artifacts signed by this workflow on main.
- patch: |-
- op: add
path: /spec/verify
value:
provider: cosign
matchOIDCIdentity:
- issuer: ^https://token[.]actions[.]githubusercontent[.]com$
subject: ^https://github[.]com/jfroy/flatops/[.]github/workflows/cluster-release[.]yaml@refs/heads/main$
target:
group: source.toolkit.fluxcd.io
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.

Worth confirming this patch actually reaches the sync-generated OCIRepository before kubernetes/cluster/ocirepository.yaml goes away, because a patches entry whose target selects nothing is a silent no-op in kustomize — no error, no warning.

The failure mode is unusually easy to miss here. The live OCIRepository/flux-system already carries spec.verify from the previous kustomize-controller apply, and SSA field ownership for it stays with kustomize-controller once this repo stops managing the object — flux-operator won't strip a field it doesn't own. So a no-op patch looks perfectly healthy on this cluster and only surfaces on a rebuild or if the object is ever recreated, at which point the cluster root reconciles latest with no signature verification at all.

Cheap check after the FluxInstance takes over: kubectl get ocirepository flux-system -n flux-system -o yaml and confirm spec.verify is claimed by a flux-operator entry in metadata.managedFields, not just inherited from kustomize-controller.

Comment on lines 11 to +16
sync:
kind: GitRepository
url: https://github.com/jfroy/flatops
ref: refs/heads/main
kind: OCIRepository
url: oci://ghcr.io/jfroy/flatops/cluster
ref: latest
path: kubernetes/cluster
interval: 1h
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.

After this lands there is no path into the cluster that doesn't go through ghcr.io/jfroy/flatops/cluster:latest with signature verification enforced. Anything that makes the artifact unpullable or unverifiable — Sigstore trust-root or bundle-format change, GHCR outage, an accidental flux tag artifact --tag latest onto a bad digest — freezes all 176 Kustomizations, and the fix cannot be delivered through Git, because the source definition now exists only in these values, which are themselves delivered by that source.

Recovery becomes a manual kubectl patch fluxinstance or a hand-applied replacement source, i.e. exactly the live mutation AGENTS.md tells agents not to perform without authorization. Since this PR already updates AGENTS.md for the new sync model, it'd be worth documenting the break-glass procedure there too — or keeping a suspended GitRepository + Kustomization in kubernetes/cluster/ as a dormant fallback.

@@ -11,8 +11,6 @@ spec:
eventSources:
- kind: FluxInstance

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 here is correct, but the sibling Alert in this same component — ../github-status/alert.yaml, wired in via alerts/kustomization.yaml — still watches Kustomization * at eventSeverity: info and feeds the github Provider pointed at https://github.com/jfroy/flatops.

GitHub commit statuses need a git SHA from the event's revision metadata. Once every Kustomization is OCI-sourced, that metadata is the OCIRepository artifact revision, latest@sha256:<oci-digest>, not main@sha1:<commit>. The --revision "${GITHUB_REF_NAME}@sha1:${GITHUB_SHA}" in cluster-release.yaml is artifact annotation metadata and does not become status.artifact.revision, so notification-controller has no commit to attach to and will fail on each Kustomization event — in every namespace this component is applied to, since the alert is namespace-scoped.

Worth resolving in the change that removes the last GitRepository: either drop ./github-status from kubernetes/components/common/alerts/kustomization.yaml, or leave a note that it's expected-dead so the error volume isn't mistaken for a regression later.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Kustomize wiring and Flux plumbing check out. Verified:

  • All 176 sourceRef blocks across ks.yaml files now resolve to OCIRepository/flux-system in flux-system; grep finds no GitRepository left in kubernetes/ outside Grafana dashboard PromQL, which is harmless.
  • kubernetes/cluster/ has no kustomization.yaml, so deleting ocirepository.yaml needs no resource-list edit, and the live object survives via kustomize.toolkit.fluxcd.io/prune: Disabled (Flux checks that annotation on the live object during GC, so the removal from the source tree is safe).
  • Receiver/cluster-release targets source.toolkit.fluxcd.io/v1 OCIRepository/flux-system, which the FluxInstance keeps generating under the same name. cluster-apps -> cluster-vap dependsOn ordering and the HelmRelease defaults patch are untouched.
  • The matchOIDCIdentity subject regex matches the identity cluster-release.yaml actually signs with (${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF} on refs/heads/main), and spec.sync.path: kubernetes/cluster lines up with the artifact layout the workflow builds.
  • The commented-out connector block in tailscale-operator/ks.yaml was updated alongside the live ones, so it will not drift.

Three things flagged inline, none of them a broken manifest:

  1. The cosign verify patch is the one load-bearing piece I could not confirm statically — if its target does not select the sync-generated OCIRepository, kustomize treats it as a silent no-op, and stale SSA field ownership on the live object will mask that indefinitely.
  2. Removing the Git root leaves no delivery path that bypasses the signed OCI artifact, including for the fix if the artifact itself becomes unverifiable.
  3. The github-status Alert/Provider in components/common/alerts is left watching Kustomization * but can no longer resolve a git SHA from OCI revisions.

Comment on lines +19 to +31
# Only reconcile cluster state from artifacts signed by this workflow on main.
- patch: |-
- op: add
path: /spec/verify
value:
provider: cosign
matchOIDCIdentity:
- issuer: ^https://token[.]actions[.]githubusercontent[.]com$
subject: ^https://github[.]com/jfroy/flatops/[.]github/workflows/cluster-release[.]yaml@refs/heads/main$
target:
group: source.toolkit.fluxcd.io
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.

With kubernetes/cluster/ocirepository.yaml deleted, this patch becomes the only declaration of Cosign verification for the cluster root source — and a Kustomize patches entry whose target matches nothing is a silent no-op, not an error.

So if the target ever stops matching (flux-operator changes the generated source's name, or someone later sets spec.sync.name), flux-operator re-applies OCIRepository/flux-system with force ownership and strips spec.verify from the live object. The cluster then reconciles whatever latest points to, with no signature check and no warning — a fail-open on the one source that controls everything.

Two ways to make it fail closed, either is cheap:

  1. Add a VAP under kubernetes/vap/ requiring spec.verify.provider == "cosign" on OCIRepository named flux-system in flux-system. That fits the existing cluster-vapcluster-apps ordering and would reject the unverified apply outright.
  2. At minimum, after merge assert the operator-generated source actually carries the block and reports SourceVerified, not just ReadyReady alone is satisfied by an unverified pull.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

The mechanical part of the migration is complete and consistent — all 176 sourceRef entries across the 137 ks.yaml files are now OCIRepository/flux-system, no kind: GitRepository remains anywhere under kubernetes/, the artifact layout (cp -a kubernetes/. $ARTIFACT_DIR/kubernetes/) matches both the ./kubernetes/apps/... Kustomization paths and sync.path: kubernetes/cluster, the alertmanager Alert already carries an OCIRepository eventSource so dropping GitRepository loses no coverage, and Receiver/cluster-release already targets the OCI source. Ownership transfer looks safe: the live OCIRepository/flux-system keeps kustomize.toolkit.fluxcd.io/prune: Disabled, so the flux-system Kustomization skips it when the file disappears, and it stays functional (orphaned but reconciling) until flux-operator adopts it.

Three things worth acting on:

1. Verification is now fail-open — see the inline comment on helm-values.yaml. Deleting the checked-in source leaves the Cosign block existing only as a Kustomize patch target, and a non-matching patches target is silently ignored rather than an error. A VAP under kubernetes/vap/ requiring spec.verify on OCIRepository/flux-system would close that.

2. AGENTS.md:33 is now inaccurate. The Cluster section was updated, but Maintenance Commands still says:

Flux reconciliation, when appropriate and after Git state is ready:

flux reconcile kustomization cluster-apps --with-source

Committed Git state is no longer the precondition — the Cluster Release workflow has to publish and promote latest first, otherwise --with-source re-pulls the previous artifact and the reconcile looks like a no-op. Suggest rewording to something like "after the Cluster Release workflow has promoted the artifact", and adding flux reconcile source oci flux-system since that is now the source under cluster-apps. Worth doing in this PR given it is already the docs-update PR for this change.

3. Failed release + advancing main can flap the sync kind. If this merge (or any later one) lands while cluster-release.yaml fails, the FluxInstance flips to OCI from the Git-delivered commit, but latest is still the pre-merge artifact where sync.kind: GitRepository — so the operator flips back to Git, re-reads main, flips to OCI again, roughly once per HelmRelease interval. Non-destructive (the flux-system Kustomization keeps its inventory, so no app pruning) and it self-resolves the moment the workflow succeeds, but the root source and its Kustomization churn in the meantime. A flux-instance HelmRelease / FluxInstance-not-Ready alert would make that visible rather than silent.

Nothing here blocks the merge order described in the PR body.

Comment on lines +20 to +31
- patch: |-
- op: add
path: /spec/verify
value:
provider: cosign
matchOIDCIdentity:
- issuer: ^https://token[.]actions[.]githubusercontent[.]com$
subject: ^https://github[.]com/jfroy/flatops/[.]github/workflows/cluster-release[.]yaml@refs/heads/main$
target:
group: source.toolkit.fluxcd.io
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.

With kubernetes/cluster/ocirepository.yaml deleted, this patch is the only thing that puts spec.verify on the source that every one of the 176 sourceRef entries now depends on — and a Kustomize patches entry whose target selects nothing is a silent no-op, not an error. Two coupled assumptions are load-bearing and neither is asserted anywhere:

  1. spec.sync has no name, so the generated source is named by flux-operator's default (flux-system) — which this target.name hardcodes, as do all the ks.yaml sourceRefs.
  2. flux-operator has to run spec.kustomize.patches over the sync-generated objects, not just the controller Deployments.

If either changes, the OCIRepository is still created and still Ready — just unverified, pulling whatever latest points at. Worse, the live object already carries spec.verify from the bootstrap manifest under kustomize-controller's SSA field ownership, so a non-matching patch would look fine indefinitely on the existing cluster and only surface on a rebuild.

Worth pinning down before merge:

# after the FluxInstance flips, confirm the patch actually landed and is owned by flux-operator
kubectl get ocirepository flux-system -n flux-system \
  -o jsonpath='{.spec.verify}{"\n"}{range .metadata.managedFields[*]}{.manager}{" "}{end}{"\n"}'

A VAP under kubernetes/vap/ requiring spec.verify.provider == "cosign" on OCIRepository/flux-system would make it fail closed permanently.

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.

This annotation is what keeps the live source alive when this file leaves the tree, and it is the odd one out in this repo: the other nine uses (components/common/kustomization.yaml, all the objectbucketclaim.yaml files) all spell it lowercase disabled.

The ordering makes the blast radius worse than a normal orphaned object. On merge, the still-Git-backed flux-system Kustomization reconciles this commit and does two things in one pass: drops this object from its inventory, and updates the flux-instance HelmRelease values. GC runs immediately; flux-operator only recreates the source after the HelmRelease upgrade lands and the FluxInstance reconciles. If Disabled isn't honored, cluster-apps, cluster-vap and every child Kustomization lose their source in that window. Non-destructive (a missing source stalls reconciliation, it doesn't trigger pruning) and it should self-heal, but it's an avoidable flap on the one change that has no Git fallback left.

Cheap to de-risk — either normalize the casing in PR 2 before this lands, or confirm on the live object first:

kubectl get ocirepository flux-system -n flux-system \
  -o jsonpath='{.metadata.annotations}{"\n"}'

Comment thread AGENTS.md
## Cluster

This is **kantai**, a Kubernetes cluster running Talos Linux, with a mix of bare-metal and virtual nodes, managed entirely through GitOps via FluxCD and Flux Operator. All services live under `kantai.xyz`. Current repo configuration targets Talos `v1.13.2` and Kubernetes `v1.36.1`. Flux syncs `refs/heads/main` from `https://github.com/jfroy/flatops` at `kubernetes/cluster`.
This is **kantai**, a Kubernetes cluster running Talos Linux, with a mix of bare-metal and virtual nodes, managed entirely through GitOps via FluxCD and Flux Operator. All services live under `kantai.xyz`. Current repo configuration targets Talos `v1.13.2` and Kubernetes `v1.36.1`. Flux syncs `kubernetes/cluster` from the `latest` artifact at `oci://ghcr.io/jfroy/flatops/cluster`. GitHub Actions publishes and keylessly signs the artifact from `refs/heads/main`; Flux verifies its Cosign identity before reconciliation.

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 Cluster section is updated, but two things this PR changes are left undocumented, and both are exactly the kind of invariant an agent can't recover from the tree:

The flux-system OCIRepository is no longer a checked-in manifest. This PR deletes kubernetes/cluster/ocirepository.yaml, so grep -rn 'kind: OCIRepository' kubernetes/cluster/ now comes back empty while 176 sourceRef entries point at it. It is generated by FluxInstance.spec.sync and its Cosign verify block exists only as a Kustomize patch in kubernetes/apps/flux-system/instance/ks/helm-values.yaml. Worth a sentence saying so, since the natural place to look for it no longer has it.

"Maintenance Commands" (line 33) is now wrong. It still says:

Flux reconciliation, when appropriate and after Git state is ready:

Committed Git state is no longer the precondition — Cluster Release has to publish and promote latest first. Until it does, flux reconcile kustomization cluster-apps --with-source re-pulls the previous artifact and reads as a successful no-op, which is a genuinely misleading failure mode for anyone debugging "my commit didn't apply". Suggest rewording to "after the Cluster Release workflow has promoted the artifact" and adding flux reconcile source oci flux-system, since that is the source under cluster-apps now.

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

The mechanical swap holds up: grep -rn 'kind: GitRepository' kubernetes/ is empty (only Grafana PromQL and .github/instructions/ mention it), the artifact layout (cp -a kubernetes/. $ARTIFACT_DIR/kubernetes/) makes both sync.path: kubernetes/cluster and every ./kubernetes/apps/... path resolve, the matchOIDCIdentity subject regex is an exact match for ${GITHUB_SERVER_URL}/${GITHUB_WORKFLOW_REF} on main, flux tag artifact retags the signed digest so cosign discovery on latest still lands on a signed manifest, Receiver/cluster-release already targets OCIRepository/flux-system, the alertmanager Alert already carried an OCIRepository eventSource so dropping GitRepository loses no coverage, kubernetes/cluster/ has no kustomization.yaml so deleting a file there needs no resource-list edit, and the commented-out connector block in tailscale-operator/ks.yaml was swapped too. Three inline comments on the handoff mechanics; one thing that doesn't fit on a line:

Alert/github-status goes dark repo-wide, and this PR is what completes it. It watches Kustomization * through a type: github Provider, which needs a git SHA out of the event revision. For an OCIRepository the artifact revision is <tag>@<digest>latest@sha256:… — so notification-controller ends up trying to post a commit status against an OCI digest rather than a commit. The workflow does record the real SHA (--revision "${GITHUB_REF_NAME}@sha1:${GITHUB_SHA}"), but that lands in status.artifact.metadata["org.opencontainers.image.revision"], which is not what the Kustomization reports as its revision. PR 3 already broke this for the default namespace; this PR migrates the last holdouts, so after merge no Flux commit statuses post at all. Either confirm notification-controller resolves the SHA from the artifact metadata, or drop components/common/alerts/github-status/ (Alert + Provider + ExternalSecret) rather than leaving a Provider that errors on every reconcile.

Konflate is red on this PR — 403 while resolving oci://ghcr.io/jfroy/flatops/cluster:latest. Per the stated prerequisites that should clear once PR 1 has published from main, but it does mean the two things this migration cannot survive without are both currently unproven: that the GHCR package is anonymously pullable (spec.sync has no pullSecret, and nothing in cluster-release.yaml sets package visibility), and that Konflate really does alias the OCI source to the PR tree. A green Konflate run is the cheapest evidence for both — worth waiting for it before merging this one, since there is no Git delivery path left afterwards.

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