# Același tag, niciun deploy: de ce `cdk deploy` cu o imagine neschimbată nu face nimic, și scriptul de 300 de linii pe care l-am scris în jurul lui

Două povești despre același pipeline de deploy, spuse împreună pentru că a doua există din cauza primei.

Prima e un bug care nu e bug: am reconstruit o imagine de container, am împins-o sub același tag, am rulat `cdk deploy`, l-am văzut raportând *no changes* și am petrecut o oră înțelegând de ce producția rula în continuare codul vechi. A doua e ce s-a întâmplat când CI-ul nostru a fost indisponibil o vreme, din motive de facturare în afara controlului nostru, și aveam nevoie de un mod de a face deploy de pe un laptop mai sigur decât cel prin care [șterseserăm cândva trei servicii de pe un laptop](/ro/blog/cloudformation-deleted-our-app-runner-services).

## Partea întâi: CloudFormation compară string-uri, nu octeți

Serviciul App Runner e o resursă CloudFormation, iar imaginea pe care o rulează e o proprietate string: `ImageIdentifier: 123456.dkr.ecr.us-east-1.amazonaws.com/api:abc1234`. Când rulezi `cdk deploy`, CloudFormation compară noul template cu ultimul pe care l-a aplicat. Dacă string-ul e identic, nu e diff, nu e update, iar App Runner continuă să servească ce a tras ultima dată.

E un comportament corect și mușcă într-o singură situație: tag-ul e același, dar imaginea din spatele lui nu e. Am dat de asta când am reconstruit același commit cu un argument de build schimbat. Același SHA git, același tag `api:abc1234`, alți octeți în ECR, iar CloudFormation, corect, n-a văzut nimic de făcut. Tag-ul din registry arăta acum spre un digest nou; serviciul rula în continuare pe cel vechi; nimic nicăieri nu era greșit, în afară de așteptarea noastră.

<div class="article-figure">
<svg viewBox="0 0 900 230" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Două rânduri. Rândul de sus, fixare pe tag: tag-ul ECR api:abc1234 e mutat de la digest-ul sha256:1111 la sha256:2222 printr-un rebuild; template-ul spune tot api:abc1234, CloudFormation nu vede niciun diff, App Runner rulează în continuare sha256:1111. Rândul de jos, fixare pe digest: scriptul de deploy rezolvă tag-ul la sha256:2222 și pune digest-ul în template; CloudFormation vede un diff și App Runner desfășoară sha256:2222.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<text x="20" y="26" fill="#ff6b8a" font-size="14" font-weight="700">fixare pe tag</text>
<rect x="20" y="40" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="120" y="62" text-anchor="middle" fill="#f1f3ff">tag ECR api:abc1234</text><text x="120" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">sha256:1111 → rebuild → sha256:2222</text>
<rect x="260" y="40" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="360" y="62" text-anchor="middle" fill="#f1f3ff">template</text><text x="360" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">ImageIdentifier: …/api:abc1234</text>
<rect x="500" y="40" width="170" height="56" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="585" y="62" text-anchor="middle" fill="#ff6b8a" font-weight="700">niciun diff</text><text x="585" y="82" text-anchor="middle" fill="#9aa3c7" font-size="11">același string ca data trecută</text>
<rect x="710" y="40" width="175" height="56" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="797" y="62" text-anchor="middle" fill="#f1f3ff">App Runner</text><text x="797" y="82" text-anchor="middle" fill="#ff6b8a" font-size="11">rulează tot sha256:1111</text>
<text x="20" y="136" fill="#4fffb0" font-size="14" font-weight="700">fixare pe digest</text>
<rect x="20" y="150" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="120" y="172" text-anchor="middle" fill="#f1f3ff">scriptul de deploy</text><text x="120" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">rezolvă tag → sha256:2222</text>
<rect x="260" y="150" width="200" height="56" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="360" y="172" text-anchor="middle" fill="#f1f3ff">template</text><text x="360" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">ImageIdentifier: …/api@sha256:2222</text>
<rect x="500" y="150" width="170" height="56" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="585" y="172" text-anchor="middle" fill="#4fffb0" font-weight="700">diff</text><text x="585" y="192" text-anchor="middle" fill="#9aa3c7" font-size="11">digest-ul s-a schimbat</text>
<rect x="710" y="150" width="175" height="56" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="797" y="172" text-anchor="middle" fill="#f1f3ff">App Runner</text><text x="797" y="192" text-anchor="middle" fill="#4fffb0" font-size="11">desfășoară sha256:2222</text>
</g>
</svg>
</div>

Există două rezolvări oneste. Prima e o regulă: fiecare deploy mută SHA-ul. Dacă vrei o altă imagine, comite ceva. E simplă și e ce am făcut o vreme, și pică în momentul în care cineva trebuie să reconstruiască o imagine dintr-un motiv care nu implică o schimbare de cod, cum ar fi un patch de securitate al imaginii de bază.

A doua e fixarea pe digest. După ce imaginea e împinsă, scriptul de deploy întreabă ECR la ce se rezolvă tag-ul și pasează digest-ul, nu tag-ul, în template:

```bash
digest=$(aws ecr describe-images --repository-name api --image-ids imageTag="$TAG" \
  --query 'imageDetails[0].imageDigest' --output text)
npx cdk deploy platform-prod --context apiImage="$REPO/api@$digest"
```

```ts
// în stack
imageRepository: { imageIdentifier: this.node.tryGetContext('apiImage'), imageRepositoryType: 'ECR' }
```

Acum template-ul se schimbă ori de câte ori se schimbă octeții, și doar atunci. Un rebuild al aceluiași commit cu conținut diferit e un deploy real. O re-rulare cu conținut identic e un no-op autentic. Comportamentul CloudFormation nu s-a schimbat; doar i-am dat string-ul care înseamnă cu adevărat „imaginea”.

Digest-ul apare în consola CloudFormation și în `cdk diff` ca un hash ilizibil de 64 de caractere. Afișăm `tag → digest` în output-ul de deploy, ca un om să poată spune totuși ce pleacă.

## Partea a doua: deploy de pe laptop, în siguranță

CI-ul a dispărut o vreme. Platforma tot avea nevoie de deploy-uri. Învățaserăm deja, scump, că `cdk deploy` de pe mașina unui developer cu contextul greșit șterge resurse de producție, așa că „rulează-l local” nu era o opțiune pe care s-o mai acceptăm. Alternativa era un script a cărui treabă e să facă un deploy de pe laptop să se comporte ca un deploy din CI: aceleași intrări, aceleași bariere, nicio cale de a livra ce nu e în git.

A crescut la vreo 300 de linii de bash. Iată ce fac liniile alea, în ordine.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Pipeline-ul scriptului de deploy în opt pași: 1 preflight refuză un arbore murdar, branch-ul greșit sau lipsa lui --yes; 2 un git worktree e scos la origin/main; 3 git archive al acelui arbore e încărcat în S3; 4 trei job-uri CodeBuild construiesc imaginile în paralel din arhivă; 5 tag-urile sunt rezolvate la digest-uri; 6 cdk diff e afișat și trebuie confirmat; 7 cdk deploy rulează cu digest-urile ca context; 8 verificare post-deploy a health-ului, a SHA-ului de release și a bundle-ului de client, apoi un mesaj pe canalul echipei.">
<defs><marker id="arrDs" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<rect x="15" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="115" y="52" text-anchor="middle" fill="#ff6b8a" font-weight="700">1 · preflight</text><text x="115" y="70" text-anchor="middle" fill="#9aa3c7">arbore murdar? branch greșit? fără --yes? → exit</text>
<line x1="217" y1="60" x2="240" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="243" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="343" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">2 · worktree @ origin/main</text><text x="343" y="70" text-anchor="middle" fill="#9aa3c7">niciodată copia de lucru</text>
<line x1="445" y1="60" x2="468" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="471" y="30" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="571" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">3 · git archive → S3</text><text x="571" y="70" text-anchor="middle" fill="#9aa3c7">exact octeții pe care îi vede CodeBuild</text>
<line x1="673" y1="60" x2="696" y2="60" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="699" y="30" width="185" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="791" y="52" text-anchor="middle" fill="#f1f3ff" font-weight="700">4 · 3 × CodeBuild</text><text x="791" y="70" text-anchor="middle" fill="#9aa3c7">în paralel · ~3 min</text>
<path d="M791,92 L791,120 L115,120 L115,148" fill="none" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="15" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="115" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">5 · tag-uri → digest-uri</text><text x="115" y="190" text-anchor="middle" fill="#9aa3c7">afișate pentru om</text>
<line x1="217" y1="180" x2="240" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="243" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="343" y="172" text-anchor="middle" fill="#ffd166" font-weight="700">6 · cdk diff, confirmare</text><text x="343" y="190" text-anchor="middle" fill="#9aa3c7">orice ștergere → stop și citește</text>
<line x1="445" y1="180" x2="468" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="471" y="150" width="200" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="571" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">7 · cdk deploy</text><text x="571" y="190" text-anchor="middle" fill="#9aa3c7">digest-urile ca context</text>
<line x1="673" y1="180" x2="696" y2="180" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrDs)"/>
<rect x="699" y="150" width="185" height="60" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="791" y="172" text-anchor="middle" fill="#f1f3ff" font-weight="700">8 · verificare + notificare</text><text x="791" y="190" text-anchor="middle" fill="#9aa3c7">health · SHA · grep pe bundle</text>
<text x="450" y="238" text-anchor="middle" fill="#9aa3c7">Un deploy de pe laptop și unul din CI rulează aceiași pași pe aceiași octeți. Laptopul are doar un om la pasul 6.</text>
</g>
</svg>
</div>

**Preflight.** Scriptul refuză să ruleze dacă arborele de lucru are schimbări necomise, dacă `--env prod` e dat fără `--yes`, dacă branch-ul curent nu e `main` pentru un deploy de prod sau dacă identitatea AWS pe care urmează s-o folosească nu e rolul de deploy așteptat pentru contul ăla. Fiecare refuz afișează o propoziție care spune ce e de făcut. Cam 40 de linii, și cele mai valoroase 40 din fișier.

**Worktree.** Creează un `git worktree` detașat la `origin/main` (după un `git fetch`) într-un director temporar și face totul de acolo. Orice e în copia de lucru a developerului, stash-uit, pe jumătate editat sau experimental, nu poate pleca. SHA-ul acelui worktree e identificatorul de release pentru tot ce urmează.

**Arhivă în S3.** `git archive` al worktree-ului, încărcat într-un bucket de build sub SHA. CodeBuild construiește din arhiva aia, nu dintr-o conexiune GitHub, deci intrarea build-ului e aceiași octeți fie că e declanșat de CI, fie de script.

**Trei build-uri în paralel.** Câte un proiect CodeBuild per aplicație, pornite împreună, interogate împreună. Construirea a trei imagini Next.js pe Docker-ul unui laptop ar dura opt minute și ar încălzi camera; pe trei instanțe CodeBuild durează trei. Proiectele sunt aceleași pe care le folosește CI.

**Digest-uri.** Ca în partea întâi. Scriptul rezolvă fiecare tag și afișează un tabel `app → tag → digest`.

**Diff și confirmare.** `cdk diff` contra mediului țintă, afișat integral. Dacă diff-ul conține vreo ștergere de resursă, scriptul o evidențiază și cere operatorului să tasteze numele stack-ului ca să continue, nu doar `y`. Ăsta e pasul care ar fi prevenit incidentul.

**Deploy.** `cdk deploy` cu digest-urile ca context și `--require-approval never`, pentru că aprobarea s-a dat deja la pasul anterior, cu informație mai bună decât dă propriul prompt al CDK.

**Verificare și notificare.** Lovește ruta de health până raportează noul SHA, [face grep pe bundle-ul de client după același SHA](/ro/blog/nextjs-build-time-vs-runtime-config-on-containers) și postează un mesaj pe canalul echipei cu mediul, SHA-ul, numele operatorului și rezumatul diff-ului. Dacă verificarea pică, scriptul o spune zgomotos și iese cu cod nenul; nu face rollback automat, pentru că o verificare picată înseamnă de obicei „uită-te la ea”, nu „anuleaz-o”.

## Cum arată la folosire

```bash
./scripts/deploy.sh --env staging
./scripts/deploy.sh --env prod --yes
./scripts/deploy.sh --env prod --dry-run     # synth + diff, fără build, fără deploy
```

Trei flag-uri. Tot restul e decis de script din git și din cont. Nu există nicio cale de a pasa o imagine custom, un branch custom sau o valoare de context custom, intenționat.

## L-am scrie din nou?

Da, și l-am scrie din prima zi, nu după incident și după pana de CI. Nu pentru că deploy-urile de pe laptop sunt bune, ci pentru că *scriptul e pipeline-ul*. Când CI-ul s-a întors, workflow-ul lui a devenit: checkout, asumă rolul OIDC, rulează `./scripts/deploy.sh --env prod --yes`. O singură cale de cod, exersată din ambele locuri, cu barierele în cod, nu într-un fișier YAML care rulează doar pe mașina altcuiva.

Cele 300 de linii sunt mult pentru un script de shell. Au înlocuit o pagină de runbook care zicea „ai grijă”, care e mai scurtă și nu funcționează.

Dacă deploy-ul tău e un `cdk deploy` pe care cineva îl rulează de unde se nimerește, [te putem ajuta să-l facem plictisitor](/contact).
