Create Zot OCI Service #50

Merged
tepichord merged 9 commits from feat/48-zot-oci-service into main 2026-09-12 07:28:12 -07:00
Owner

Closes #48

Automated PR created by forgejo-done adapter.

Closes #48 Automated PR created by forgejo-done adapter.
Creates the zot/ Terraform root deploying Zot as a single-replica
StatefulSet on the worker node with a Terraform-owned PVC on the
persistent StorageClass and a Tailscale operator Ingress at
zot.faun-snapper.ts.net.

Refs: #48
Asserts namespace PSA label, Helm release chart/version/repository,
Ingress Tailscale class with pinned MagicDNS name, PVC on the durable
StorageClass with wait_until_bound=false, and chart persistence config
(pvc.create=false, pvc.name=zot-data).

Refs: #48
Add zot/ to the terraform fmt check list, zot/values.yaml to the
yamllint list, and add test_terraform_test_zot following the existing
pattern.

Refs: #48
Add zot/README.md with architecture, prerequisites, deploy steps, push/pull
examples, data persistence contract, and gotchas. Update PLAN.md with Phase 6
and ARCHITECTURE.md with Zot in the service table and network topology.

Refs: #48
requests[0].storage is wrong — requests is a map keyed by attribute name,
not a list. Use requests.storage instead.
All other roots commit their .terraform.lock.hcl; the zot root was missing
its copy.
tepichord added this to the MVP project 2026-09-11 19:43:47 -07:00
Conflict resolution, in tests/test_static.py and tests/test_terraform.py:
both sides added a root to the same two lists, so the union is what the merge
commit should say.

- test_terraform_fmt keeps main's roots and this branch's `zot/`.
- test_terraform_test_zot is added beside main's
  test_terraform_test_kubernetes_dashboard — one function per root.
- test_yamllint takes main's gitops paths (the vault/pocket-id/woodpecker
  values files all moved into gitops/clusters/milner/) plus this branch's
  `zot/values.yaml`, which is still a Terraform root at this commit.

The zot Terraform root and its registrations are removed in the next commit;
keeping the union here means the merge commit itself is a state that builds
and tests clean, and the conversion reads as its own change.
The registry is an in-cluster workload, so it belongs to Flux like every other
one; `zot/` was the last Terraform root holding cluster state that GitOps
should own. It had also never been applied — no `zot` namespace exists on the
cluster — so nothing needed adopting and there was no live state to migrate.

New component: gitops/clusters/milner/infrastructure/zot/, registered as
`namespaces/zot.yaml` + `zot` in that layer. It follows the Pocket ID pattern
(OCIRepository + chartRef, values via configMapGenerator, an explicit
`namespace:` because a generated ConfigMap has none of its own) and declares
the PVC and the Tailscale Ingress here rather than letting the chart own them,
for the same reasons Pocket ID does: the chart provisions its claim on the
default class and cannot pin a MagicDNS name.

Two things the Terraform values got wrong, both silent, both found only by
rendering the chart instead of reading it (`helm template` with these values,
plus kubeconform and the L1 conftest policy over the result):

- `config:` is not a key the chart reads. It is `configFiles.config.json`, and
  it is only mounted when `mountConfig: true`. The root set the wrong key and
  never set `mountConfig`, so the registry would have started on the chart's
  built-in config while the file looked right.
- `service.type` defaults to NodePort, which would publish the registry on
  every node IP. Set to ClusterIP: the only intended door in is the Ingress.

Also corrected: `distSpecVersion` 1.1.1, not 1.1.0 — the image's own verifier
warns that 1.1.0 is not what zot v2.1.21 implements. `storage.gc: true` is on so
a re-pushed tag cannot leave its layers on the durable disk forever. No
retention policy: nothing pushes yet, and it deletes manifests on a schedule;
the verified shape to add is in the component README.

Verified: config accepted by the image's verifier, chart render valid (6/6),
rendered StatefulSet passes policy/k8s.rego, layer validates under L0 flux
build + L1 kubeconform/conftest, all five kinds accepted by a server-side dry
run against the installed Flux CRDs, and tests/test_chart_sources.py resolves
oci://ghcr.io/project-zot/helm-charts/zot:0.1.124 (the OCIRepository form Flux
requires — it fetches `url:tag` verbatim, unlike the helm CLI).

Not verified, and deliberately not claimed: the registry is not deployed, so
ARCHITECTURE.md marks it `planned`. Flux deploys it on the next reconcile; flip
the row once a pod is Running and a real push succeeds.
feat(zot): enable two-tier image retention and gc scheduling
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
9d3626c1e7
The registry sits on the durable tier, shared with Vault and Pocket ID, so
image churn has to be bounded rather than hoped about. Retention was left off
in the previous commit because it deletes manifests on a schedule; this turns
it on with the semantics confirmed first.

  preview/**     the 5 most recent pr-* tags per repository, untagged deleted
                 after 24h
  everything else the 20 most recent tags, or anything pushed in the last 90
                 days, untagged never deleted

gc scheduling is now explicit (gcDelay 1h, gcInterval 24h) because retention is
evaluated on the GC schedule and the upstream docs contradict themselves on
what gcInterval defaults to when omitted — the config table says one hour, the
attribute description says no periodic collection. Setting it is the only way
to know.

The policy also fixes the push convention: per-PR images must go to
preview/<name> with a pr-* tag, because that is the tier that gets trimmed.
Anything else is in the catch-all tier and survives 90 days.

Three properties of the retention language drove the shape, each of which
deletes things silently if read backwards:

- keepTags is a whitelist. A policy naming repositories but not what to keep
  empties them, so the `**` entry carrying an explicit `.*` is load-bearing,
  not decoration.
- First matching policy wins per repository, so preview/** must precede **.
- deleteUntagged: false in the catch-all tier is deliberate: an untagged
  manifest is what a digest pin (image@sha256:...) looks like, so collecting
  them would eventually break exactly the immutable pin that makes a deploy
  reproducible, days later, as an ImagePullBackOff. The preview tier can afford
  true because nothing there is pinned by digest.

Verified on a throwaway zot v2.1.21 with the delays shrunk so decisions arrive
in seconds, not hours: five distinct images tagged preview/app:pr-1..pr-5 left
holding pr-4/pr-5; the catch-all tier logged v1/v2 as "didn't meet any tag
retention rule" and left v3/v4; an untagged manifest in the catch-all tier
stayed pullable by digest after a GC pass.

One sharp edge found and documented: mostRecentlyPushedCount ranks by manifest
push timestamp, so tags pointing at the same manifest share timestamps and "the
N most recent" is meaningless among them — five tags on one manifest kept pr-1
and pr-5 rather than the last two pushed. Real per-PR images are distinct
builds, so the tiers behave as intended.

The shipped config also passes `zot verify` and `zot verify-feature retention`
(which parses it back as GCInterval 24h, GCDelay 1h, PushedWithin 90d).
tepichord deleted branch feat/48-zot-oci-service 2026-09-12 07:28:12 -07:00
tepichord referenced this pull request from a commit 2026-09-12 07:28:12 -07:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
tepichord/milner!50
No description provided.