Info

  • Docker
  • CI/CD
  • Umair Abbas
Possedere repo, CI e registry dal primo giorno: la checklist del fondatore per lo sviluppo affidato all'esternoPossedere repo, CI e registry dal primo giorno: la checklist del fondatore per lo sviluppo affidato all'esterno

Possedere repo, CI e registry dal primo giorno: la checklist del fondatore per lo sviluppo affidato all'esterno

La maggior parte dei progetti affidati all’esterno non fallisce sul codice. Fallisce il giorno in cui il fornitore smette di rispondere.

Ho una piccola agenzia di software, e buona parte del nostro lavoro in questi anni è stato raccogliere prodotti lasciati a metà da un altro team. Lo schema si ripete con una regolarità noiosa. Il codice sta in un repository che il fondatore può leggere, ma l’immagine è costruita a partire da una base che vive nel registry privato del vecchio fornitore. La CI gira sotto l’account personale di qualcuno. La produzione scarica :latest da un namespace in cui nessuno, dalla parte del cliente, riesce ad autenticarsi. Tutto funziona finché non funziona più, e a quel punto nessuno è in grado di ricostruire niente.

Questo articolo è la checklist che oggi consegno ai fondatori prima del primo commit. Non è teoria. Ogni comando e ogni output qui sotto sono stati eseguiti su una normale macchina Debian 13 con Docker 26.1 (l’ambiente esatto è in fondo), contro un piccolo repository dimostrativo. Puoi riprodurre tutto in una decina di minuti.

La regola: un estraneo con le tue credenziali deve poter pubblicare

La prova che uso è semplice. Dai a un ingegnere competente che non ha mai incontrato il fornitore:

  • accesso in lettura al repository,
  • accesso in scrittura al tuo container registry,
  • nient’altro.

Riesce a clonare, costruire, eseguire e pubblicare esattamente il servizio che sta in produzione? Se la risposta è «dovrebbe chiedere qualcosa all’agenzia», il prodotto non è ancora tuo. Tutto quello che segue serve a trasformare quella risposta in un sì, e a dimostrarlo con uno script invece che con una promessa.

1. Il repository vive nella tua organizzazione

Sembra ovvio. Ed è anche la voce che trovo disattesa più spesso.

Il repository deve stare sotto un’organizzazione controllata dall’azienda, con almeno due persone dell’azienda come proprietari. Gli ingegneri del fornitore vengono invitati come membri o collaboratori esterni. Non creano il repository nella loro organizzazione per «trasferirlo più avanti». Più avanti non arriva mai, oppure arriva durante una controversia.

Metti un file CODEOWNERS dal primo giorno, così la proprietà è scritta nel repository e non in un allegato contrattuale:

# Client-side engineers own every path. The vendor team is added as reviewers, not owners.
* @acme-corp/platform-owners
/.github/ @acme-corp/platform-owners
/Dockerfile @acme-corp/platform-owners

CODEOWNERS ha effetto solo quando la protezione del branch richiede la revisione del code owner: attivala anche su main. Se il tuo host git è GitHub, puoi controllare chi possiede davvero le cose con la CLI (lanciali sulla tua organizzazione; l’output dipende dal tuo account):

Terminal window
gh api repos/acme-corp/handoff-demo --jq '.owner.login, .owner.type'
gh api "orgs/acme-corp/members?role=admin" --jq '.[].login'

Il primo comando deve stampare il nome della tua organizzazione e Organization, il secondo deve elencare persone sul tuo libro paga.

2. Fissa le immagini base per digest, non per tag

I tag si spostano. python:3.12-slim oggi e python:3.12-slim fra otto mesi sono byte diversi. Per un progetto del fine settimana va benissimo, per una consegna è pessimo: il nuovo team non ha modo di capire se un difetto è suo o dell’immagine base.

Fissa il digest e lascia un commento che spieghi perché:

# Base image pinned by digest: a rebuild next year uses the same bytes.
FROM python:3.12-slim@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f

Per trovare il digest di ciò che hai già scaricato:

Terminal window
docker images --digests python
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE
python 3.12-slim sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f df18428df8a0 17 hours ago 119MB

Aggiornare la base diventa così una pull request di una riga, sottoposta a revisione, invece di un effetto collaterale invisibile della ricostruzione. Strumenti come Dependabot o Renovate possono aprirle al posto tuo.

3. Niente immagini base dal registry privato del fornitore

Questa è quella che fa più male, quindi ecco com’è fatta quando va storta. Un tipico Dockerfile «dell’era del fornitore»:

FROM ghcr.io/example-vendor-agency/python-base:latest
COPY app/ ./app/
CMD ["python", "app/server.py"]

Sui portatili del fornitore questo si costruisce, perché loro sono autenticati. Su una macchina pulita senza le loro credenziali, questo è l’output vero (ridotto alle righe che contano):

$ docker build -t acme/handoff-demo:vendor-era .
#2 [internal] load metadata for ghcr.io/example-vendor-agency/python-base:latest
#2 ERROR: failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Aexample-vendor-agency%2Fpython-base%3Apull&service=ghcr.io: 403 Forbidden
------
Dockerfile:1
--------------------
1 | >>> FROM ghcr.io/example-vendor-agency/python-base:latest
2 | COPY app/ ./app/
3 | CMD ["python", "app/server.py"]
--------------------
ERROR: failed to solve: ghcr.io/example-vendor-agency/python-base:latest: failed to resolve source metadata for ghcr.io/example-vendor-agency/python-base:latest: failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Aexample-vendor-agency%2Fpython-base%3Apull&service=ghcr.io: 403 Forbidden
exit code: 1

(example-vendor-agency è un namespace di esempio, quindi il 403 qui sopra è reale, ma è lo stesso errore che otterresti da un namespace privato a cui non hai accesso.)

Se il fornitore ha un’immagine base irrobustita a cui tiene, va benissimo. Viene copiata nel tuo registry, il suo Dockerfile entra nel tuo repository, e le tue immagini si costruiscono a partire da lì.

Una macchina pulita non riesce a scaricare l'immagine base privata del fornitore

4. Ogni immagine dice da dove viene

Quando qualcosa si rompe alle due di notte, la prima domanda è «quale commit sta girando?». Le etichette OCI rispondono senza dover ricostruire la cronologia delle chat:

ARG GIT_SHA=unknown
ARG SOURCE_URL=unknown
LABEL org.opencontainers.image.source=$SOURCE_URL \
org.opencontainers.image.revision=$GIT_SHA \
org.opencontainers.image.title="handoff-demo"

Poi chiunque abbia l’immagine può fare così:

Terminal window
docker image inspect localhost:5000/acme/handoff-demo:203d8f1 \
--format '{{ index .Config.Labels "org.opencontainers.image.source" }} @ {{ index .Config.Labels "org.opencontainers.image.revision" }}'
file:///tmp/client-git/acme/handoff-demo.git @ 203d8f1

Nella CI vera la sorgente è l’URL del tuo repository e la revisione è lo SHA completo del commit. Anche GHCR usa org.opencontainers.image.source per collegare un pacchetto al suo repository.

Già che sei nel Dockerfile, fai girare il processo come utente non privilegiato. Costa due righe ed è un rilievo in meno nella prima revisione di sicurezza dopo la consegna:

RUN useradd --system --uid 10001 --no-create-home app
USER 10001

5. Il registry è nel tuo account

La produzione deve scaricare le immagini da un registry di proprietà dell’azienda: GHCR sotto la tua organizzazione, ECR nel tuo account AWS, Artifact Registry nel tuo progetto GCP, oppure un registry self-hosted. La CI del fornitore ci scrive con una credenziale a perimetro ristretto che hai emesso tu e che puoi revocare.

Per la dimostrazione ho usato l’immagine ufficiale registry:2 al posto del «registry del cliente»:

$ docker run -d --name client-registry -p 5000:5000 registry:2
be33ebd056ea7ac90d49ebd0a348e568c62fbc51739ad42ee1dd159665b591b5

Dopo una push, l’API del registry mostra esattamente ciò che possiedi:

$ curl -s http://localhost:5000/v2/_catalog
{"repositories":["acme/handoff-demo"]}
$ curl -s http://localhost:5000/v2/acme/handoff-demo/tags/list
{"name":"acme/handoff-demo","tags":["12d12c4","203d8f1"]}

Nota che i tag sono SHA di commit, non latest. Rilascia per digest o per tag SHA, così tornare indietro è una modifica di una parola.

6. La CI è un file nel repository, e gira sotto l’organizzazione

La configurazione della CI appartiene al repository, e la pipeline gira sotto l’account dell’organizzazione, non sotto quello personale di un ingegnere del fornitore. I segreti vivono nell’archivio dei segreti dell’organizzazione. Ecco il workflow del repository dimostrativo, per GitHub Actions:

name: build-and-push
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Log in to the client's registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build (and push on main)
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
build-args: |
GIT_SHA=${{ github.sha }}
SOURCE_URL=${{ github.server_url }}/${{ github.repository }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

Due dettagli contano per la proprietà. Usa il GITHUB_TOKEN integrato, quindi non c’è nessun token personale a lunga vita da ereditare. E pubblica su ghcr.io/${{ github.repository }}, quindi l’immagine finisce sotto chi possiede il repository, cioè tu.

Prima di fidarmi di un workflow lo passo al linter:

$ actionlint .github/workflows/build.yml && echo "actionlint: no issues"
actionlint: no issues

7. Dimostralo: il controllo di consegna

Le checklist marciscono. Uno script no, finché lo esegui. Questa è la versione che uso per i servizi piccoli. Clona in una directory temporanea, punta Docker a una configurazione vuota perché nessun login salvato possa aiutare, poi costruisce, esegue, sonda, ispeziona e fa un giro completo dell’immagine attraverso il tuo registry.

#!/usr/bin/env bash
# handoff-check.sh: can someone who is NOT the vendor rebuild, run and ship this repo?
# Usage: ./handoff-check.sh <git-url-or-path> <registry/namespace>
set -euo pipefail
REPO="$1"
REGISTRY="$2"
WORK="$(mktemp -d)"
export DOCKER_CONFIG="$WORK/docker-config" # empty config: no saved vendor logins
mkdir -p "$DOCKER_CONFIG"
echo "== 1. Fresh clone"
git clone --quiet "$REPO" "$WORK/src"
cd "$WORK/src"
SHA="$(git rev-parse --short HEAD)"
echo "HEAD is $SHA"
echo "== 2. Secrets that should never be in git"
if git ls-files | grep -E '(\.env$|\.pem$|\.key$|id_rsa)'; then
echo "FAIL: secret-looking files are tracked"; exit 1
else
echo "OK: no tracked .env/.pem/.key files"
fi
echo "== 3. Unpinned base images"
if grep -E '^FROM ' Dockerfile | grep -v '@sha256:'; then
echo "WARN: base image not pinned by digest"
else
echo "OK: every FROM is pinned by digest"
fi
echo "== 4. Build with no registry credentials"
docker build --quiet \
--build-arg GIT_SHA="$SHA" \
--build-arg SOURCE_URL="$REPO" \
-t "$REGISTRY/handoff-demo:$SHA" .
echo "== 5. Run and probe"
CID="$(docker run -d -p 18080:8080 "$REGISTRY/handoff-demo:$SHA")"
for i in $(seq 1 20); do
curl -fsS http://127.0.0.1:18080/healthz 2>/dev/null && break
sleep 0.5
done
echo
echo "runs as uid: $(docker exec "$CID" id -u)"
docker rm -f "$CID" >/dev/null
echo "== 6. Provenance labels"
docker image inspect "$REGISTRY/handoff-demo:$SHA" \
--format '{{ index .Config.Labels "org.opencontainers.image.source" }} @ {{ index .Config.Labels "org.opencontainers.image.revision" }}'
echo "== 7. Push to the client's registry and pull back by digest"
docker push --quiet "$REGISTRY/handoff-demo:$SHA"
DIGEST="$(docker image inspect "$REGISTRY/handoff-demo:$SHA" --format '{{ index .RepoDigests 0 }}')"
docker rmi --force "$REGISTRY/handoff-demo:$SHA" >/dev/null
docker pull --quiet "$DIGEST"
echo "pulled $DIGEST"
rm -rf "$WORK"
echo "== handoff-check passed"

Eseguito contro il repository dimostrativo (un bare repository che fa le veci dell’host git del cliente) e il registry locale:

$ ./handoff-check.sh file:///tmp/client-git/acme/handoff-demo.git localhost:5000/acme
== 1. Fresh clone
HEAD is 203d8f1
== 2. Secrets that should never be in git
OK: no tracked .env/.pem/.key files
== 3. Unpinned base images
OK: every FROM is pinned by digest
== 4. Build with no registry credentials
sha256:60d46686c0cc427edffe8ad32ccc944001ce775a7605a20a90830505c39a6ab1
== 5. Run and probe
{"status": "ok", "version": "203d8f1"}
runs as uid: 10001
== 6. Provenance labels
file:///tmp/client-git/acme/handoff-demo.git @ 203d8f1
== 7. Push to the client's registry and pull back by digest
localhost:5000/acme/handoff-demo:203d8f1
localhost:5000/acme/handoff-demo@sha256:566f323ad28e0c8a27cc5b074b48aba0b0ebb5e35754100eb9417d2397a91546
pulled localhost:5000/acme/handoff-demo@sha256:566f323ad28e0c8a27cc5b074b48aba0b0ebb5e35754100eb9417d2397a91546
== handoff-check passed
handoff-check.sh che passa sul repository dimostrativo

Qualche limite, detto con onestà. Il controllo sui segreti intercetta solo i nomi di file evidenti: per il contenuto serve uno scanner vero come gitleaks o trufflehog. Il passo 4 scarica comunque l’immagine base pubblica in forma anonima, ed è esattamente il punto: basi pubbliche e il tuo registry, nient’altro. E contro un registry vero il passo di push andrebbe eseguito con una credenziale di CI emessa dalla tua organizzazione, non senza nulla.

Chiedo ai fornitori di trasformare questo script (o la loro versione) in un job di CI settimanale. Il giorno in cui diventa rosso è il giorno in cui scopri una dipendenza dal portatile di qualcuno, invece del giorno in cui quel qualcuno se ne va.

La checklist del fondatore

Incollala nel capitolato:

  1. Repository creato nell’organizzazione dell’azienda il primo giorno; almeno due proprietari interni; CODEOWNERS committato e reso vincolante dalla protezione del branch.
  2. Ogni FROM fissato per digest; immagini base solo da registry pubblici o dal registry dell’azienda.
  3. Immagini etichettate con org.opencontainers.image.source e org.opencontainers.image.revision; container eseguiti senza privilegi di root.
  4. Container registry nel cloud o nell’account dell’azienda; immagini marcate con lo SHA del commit; rilasci in produzione per SHA o per digest.
  5. CI definita nel repository, in esecuzione sotto l’organizzazione, con segreti a perimetro organizzazione o con il token integrato della piattaforma.
  6. Un controllo di consegna che cloni, costruisca, esegua e pubblichi senza nessuna credenziale del fornitore, eseguito in CI a cadenza fissa.
  7. Un elenco scritto di ogni account esterno da cui il prodotto dipende (DNS, posta, pagamenti, cloud), ciascuno con un responsabile interno all’azienda.

Niente di tutto questo rallenta un buon fornitore. Un fornitore che oppone resistenza ti sta dicendo qualcosa.

Ambiente usato per questo articolo

docker client 26.1.5+dfsg1 / server 26.1.5+dfsg1 (storage driver: vfs)
git version 2.47.3
actionlint 1.7.12
Linux 6.12.94+ x86_64
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"

Umair Abbas è il fondatore di CodeFlamme, un’agenzia di software di Lahore, in Pakistan, che realizza prodotti web, mobile e AI in cui il cliente possiede repository, CI e cloud dal primo giorno.

Traduzione italiana dell’articolo originale in inglese, Own the Repo, CI and Container Registry on Day One. Comandi, output e script sono riprodotti invariati.


← Back to blog