# Methods library for zot — sourced by install-zot.sh (and the orchestrator's # generated run-*.sh). SCRIPT_DIR and ZOT_* env must be set before sourcing. _resolve_kubeconfig() { if [ -n "${KUBECONFIG:-}" ] && [ -f "$KUBECONFIG" ]; then return fi for candidate in \ /var/lib/k0s/pki/admin.conf \ /etc/kubernetes/admin.conf \ /root/.kube/config; do if [ -f "$candidate" ]; then export KUBECONFIG="$candidate" return fi done if command -v k0s >/dev/null 2>&1; then local tmp tmp="$(mktemp)" k0s kubeconfig admin > "$tmp" 2>/dev/null && export KUBECONFIG="$tmp" && return rm -f "$tmp" fi echo "ERROR: kubeconfig not found — set KUBECONFIG explicitly" >&2; exit 1 } _kubectl() { if command -v kubectl >/dev/null 2>&1; then command kubectl "$@" elif command -v k0s >/dev/null 2>&1; then k0s kubectl "$@" else echo "ERROR: no kubectl or k0s binary found" >&2; exit 127 fi } _resolve_kubeconfig _require_env() { local var="$1" if [ -z "${!var:-}" ]; then echo "ERROR: required variable $var is not set" >&2 exit 1 fi } _namespace_exists() { _kubectl get namespace "$ZOT_NAMESPACE" >/dev/null 2>&1 } _wait_for_statefulset() { local name="$1" local timeout="${2:-300}" _kubectl rollout status statefulset/"$name" \ --namespace "$ZOT_NAMESPACE" \ --timeout="${timeout}s" } _apply_cert_manager_resources() { local cert_template="$SCRIPT_DIR/manifests/certificate.yaml" [ -s "$cert_template" ] || return 0 _require_env ZOT_CF_DNS_TOKEN echo " [cert-manager] applying CF DNS-01 token secret '${ZOT_CF_DNS_SECRET_NAME}' in namespace 'cert-manager'..." _kubectl create secret generic "${ZOT_CF_DNS_SECRET_NAME}" \ --namespace cert-manager \ --from-literal=api-token="${ZOT_CF_DNS_TOKEN}" \ --dry-run=client -o yaml | _kubectl apply -f - echo " [cert-manager] applying ClusterIssuer..." local issuer_name issuer_name=$(grep -m1 'name:' "$SCRIPT_DIR/manifests/clusterissuer.yaml" | awk '{print $2}') if ! _kubectl get clusterissuer "$issuer_name" >/dev/null 2>&1; then _kubectl apply --server-side -f "$SCRIPT_DIR/manifests/clusterissuer.yaml" fi echo " [cert-manager] applying Certificate (namespace=${ZOT_TLS_NAMESPACE})..." _kubectl apply --server-side -f "$cert_template" if [ -s "$SCRIPT_DIR/manifests/referencegrant.yaml" ]; then echo " [cert-manager] applying ReferenceGrant (cross-namespace cert read)..." _kubectl apply --server-side -f "$SCRIPT_DIR/manifests/referencegrant.yaml" fi echo " [cert-manager] waiting for TLS secret '${ZOT_TLS_SECRET}' in namespace '${ZOT_TLS_NAMESPACE}' (up to 300s)..." local i=0 until _kubectl get secret "${ZOT_TLS_SECRET}" --namespace "${ZOT_TLS_NAMESPACE}" >/dev/null 2>&1; do i=$((i + 1)) if [ $i -ge 60 ]; then echo "ERROR: tls secret '${ZOT_TLS_SECRET}' not created after 300s in '${ZOT_TLS_NAMESPACE}' — check ClusterIssuer and DNS challenge" >&2 _kubectl describe certificate "${ZOT_TLS_SECRET}" --namespace "${ZOT_TLS_NAMESPACE}" 2>&1 | tail -30 >&2 || true exit 1 fi sleep 5 done echo " [cert-manager] TLS secret '${ZOT_TLS_SECRET}' ready in '${ZOT_TLS_NAMESPACE}'" } # Redesigned 2026-07-03 (candidate remediation, R4 — see # provisioning/.coder/2026-07-03_laws-catalogo-provisioning.plan.md §8 T4-followup). # Was: kubectl get gateway -o json | jq (decide if listener exists) | kubectl patch. # Now: declarative Server-Side Apply of a manifest rendered by the orchestrator # (manifests/public-gateway-listener.yaml.j2 -> .yaml, same convention as # clusterissuer/certificate/referencegrant above). This works safely on a Gateway # object SHARED with other components/field-managers because Gateway API's # spec.listeners is marked `x-kubernetes-list-type: map` (merge key: name) — # Server-Side Apply merges by listener name instead of replacing the whole array, # so this never touches listeners owned by other managers. No read, no decision; # naturally idempotent (re-applying identical content is a no-op). # --force-conflicts: the sharing-key Gateway-creation branch above may have # already declared this same listener via plain (client-side) `kubectl apply`: # take ownership under field-manager=zot-registry rather than erroring on the # pre-existing client-side-apply field ownership. _patch_public_gateway_listener() { local gw="${ZOT_PUBLIC_GATEWAY_NAME}" [ -z "$gw" ] && return 0 [ -z "${ZOT_GATEWAY_HOSTNAME_REG}" ] && { echo "ERROR: ZOT_GATEWAY_HOSTNAME_REG required when ZOT_PUBLIC_GATEWAY_NAME is set" >&2; exit 1; } local manifest="$SCRIPT_DIR/manifests/public-gateway-listener.yaml" [ -s "$manifest" ] || { echo "ERROR: $manifest not found or empty — was it rendered by the orchestrator?" >&2; exit 1; } _kubectl apply --server-side --force-conflicts --field-manager=zot-registry -f "$manifest" echo " [ok] gateway ${gw}: HTTPS listener https-reg applied (server-side, field-manager=zot-registry)" } # Redesigned 2026-07-03, same pass as the add path: declarative Server-Side Apply # retraction. Applying an explicit empty `listeners: []` under the SAME field # manager that added the listener (zot-registry) tells the API server that # manager now owns zero entries in the merge-keyed (x-kubernetes-list-type=map) # list — it prunes exactly what zot-registry previously contributed (https-reg), # leaving listeners owned by any other field manager on this shared Gateway # untouched. field-manager=zot-registry never declared gatewayClassName or other # top-level Gateway fields (those are owned by whichever manager created the # Gateway itself), so this partial apply cannot affect them. Accepted on the same # documented Gateway API + SSA per-manager ownership basis as the add path — not # separately exercised against a live cluster. _remove_public_gateway_listener() { local gw="${ZOT_PUBLIC_GATEWAY_NAME}" local gw_ns="${ZOT_PUBLIC_GATEWAY_NS}" [ -z "$gw" ] && return 0 _kubectl apply --server-side --force-conflicts --field-manager=zot-registry -f - <&2 repos_block='{"**":{"anonymousPolicy":[],"defaultPolicy":[]}}' fi local cfg cfg=$(jq -cn \ --arg data_dir "$data_dir" \ --arg port "$port" \ --argjson wp "$write_policy" \ --arg admin "$admin_user" \ --argjson repos "$repos_block" \ '{ distSpecVersion: "1.1.0", storage: { rootDirectory: $data_dir, dedupe: false, gc: true }, http: { address: "0.0.0.0", port: $port, realm: "zot", readTimeout: "0s", writeTimeout: "0s", accessControl: { adminPolicy: { users: [$admin] }, repositories: $repos } }, log: { level: "info" }, extensions: { search: { enable: true }, ui: { enable: true }, trust: { enable: true, cosign: true } } }') if [ -n "${ZOT_S3_BUCKET:-}" ]; then cfg=$(echo "$cfg" | jq \ --arg bucket "$ZOT_S3_BUCKET" \ --arg region "${ZOT_S3_REGION:-eu-central-1}" \ --arg endpoint "${ZOT_S3_ENDPOINT:-}" \ --argjson pstyle "${ZOT_S3_PATH_STYLE:-true}" \ '.storage.storageDriver = {name:"s3",rootdirectory:"/zot",region:$region,bucket:$bucket,secure:true,skipverify:false,forcepathstyle:$pstyle} | if $endpoint != "" then .storage.storageDriver.regionendpoint = $endpoint else . end') fi if [ -n "${ZOT_HTPASSWD:-}" ]; then cfg=$(echo "$cfg" | jq '.http.auth = {htpasswd:{path:"/etc/zot/htpasswd"},failDelay:0}') fi if [ "${ZOT_POD_TLS:-}" = "true" ] && [ -n "${ZOT_TLS_SECRET:-}" ]; then cfg=$(echo "$cfg" | jq '.http.tls = {cert:"/etc/zot/ssl/tls.crt",key:"/etc/zot/ssl/tls.key"}') fi if [ -n "${ZOT_SYNC_UPSTREAM:-}" ]; then local prefixes_json prefixes_json=$(printf '%s' "${ZOT_SYNC_UPSTREAM_PREFIXES:-domains/**,modes/**}" \ | tr ',' '\n' | sed 's/^ *//;s/ *$//' | jq -Rn '[inputs] | map({prefix:.})') cfg=$(echo "$cfg" | jq \ --arg u "$ZOT_SYNC_UPSTREAM" \ --argjson tls "${ZOT_SYNC_UPSTREAM_TLS:-true}" \ --argjson od "${ZOT_SYNC_ON_DEMAND:-true}" \ --argjson content "$prefixes_json" \ --arg dl "${ZOT_DATA_DIR:-/var/lib/data/zot}/sync-tmp" \ '.extensions.sync = {downloadDir:$dl,credentialsFile:"/etc/zot/sync-creds.json", registries:[{urls:[$u],tlsVerify:$tls,onDemand:$od,content:$content}]}') fi echo "$cfg" } _apply_inline_manifests() { local config_json config_json=$(_generate_config_json) _kubectl apply -f - </dev/null 2>&1; then _kubectl apply -f - <&2; exit 1; } # Dedicated IP pool: skipped when sharing an existing LB IP (ZOT_GATEWAY_SHARING_KEY set). # Sharing mode uses infrastructure.annotations (io.cilium/lb-ipam-ips + # lbipam.cilium.io/sharing-key) so the existing pool for that IP is reused. if [ -z "${ZOT_GATEWAY_SHARING_KEY}" ]; then _kubectl apply -f - <&2; exit 1; } if [ -n "${ZOT_GATEWAY_SHARING_KEY}" ]; then _kubectl apply -f - <&2 exit 1 ;; esac echo " UI : https://${ZOT_GATEWAY_HOSTNAME_UI}" echo " Registry : https://${ZOT_GATEWAY_HOSTNAME_REG}" _patch_public_gateway_listener _create_public_httproute fi _upload_cosign_trust_key } # Upload cosign public key to zot's imagetrust trust store. # API: POST /v2/_zot/ext/cosign (Content-Type: text/plain, PEM public key body). # Skipped when ZOT_COSIGN_PUBKEY_PATH is unset or ZOT_ADMIN_PASS is empty. # Uses ZOT_GATEWAY_HOSTNAME_REG via curl — never propagates failures. _upload_cosign_trust_key() { [ -z "${ZOT_COSIGN_PUBKEY_PATH:-}" ] && return 0 if [ ! -f "${ZOT_COSIGN_PUBKEY_PATH}" ]; then echo "WARNING: ZOT_COSIGN_PUBKEY_PATH=${ZOT_COSIGN_PUBKEY_PATH} not found — skipping imagetrust upload" >&2 return 0 fi if [ -z "${ZOT_ADMIN_PASS:-}" ]; then echo "WARNING: ZOT_ADMIN_PASS not set — skipping imagetrust cosign key upload" >&2 return 0 fi local upload_url="https://${ZOT_GATEWAY_HOSTNAME_REG}/v2/_zot/ext/cosign" echo " [imagetrust] uploading cosign public key → ${upload_url}..." local http_code curl_exit set +e http_code=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${upload_url}" \ -u "${ZOT_ADMIN_USER}:${ZOT_ADMIN_PASS}" \ -H "Content-Type: text/plain" \ --data-binary "@${ZOT_COSIGN_PUBKEY_PATH}" 2>/dev/null) curl_exit=$? set -e if [ "${curl_exit}" -ne 0 ]; then echo "WARNING: curl failed (exit ${curl_exit}) reaching ${ZOT_GATEWAY_HOSTNAME_REG} — imagetrust key upload skipped" >&2 return 0 fi if [ "${http_code}" = "200" ] || [ "${http_code}" = "201" ] || [ "${http_code}" = "204" ]; then echo " [imagetrust] cosign public key uploaded (HTTP ${http_code})" else echo "WARNING: imagetrust cosign key upload returned HTTP ${http_code} — run manually: POST ${upload_url}" >&2 fi } _do_update() { _kubectl set image statefulset/zot \ zot="${ZOT_IMAGE}" \ --namespace "$ZOT_NAMESPACE" echo "Waiting for zot rollout..." _wait_for_statefulset "zot" 300 echo "zot updated to image ${ZOT_IMAGE}" } _do_delete() { if _namespace_exists; then echo "WARNING: this deletes the zot StatefulSet but NOT the PVC (durable lifecycle)" _kubectl delete statefulset zot --namespace "$ZOT_NAMESPACE" --timeout=60s || true _kubectl delete service zot --namespace "$ZOT_NAMESPACE" --timeout=30s || true _kubectl delete configmap zot-config --namespace "$ZOT_NAMESPACE" --timeout=30s || true _kubectl delete httproute zot --namespace "$ZOT_NAMESPACE" --timeout=30s 2>/dev/null || true _kubectl delete httproute zot-public --namespace "$ZOT_NAMESPACE" --timeout=30s 2>/dev/null || true _kubectl delete tlsroute zot --namespace "$ZOT_NAMESPACE" --timeout=30s 2>/dev/null || true echo "zot removed — PVC and S3 data preserved" else echo "Namespace ${ZOT_NAMESPACE} does not exist, nothing to delete" fi _kubectl delete gateway registry --namespace "${ZOT_GATEWAY_NAMESPACE}" --timeout=30s || true _remove_public_gateway_listener } _do_health() { local pod pod=$(_kubectl get pod --namespace "$ZOT_NAMESPACE" \ --selector app.kubernetes.io/name=zot \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [ -z "$pod" ]; then echo "UNHEALTHY: no zot pod found in namespace ${ZOT_NAMESPACE}" >&2 exit 1 fi local scheme="http" local wget_tls_flag="" if [ -n "${ZOT_TLS_SECRET}" ]; then scheme="https" wget_tls_flag="--no-check-certificate" fi local status status=$(_kubectl exec --namespace "$ZOT_NAMESPACE" "$pod" -- \ wget -qO- --server-response ${wget_tls_flag} "${scheme}://localhost:${ZOT_PORT}/v2/" 2>&1 \ | grep "HTTP/" | tail -1 | awk '{print $2}' || echo "") if [ "$status" = "200" ] || [ "$status" = "401" ]; then echo "HEALTHY: zot /v2/ returned HTTP ${status}" else echo "UNHEALTHY: zot /v2/ returned HTTP ${status} (expected 200 or 401)" >&2 exit 1 fi }