infra/stacks/kyverno/modules/kyverno/ghcr-credentials.tf
Viktor Barzin c13a3f1694 plotting-book: pull image from private ghcr instead of public DockerHub
Anca's plotting-book app now builds its image in her own GitHub repo to
the private package ghcr.io/passionprojectsanca/book-plotter (off public
DockerHub viktorbarzin/book-plotter). Wire the cluster to pull it:

- stacks/plotting-book: point the deployment baseline image at the ghcr
  package and add imagePullSecrets {ghcr-credentials} so the pod can pull
  the private image (the live tag is still CI-owned via ignore_changes).
- stacks/kyverno: add the plotting-book namespace to the ghcr-credentials
  allowlist so the Kyverno generate policy clones the pull secret into it.
  Verified the shared ghcr_pull_token (Viktor, repo-admin on Anca's repo)
  can read the private package before wiring this.

Docs: correct ci-cd.md (it wrongly listed plotting-book as already on
ghcr — it was on DockerHub) and note the special arrangement; amend
ADR-0003 to record that this GitHub-first repo builds to its own org's
ghcr namespace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:32:19 +00:00

112 lines
4.1 KiB
HCL

# =============================================================================
# ghcr.io pull credentials — synced ONLY to namespaces running PRIVATE ghcr
# images (ADR-0002 off-infra builds)
# =============================================================================
# The credential is Viktor's admin PAT (Vault secret/viktor/ghcr_pull_token —
# an alias of github_pat: GitHub has no API to mint tokens, so a UI-minted
# read:packages token can replace the alias value later with no TF change).
# Because the PAT is broad, this is a positive allowlist, NOT cluster-wide
# like registry-credentials: any workload in a listed namespace can read the
# secret, so every entry widens the blast radius. Public-image namespaces
# need no credentials — keep this list to private-image consumers only.
locals {
ghcr_private_namespaces = [
"tripit",
# tuya-bridge runs a PUBLIC-decision image, but new ghcr packages default
# PRIVATE until their visibility is flipped (UI) — safety net so pulls
# work from the first deploy; prune once the package is public.
"tuya-bridge",
"f1-stream",
"job-hunter",
"instagram-poster",
"payslip-ingest",
"wealthfolio",
"fire-planner",
"recruiter-responder",
# openclaw's install-recruiter-plugin init container pulls the PRIVATE
# ghcr.io/viktorbarzin/recruiter-responder:latest image (infra#27).
"openclaw",
# k8s-portal: last in-cluster image build, migrated to GHA→ghcr (ADR-0002,
# "no local builds"). ghcr.io/viktorbarzin/k8s-portal:latest is PRIVATE
# (infra repo default); the deployment references the cloned secret.
"k8s-portal",
# goldmane-edge-aggregator: PRIVATE ghcr image pulled by the aggregate
# Deployment + digest CronJob (ADR-0014, infra#58).
"goldmane-edge-aggregator",
# plotting-book: image migrated from public DockerHub to PRIVATE
# ghcr.io/passionprojectsanca/book-plotter (built by GHA in Anca's repo,
# under her own org's ghcr). The deployment references the cloned secret.
"plotting-book",
]
}
resource "kubernetes_secret" "ghcr_credentials" {
metadata {
name = "ghcr-credentials"
namespace = kubernetes_namespace.kyverno.metadata[0].name
}
type = "kubernetes.io/dockerconfigjson"
data = {
".dockerconfigjson" = jsonencode({
auths = {
"ghcr.io" = {
username = "ViktorBarzin"
password = try(data.vault_kv_secret_v2.viktor.data["ghcr_pull_token"], "")
auth = base64encode("ViktorBarzin:${try(data.vault_kv_secret_v2.viktor.data["ghcr_pull_token"], "")}")
}
}
})
}
}
resource "kubectl_manifest" "sync_ghcr_credentials" {
# Kyverno's validate-policy webhook DENIES in-place changes to a generate
# rule's spec ("changes of immutable fields ... is disallowed"), so any
# allowlist edit must delete+recreate the policy. Generated secrets survive
# policy deletion; generateExisting re-adopts them on recreate.
force_new = true
yaml_body = yamlencode({
apiVersion = "kyverno.io/v1"
kind = "ClusterPolicy"
metadata = {
name = "sync-ghcr-credentials"
}
spec = {
rules = [
{
name = "sync-ghcr-secret"
match = {
any = [
{
resources = {
kinds = ["Namespace"]
names = local.ghcr_private_namespaces
}
}
]
}
generate = {
generateExisting = true
apiVersion = "v1"
kind = "Secret"
name = "ghcr-credentials"
namespace = "{{request.object.metadata.name}}"
synchronize = true
clone = {
namespace = "kyverno"
name = "ghcr-credentials"
}
}
}
]
}
})
depends_on = [
helm_release.kyverno,
kubernetes_secret.ghcr_credentials,
kubernetes_cluster_role_binding.kyverno_admission_secret_manager,
kubernetes_cluster_role_binding.kyverno_background_secret_manager,
]
}