@fjall/deploy-core 2.20.0 → 2.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/.minified CHANGED
@@ -1 +1 @@
1
- 153 files minified at 2026-06-23T06:42:25.535Z
1
+ 153 files minified at 2026-06-23T21:59:07.691Z
@@ -1 +1 @@
1
- import{join as $}from"path";import{CloudFormationClient as q}from"@aws-sdk/client-cloudformation";import{EC2Client as z}from"@aws-sdk/client-ec2";import{success as U,failure as g}from"@fjall/generator";import{logger as E}from"@fjall/util/logger";import{imageTagParameterName as Y,maskSensitiveOutput as S}from"@fjall/util";import{parseDockerServicesFromManifest as J}from"@fjall/util/manifest";import{stubCallerIdentity as Q}from"../../types/deployment/index.js";import{getApplicationDeployOrder as V,getApplicationStackName as X,getApplicationStepName as F,getApplicationStepId as H,APPLICATION_STACKS as j}from"../../types/operations.js";import{CdkContextBuilder as Z}from"../../services/supporting/CdkContextBuilder.js";import{buildParamsContext as ee,bootstrapOrFail as te}from"../contextHelpers.js";import{runDetectionPipeline as oe}from"./detectionPipeline.js";import{STEP_IDS as O,STEP_NAMES as re}from"../../types/stepDefinitions.js";import{StepRegistry as ne}from"../../steps/stepRegistry.js";import{checkTargetReadiness as ae}from"../../aws/targetReadiness.js";import{cascadeHomeRegion as ie}from"../organisation/cascadeHelpers.js";import{withStepLifecycle as se}from"../stepLifecycle.js";import{DOCKER_BUILD_STEP_NAME as G,runDockerBuild as ce}from"./dockerBuildHelper.js";import{getParallelPhase2Stacks as de,deployParallelPhase as le,deployStackSequential as pe,runDockerPreCompute as ue,createBuildCallbacks as fe}from"./applicationDeployHelpers.js";import{deployCodeOnly as me}from"./codeOnlyDeploy.js";const R="applicationDeploy";function ge(i){const a=Object.entries(i);if(a.length===0)return;const r={};for(const[t,s]of a){const C=Y(t);r[C]=s}return r}async function Me(i,a,r){const{callbacks:t,options:s}=i,C=Date.now(),D=Z.buildDeploymentContext({deployType:"application",target:r.appName,path:r.path,region:a.awsProvider.getRegion(),callerIdentity:Q(a.awsProvider.getAccountId()),...ee({orgConfig:i.orgConfig,identity:i.identity,skipOidc:i.options?.skipOidc})},{verbose:s?.verbose,infraOnly:s?.infraOnly},i.orgConfig),p=a.frameworkRegistry.resolve({appPath:r.path});let w;const k=!!s?.imageTag;if(p&&!k){w=p.builder.plan({appPath:r.path},p.detection);const e=fe(t),o=await p.builder.build(r.path,w,e,{skipBuild:s?.skipBuild,infraOnly:s?.infraOnly});if(!o.success){const n=new Error(S(o.error.message));return t.onError?.(n),g(n)}}if(s?.deployOnly||k){t.onLog?.(k?`Rollback mode \u2014 rolling to image tag ${s?.imageTag}`:"Deploy-only mode \u2014 skipping infrastructure pipeline","info");const e=$(r.path,"cdk.out"),o=J(e),n=o.length>0,l=p?.detection.hasDockerfile===!0,d=n||l;E.debug(R,"Deploy-only branch entered",{isRollback:k,imageTag:s?.imageTag,appName:r.appName,appPath:r.path,cdkOutPath:e,dockerProviderAvailable:i.dockerProvider!==void 0,builderName:p?.builder.name,hasDockerfileFromManifest:n,hasDockerfileFromDisk:l,hasDockerfile:d,manifestDockerServiceCount:o.length,manifestDockerPaths:o.map(u=>u.docker.path)}),!n&&!l&&t.onLog?.("No Dockerfile detected via manifest or appPath \u2014 skipping Docker build. If this app uses a cross-repo Dockerfile, ensure a full deploy has run first to populate cdk.out/fjall-manifest.json.","warn");let f={};if(!k&&i.dockerProvider!==void 0&&d){E.debug(R,"Running Docker build before code-only deploy",{source:n?"manifest":"disk"});const u=await ce(i,a,r,t);if(!u.success)return g(u.error);f=u.data}else E.debug(R,"Skipping Docker build",{reason:k?"rollback":i.dockerProvider===void 0?"no dockerProvider":"no Dockerfile detected"});return me(i,a,r,f)}t.onLog?.("Analysing infrastructure\u2026","info");const N=await oe(r,a,D,t);if(!N.success){const e=new Error(S(N.error.message));return t.onError?.(e),g(e)}const c=N.data;try{await t.onDetectionComplete?.({...c,builderName:p?.builder.name??"unknown"})}catch(e){const o=e instanceof Error?e.message:String(e),n=new Error(S(o));return t.onError?.(n),g(n)}const K={deploymentType:"application",operation:"deploy",deployOnly:!1,infraOnly:s?.infraOnly??!1,hasDockerfile:c.hasDockerfile,pattern:c.pattern,resources:c.resources,...p&&{builderName:p.builder.name}},T=ne.getSteps(K),A=T.findIndex(e=>e.id===O.TARGET_READINESS),I=await se(t,{stepId:O.TARGET_READINESS,stepName:re.TARGET_READINESS,...A>=0&&{stepIndex:A,totalSteps:T.length}},async()=>{if(s?.skipReadinessCheck)return t.onLog?.("Skipping target readiness check (--skip-readiness-check)","warn"),{kind:"skipped",data:void 0};const e=a.awsProvider.getAccountId(),o=i.orgConfig?.providerAccounts.find(f=>f.id===e)?.name,n=a.awsProvider.getCredentials(),l=ie(i.orgConfig),d=await ae({cloudFormation:a.awsProvider.getClient(q),ec2:new z({region:l,...n!==void 0&&{credentials:n}})},{id:e,...o!==void 0&&{name:o}},a.awsProvider.getRegion(),i.orgConfig,i.abortSignal);return d.success?d.data.ready?{kind:"completed",data:void 0}:{kind:"error",error:new Error(S(d.data.advisory))}:{kind:"error",error:new Error(S(d.error.message))}});if(!I.success)return I;const m=w?w.deployOrder:V({pattern:c.pattern,resources:c.resources}),h=m.length;if(!c.hasDifferences&&!s?.force){t.onLog?.("No infrastructure changes detected","info");const e=c.hasDockerfile&&i.dockerProvider!==void 0&&m.includes(j.COMPUTE);for(let n=0;n<m.length;n++){const l=m[n];e&&l===j.COMPUTE&&(t.onStepStart?.(O.DOCKER_OPERATIONS,G),t.onStepComplete?.(O.DOCKER_OPERATIONS,G,"skipped"));const d=H(l,"deploy"),f=F(l,"deploy");t.onStepStart?.(d,f,n,h),t.onStepComplete?.(d,f,"skipped",n,h)}const o=await a.stackService.resolveWebsiteUrl(r.appName);return U({target:r.appName,deploymentType:"application",outputs:o?{websiteUrl:o}:void 0,noChanges:!0,durationMs:Date.now()-C})}const v=await te(a,D,t);if(!v.success)return v;const b={},P=new Map;for(let e=0;e<m.length;e++){const o=m[e],n=X(r.appName,o),l=H(o,"deploy"),d=F(o,"deploy");if(!(c.stackChanges.get(n)??!0)&&!s?.force){t.onStepStart?.(l,d,e,h),t.onLog?.(`Skipping ${o} \u2014 no changes detected`,"info"),t.onStepComplete?.(l,d,"skipped",e,h);continue}const u=de(m,e,c.stackChanges,r.appName,s?.force);if(u.length>=2){const B=await le(u,r,a,D,t,e,h,c,b,P);if(!B.success)return g(B.error);e+=u.length-1;continue}const y=await ue(o,i,a,r,t,c.hasDockerfile);if(y!==null&&!y.success)return g(y.error);const W=y!==null&&y.success?y.data.contentHashTagsByService:{},L=ge(W),x=await pe(o,a,D,t,e,h,b,L!==void 0?{parameters:L}:void 0);if(!x.success)return g(x.error);const M=c.currentHashes.get(n);M&&P.set(n,M)}if(P.size>0){const e=await a.hashService.updateStateAfterDeploy(r.path,P);if(!e.success){const o=S(e.error.message);E.debug(R,"Failed to update state file",{error:o}),t.onLog?.(`Warning: failed to update state file \u2014 next deploy may re-deploy unchanged stacks: ${o}`,"warn")}}const _=await a.stackService.resolveWebsiteUrl(r.appName);return _&&(b.websiteUrl=_),U({target:r.appName,deploymentType:"application",outputs:Object.keys(b).length>0?b:void 0,durationMs:Date.now()-C})}export{Me as deployApplication};
1
+ import{join as z}from"path";import{CloudFormationClient as Y}from"@aws-sdk/client-cloudformation";import{EC2Client as J}from"@aws-sdk/client-ec2";import{success as j,failure as g}from"@fjall/generator";import{logger as E}from"@fjall/util/logger";import{imageTagParameterName as Q,maskSensitiveOutput as S}from"@fjall/util";import{parseDockerServicesFromManifest as V}from"@fjall/util/manifest";import{stubCallerIdentity as X}from"../../types/deployment/index.js";import{getApplicationDeployOrder as Z,getApplicationStackName as ee,getApplicationStepName as G,getApplicationStepId as K,APPLICATION_STACKS as O}from"../../types/operations.js";import{CdkContextBuilder as te}from"../../services/supporting/CdkContextBuilder.js";import{buildParamsContext as oe,bootstrapOrFail as re}from"../contextHelpers.js";import{runDetectionPipeline as ne}from"./detectionPipeline.js";import{STEP_IDS as R,STEP_NAMES as ie}from"../../types/stepDefinitions.js";import{StepRegistry as ae}from"../../steps/stepRegistry.js";import{checkTargetReadiness as se}from"../../aws/targetReadiness.js";import{cascadeHomeRegion as ce}from"../organisation/cascadeHelpers.js";import{withStepLifecycle as le}from"../stepLifecycle.js";import{DOCKER_BUILD_STEP_NAME as W,runDockerBuild as de}from"./dockerBuildHelper.js";import{getParallelPhase2Stacks as pe,deployParallelPhase as ue,deployStackSequential as fe,runDockerPreCompute as me,createBuildCallbacks as ge}from"./applicationDeployHelpers.js";import{deployCodeOnly as ke}from"./codeOnlyDeploy.js";const T="applicationDeploy";function ye(a){const i=Object.entries(a);if(i.length===0)return;const r={};for(const[t,s]of i){const C=Q(t);r[C]=s}return r}async function Ue(a,i,r){const{callbacks:t,options:s}=a,C=Date.now(),D=te.buildDeploymentContext({deployType:"application",target:r.appName,path:r.path,region:i.awsProvider.getRegion(),callerIdentity:X(i.awsProvider.getAccountId()),...oe({orgConfig:a.orgConfig,identity:a.identity,skipOidc:a.options?.skipOidc})},{verbose:s?.verbose,infraOnly:s?.infraOnly},a.orgConfig),p=i.frameworkRegistry.resolve({appPath:r.path});let w;const k=!!s?.imageTag;if(p&&!k){w=p.builder.plan({appPath:r.path},p.detection);const e=ge(t),o=await p.builder.build(r.path,w,e,{skipBuild:s?.skipBuild,infraOnly:s?.infraOnly});if(!o.success){const n=new Error(S(o.error.message));return t.onError?.(n),g(n)}}if(s?.deployOnly||k){t.onLog?.(k?`Rollback mode \u2014 rolling to image tag ${s?.imageTag}`:"Deploy-only mode \u2014 skipping infrastructure pipeline","info");const e=z(r.path,"cdk.out"),o=V(e),n=o.length>0,d=p?.detection.hasDockerfile===!0,l=n||d;E.debug(T,"Deploy-only branch entered",{isRollback:k,imageTag:s?.imageTag,appName:r.appName,appPath:r.path,cdkOutPath:e,dockerProviderAvailable:a.dockerProvider!==void 0,builderName:p?.builder.name,hasDockerfileFromManifest:n,hasDockerfileFromDisk:d,hasDockerfile:l,manifestDockerServiceCount:o.length,manifestDockerPaths:o.map(m=>m.docker.path)}),!n&&!d&&t.onLog?.("No Dockerfile detected via manifest or appPath \u2014 skipping Docker build. If this app uses a cross-repo Dockerfile, ensure a full deploy has run first to populate cdk.out/fjall-manifest.json.","warn");let u={};if(!k&&a.dockerProvider!==void 0&&l){E.debug(T,"Running Docker build before code-only deploy",{source:n?"manifest":"disk"});const m=await de(a,i,r,t);if(!m.success)return g(m.error);u=m.data}else E.debug(T,"Skipping Docker build",{reason:k?"rollback":a.dockerProvider===void 0?"no dockerProvider":"no Dockerfile detected"});return ke(a,i,r,u)}t.onLog?.("Analysing infrastructure\u2026","info");const N=await ne(r,i,D,t);if(!N.success){const e=new Error(S(N.error.message));return t.onError?.(e),g(e)}const c=N.data;try{await t.onDetectionComplete?.({...c,builderName:p?.builder.name??"unknown"})}catch(e){const o=e instanceof Error?e.message:String(e),n=new Error(S(o));return t.onError?.(n),g(n)}const $={deploymentType:"application",operation:"deploy",deployOnly:!1,infraOnly:s?.infraOnly??!1,hasDockerfile:c.hasDockerfile,pattern:c.pattern,resources:c.resources,...p&&{builderName:p.builder.name}},I=ae.getSteps($),v=I.findIndex(e=>e.id===R.TARGET_READINESS),L=await le(t,{stepId:R.TARGET_READINESS,stepName:ie.TARGET_READINESS,...v>=0&&{stepIndex:v,totalSteps:I.length}},async()=>{if(s?.skipReadinessCheck)return t.onLog?.("Skipping target readiness check (--skip-readiness-check)","warn"),{kind:"skipped",data:void 0};const e=i.awsProvider.getAccountId(),o=a.orgConfig?.providerAccounts.find(u=>u.id===e)?.name,n=i.awsProvider.getCredentials(),d=ce(a.orgConfig),l=await se({cloudFormation:i.awsProvider.getClient(Y),ec2:new J({region:d,...n!==void 0&&{credentials:n}})},{id:e,...o!==void 0&&{name:o}},i.awsProvider.getRegion(),a.orgConfig,a.abortSignal);return l.success?l.data.ready?{kind:"completed",data:void 0}:{kind:"error",error:new Error(S(l.data.advisory))}:{kind:"error",error:new Error(S(l.error.message))}});if(!L.success)return L;const f=w?w.deployOrder:Z({pattern:c.pattern,resources:c.resources}),y=f.length,M=c.hasDockerfile&&a.dockerProvider!==void 0&&f.includes(O.COMPUTE)&&s?.infraOnly!==!0;if(!c.hasDifferences&&!s?.force&&!M){t.onLog?.("No infrastructure changes detected","info"),t.onLog?.(s?.infraOnly===!0?"Infrastructure-only mode \u2014 application code was not rebuilt. Run with --deploy-only to deploy code changes.":"Nothing to deploy. Run with --deploy-only to redeploy application code.","info");const e=c.hasDockerfile&&a.dockerProvider!==void 0&&f.includes(O.COMPUTE);for(let n=0;n<f.length;n++){const d=f[n];e&&d===O.COMPUTE&&(t.onStepStart?.(R.DOCKER_OPERATIONS,W),t.onStepComplete?.(R.DOCKER_OPERATIONS,W,"skipped"));const l=K(d,"deploy"),u=G(d,"deploy");t.onStepStart?.(l,u,n,y),t.onStepComplete?.(l,u,"skipped",n,y)}const o=await i.stackService.resolveWebsiteUrl(r.appName);return j({target:r.appName,deploymentType:"application",outputs:o?{websiteUrl:o}:void 0,noChanges:!0,durationMs:Date.now()-C})}const _=await re(i,D,t);if(!_.success)return _;const b={},P=new Map;for(let e=0;e<f.length;e++){const o=f[e],n=ee(r.appName,o),d=K(o,"deploy"),l=G(o,"deploy"),u=c.stackChanges.get(n)??!0,m=o===O.COMPUTE&&M;if(!u&&!s?.force&&!m){t.onStepStart?.(d,l,e,y),t.onLog?.(`Skipping ${o} \u2014 no changes detected`,"info"),t.onStepComplete?.(d,l,"skipped",e,y);continue}const A=pe(f,e,c.stackChanges,r.appName,s?.force);if(A.length>=2){const H=await ue(A,r,i,D,t,e,y,c,b,P);if(!H.success)return g(H.error);e+=A.length-1;continue}const h=await me(o,a,i,r,t,c.hasDockerfile);if(h!==null&&!h.success)return g(h.error);const q=h!==null&&h.success?h.data.contentHashTagsByService:{},B=ye(q),U=await fe(o,i,D,t,e,y,b,B!==void 0?{parameters:B}:void 0);if(!U.success)return g(U.error);const F=c.currentHashes.get(n);F&&P.set(n,F)}if(P.size>0){const e=await i.hashService.updateStateAfterDeploy(r.path,P);if(!e.success){const o=S(e.error.message);E.debug(T,"Failed to update state file",{error:o}),t.onLog?.(`Warning: failed to update state file \u2014 next deploy may re-deploy unchanged stacks: ${o}`,"warn")}}const x=await i.stackService.resolveWebsiteUrl(r.appName);return x&&(b.websiteUrl=x),j({target:r.appName,deploymentType:"application",outputs:Object.keys(b).length>0?b:void 0,durationMs:Date.now()-C})}export{Ue as deployApplication};
@@ -1,7 +1,22 @@
1
1
  import { type Result } from "@fjall/generator";
2
+ import { type ManifestDockerService } from "@fjall/util/manifest";
2
3
  import type { DeployParams, DeployResult } from "../../types/params.js";
3
4
  import type { ApplicationOperation } from "../../types/operations.js";
4
5
  import type { DeployServices } from "../serviceFactory.js";
6
+ /**
7
+ * Resolve the ECR image tag the rollout should pin against for a given
8
+ * manifest service. Keys the content-hash tag map by the bare `<service>`
9
+ * name — the SAME key `dockerBuildHelper.buildGroup` writes and the
10
+ * `<ServiceName>ImageTag` CfnParameter consumes. The build target is folded
11
+ * into the tag VALUE (`<service>(-<target>)?-sha-<id>`), never the key; a
12
+ * `<service>-<target>` key would miss the freshly-pushed tag for every
13
+ * multi-stage service (e.g. the webapp `deploy-worker`/`scan-worker`).
14
+ * Returns `undefined` when the caller did not push a build for this service
15
+ * (e.g. rollback path, or a service with no manifest entry); the caller then
16
+ * uses an explicit `--image-tag` if supplied, else fails — no moving
17
+ * `-latest` tag exists to guess against.
18
+ */
19
+ export declare function lookupContentHashTag(manifestService: ManifestDockerService | undefined, contentHashTagsByService: Record<string, string>): string | undefined;
5
20
  /**
6
21
  * Re-base an operator-supplied `--image-tag` onto a specific ECS service.
7
22
  *
@@ -1 +1 @@
1
- import{join as U}from"path";import{success as _,failure as k}from"@fjall/generator";import{logger as L}from"@fjall/util/logger";import{maskSensitiveOutput as E}from"@fjall/util";import{parseDockerServicesFromManifest as j}from"@fjall/util/manifest";import{APPLICATION_STACKS as P,getApplicationStepId as N,getApplicationStepName as W}from"../../types/operations.js";import{extractEcsServiceName as O}from"../../aws/utils/arnParser.js";import{stripTagOrDigest as F}from"../../services/infrastructure/EcrImageInspectorService.js";import{withStepLifecycle as q}from"../stepLifecycle.js";const I="codeOnlyDeploy";function z(n,a){if(!n)return;const o=n.docker.target,e=o?`${n.name}-${o}`:n.name;return a[e]}function G(n,a){const o=a.toLowerCase();return n.find(e=>e.name.toLowerCase()===o)??n.find(e=>o.includes(e.name.toLowerCase()))}function H(n,a,o){const e=a.toLowerCase(),c=n.toLowerCase(),g=o.map(m=>m.toLowerCase()).filter(m=>m!==""&&c.startsWith(`${m}-`)).sort((m,T)=>T.length-m.length)[0];if(g===void 0||g===e)return n;const v=n.slice(g.length);return`${e}${v}`}const K=0,X=1;async function se(n,a,o,e){const{callbacks:c,options:g}=n,v=Date.now(),m=N(P.COMPUTE,"deploy"),T=W(P.COMPUTE,"deploy");return q(c,{stepId:m,stepName:T,stepIndex:K,totalSteps:X},async()=>{const d=g?.imageTag;d&&c.onLog?.(`Rolling to supplied image tag ${d}`,"info");const S=j(U(o.path,"cdk.out"));L.debug(I,"Code-only rollout starting",{explicitRollbackTag:d,workingDirectory:n.workingDirectory,contentHashTagsByService:e,manifestServiceCount:S.length,manifestServices:S.map(t=>({name:t.name,target:t.docker.target}))}),c.onLog?.("Discovering ECS cluster and services\u2026","info");const D=await a.ecsResolver.getDeployableClusterAndServices(o.appName);if(!D.success)return L.debug(I,"ECS discovery failed",{appName:o.appName,error:E(D.error.message)}),{kind:"error",error:D.error};const{clusterArn:r,serviceArns:$}=D.data;if(L.debug(I,"ECS discovery complete",{appName:o.appName,clusterArn:r,serviceCount:$.length,serviceArns:$}),!r||$.length===0){const t=`No deployable ECS services found for ${o.appName}`;return c.onLog?.(t,"warn"),{kind:"skipped",data:{target:o.appName,deploymentType:"application",durationMs:Date.now()-v,noChanges:!0}}}const b=g?.serviceName?.toLowerCase(),w=b===""?void 0:b,f=w?$.filter(t=>{const l=(O(t)??"").toLowerCase();return l===w||l.endsWith(w)}):$;if(w&&f.length===0){const t=$.map(l=>O(l)??"unknown").join(", ");return{kind:"error",error:new Error(`Service '${w}' not found. Available: ${t||"none"}`)}}const C={},h=[],R=$.map(t=>O(t)).filter(t=>t!==void 0&&t!=="");for(let t=0;t<f.length;t++){const l=f[t];if(!l)continue;const i=O(l)??`service-${t+1}`,A=`[${t+1}/${f.length}] ${i}`,y=G(S,i),x=z(y,e);let p,s;d!==void 0?(p=H(d,i,R),p!==d?(s=d,c.onLog?.(`Re-based image tag ${d} \u2192 ${p} for ${i}`,"info")):R.length>1&&!d.toLowerCase().startsWith(`${i.toLowerCase()}-`)&&c.onLog?.(`Image tag ${d} has no recognised service prefix; applying verbatim to ${i} \u2014 confirm it points at this service's image`,"warn")):x!==void 0?p=x:(p=`${i.toLowerCase()}-latest`,c.onLog?.(`No content-hash tag found for ${i}; falling back to mutable tag ${p}`,"warn")),L.debug(I,"Starting service rollout",{serviceName:i,serviceArn:l,imageTag:p,explicitRollbackTag:d,contentHashTag:x,manifestServiceMatched:y?.name,dockerTarget:y?.docker.target,index:t,total:f.length}),c.onECSUpdate?.(`${A}: rolling out image tag ${p}`,i);const u=await Y(a,r,l,i,p,c,s);if(L.debug(I,"Service rollout completed",{serviceName:i,success:u.success,...u.success?{taskDefinitionArn:u.data.taskDefinitionArn,imageDigest:u.data.imageDigest,durationMs:u.data.durationMs}:{error:E(u.error.message)}}),!u.success){h.push(`${i}: ${E(u.error.message)}`);continue}C[`${i}TaskDefinition`]=u.data.taskDefinitionArn,C[`${i}PreviousTaskDefinition`]=u.data.previousTaskDefinitionArn,C[`${i}ImageTag`]=p,C[`${i}ImageUri`]=u.data.imageUri,C[`${i}EcrRepositoryArn`]=u.data.ecrRepositoryArn,u.data.imageDigest!==void 0&&(C[`${i}ImageDigest`]=u.data.imageDigest)}const M=Date.now()-v;if(h.length>0){const t=f.length-h.length,l=f.length,i=h.length===l?`All ${l} ECS service rollouts failed: ${h.join("; ")}`:`Partial rollout: ${t}/${l} services succeeded. Failures: ${h.join("; ")}`;return{kind:"error",error:new Error(i)}}return{kind:"completed",data:{target:o.appName,deploymentType:"application",outputs:Object.keys(C).length>0?C:void 0,durationMs:M}}})}async function Y(n,a,o,e,c,g,v){const m=Date.now(),T=await n.ecsService.describeServices(a,[o]);if(!T.success)return k(new Error(`Failed to describe service ${e}: ${T.error.message}`));const d=T.data[0];if(!d?.taskDefinition)return k(new Error(`Service ${e} has no task definition`));const S=d.taskDefinition,D=await n.ecsService.getLatestTaskDefinition(S);if(!D.success)return k(new Error(`Failed to fetch task definition for ${e}: ${D.error.message}`));const r=D.data;if(!r||!r.containerDefinitions||r.containerDefinitions.length===0)return k(new Error(`Task definition for ${e} has no container definitions`));const $=r.containerDefinitions.find(s=>s.image)?.image;if(!$)return k(new Error(`Task definition for ${e} has no container image \u2014 cannot derive repository URI`));const b=F($),w=await n.ecrImageInspector.getImageDigest(b,c);let f;if(w.success)f=w.data;else{if(v!==void 0)return k(new Error(`Re-based image tag '${c}' (from supplied '${v}') was not found in ECR for ${e}: ${E(w.error.message)}. The supplied tag's build has no image for ${e} \u2014 supply that service's own tag, or pass --service to roll a single service.`));g.onLog?.(`Could not resolve image digest for ${e} (${E(w.error.message)}); falling back to tag pinning`,"warn")}const C=s=>f!==void 0?`${s}@${f}`:`${s}:${c}`,h=r.containerDefinitions.map(s=>s.image?{...s,image:C(F(s.image))}:s),R=r.family;if(!R)return k(new Error(`Task definition for ${e} has no family`));const M=await n.ecsService.registerTaskDefinition({family:R,taskRoleArn:r.taskRoleArn,executionRoleArn:r.executionRoleArn,networkMode:r.networkMode,containerDefinitions:h,volumes:r.volumes,placementConstraints:r.placementConstraints,requiresCompatibilities:r.requiresCompatibilities,cpu:r.cpu,memory:r.memory,runtimePlatform:r.runtimePlatform,proxyConfiguration:r.proxyConfiguration,inferenceAccelerators:r.inferenceAccelerators,pidMode:r.pidMode,ipcMode:r.ipcMode,ephemeralStorage:r.ephemeralStorage});if(!M.success)return k(new Error(`Failed to register task definition for ${e}: ${M.error.message}`));const t=M.data;g.onTaskDefRegistered?.({serviceName:e,taskDefinitionArn:t,previousTaskDefinitionArn:S,revision:Q(t),family:R,...f!==void 0?{imageDigest:f}:{}});const l=await n.ecsService.updateService(a,o,{taskDefinition:t,forceNewDeployment:!0});if(!l.success)return k(new Error(`Failed to update service ${e}: ${l.error.message}`));const i=await n.ecsService.pollDeployment({clusterArn:a,serviceArn:o,waitForCompletion:!0,progressCallback:s=>{const u=s.latestEvent?` \u2014 ${E(s.latestEvent)}`:"";g.onECSProgress?.(`${e}: ${E(s.message)}${u}`,s.percentComplete)}}),A=Date.now()-m;if(!i.success){const s=E(i.error.message);return g.onECSComplete?.({serviceName:e,serviceArn:o,success:!1,taskDefinitionArn:t,durationMs:A,reason:s}),k(new Error(`ECS rollout failed for ${e}: ${s}`))}const y=await n.ecsService.getDeploymentStatus(a,o);g.onECSComplete?.({serviceName:e,serviceArn:o,success:!0,taskDefinitionArn:t,durationMs:A,finalRunningCount:y.success?y.data.runningCount:void 0,finalDesiredCount:y.success?y.data.desiredCount:void 0}),L.debug(I,`Rolled out ${e} successfully`,{serviceArn:o,newTaskDefArn:t,previousTaskDefArn:S,durationMs:A});const x=`${b}:${c}`,p=J(b);return _({taskDefinitionArn:t,previousTaskDefinitionArn:S,imageUri:x,ecrRepositoryArn:p,imageDigest:f,durationMs:A})}function J(n){const a=n.match(/^(\d{12})\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com\/(.+)$/);if(!a)return"";const[,o,e,c]=a;return`arn:aws:ecr:${e}:${o}:repository/${c}`}function Q(n){const a=n.lastIndexOf(":");if(a<0)return 0;const o=n.slice(a+1);return/^\d+$/.test(o)?parseInt(o,10):0}export{se as deployCodeOnly,H as rebaseRollbackTagForService};
1
+ import{join as _}from"path";import{success as j,failure as S}from"@fjall/generator";import{logger as L}from"@fjall/util/logger";import{maskSensitiveOutput as v}from"@fjall/util";import{parseDockerServicesFromManifest as N}from"@fjall/util/manifest";import{APPLICATION_STACKS as F,getApplicationStepId as W,getApplicationStepName as q}from"../../types/operations.js";import{extractEcsServiceName as O}from"../../aws/utils/arnParser.js";import{stripTagOrDigest as U}from"../../services/infrastructure/EcrImageInspectorService.js";import{withStepLifecycle as z}from"../stepLifecycle.js";const I="codeOnlyDeploy";function G(n,a){if(n)return a[n.name]}function H(n,a){const o=a.toLowerCase();return n.find(t=>t.name.toLowerCase()===o)??n.find(t=>o.includes(t.name.toLowerCase()))}function K(n,a,o){const t=a.toLowerCase(),c=n.toLowerCase(),g=o.map(p=>p.toLowerCase()).filter(p=>p!==""&&c.startsWith(`${p}-`)).sort((p,T)=>T.length-p.length)[0];if(g===void 0||g===t)return n;const E=n.slice(g.length);return`${t}${E}`}const X=0,Y=1;async function ae(n,a,o,t){const{callbacks:c,options:g}=n,E=Date.now(),p=W(F.COMPUTE,"deploy"),T=q(F.COMPUTE,"deploy");return z(c,{stepId:p,stepName:T,stepIndex:X,totalSteps:Y},async()=>{const d=g?.imageTag;d&&c.onLog?.(`Rolling to supplied image tag ${d}`,"info");const D=N(_(o.path,"cdk.out"));L.debug(I,"Code-only rollout starting",{explicitRollbackTag:d,workingDirectory:n.workingDirectory,contentHashTagsByService:t,manifestServiceCount:D.length,manifestServices:D.map(e=>({name:e.name,target:e.docker.target}))}),c.onLog?.("Discovering ECS cluster and services\u2026","info");const h=await a.ecsResolver.getDeployableClusterAndServices(o.appName);if(!h.success)return L.debug(I,"ECS discovery failed",{appName:o.appName,error:v(h.error.message)}),{kind:"error",error:h.error};const{clusterArn:r,serviceArns:m}=h.data;if(L.debug(I,"ECS discovery complete",{appName:o.appName,clusterArn:r,serviceCount:m.length,serviceArns:m}),!r||m.length===0){const e=`No deployable ECS services found for ${o.appName}`;return c.onLog?.(e,"warn"),{kind:"skipped",data:{target:o.appName,deploymentType:"application",durationMs:Date.now()-E,noChanges:!0}}}const R=g?.serviceName?.toLowerCase(),$=R===""?void 0:R,f=$?m.filter(e=>{const l=(O(e)??"").toLowerCase();return l===$||l.endsWith($)}):m;if($&&f.length===0){const e=m.map(l=>O(l)??"unknown").join(", ");return{kind:"error",error:new Error(`Service '${$}' not found. Available: ${e||"none"}`)}}const w={},k=[],b=m.map(e=>O(e)).filter(e=>e!==void 0&&e!=="");for(let e=0;e<f.length;e++){const l=f[e];if(!l)continue;const i=O(l)??`service-${e+1}`,A=`[${e+1}/${f.length}] ${i}`,y=H(D,i),x=G(y,t);let C,s;if(d!==void 0)C=K(d,i,b),C!==d?(s=d,c.onLog?.(`Re-based image tag ${d} \u2192 ${C} for ${i}`,"info")):b.length>1&&!d.toLowerCase().startsWith(`${i.toLowerCase()}-`)&&c.onLog?.(`Image tag ${d} has no recognised service prefix; applying verbatim to ${i} \u2014 confirm it points at this service's image`,"warn");else if(x!==void 0)C=x;else{const P=`No image available to deploy for ${i}: no content-hash tag was produced this run. Run a full deploy to build the image, or pass --image-tag <tag> to roll a specific existing image.`;c.onLog?.(P,"warn"),k.push(`${i}: ${P}`);continue}L.debug(I,"Starting service rollout",{serviceName:i,serviceArn:l,imageTag:C,explicitRollbackTag:d,contentHashTag:x,manifestServiceMatched:y?.name,dockerTarget:y?.docker.target,index:e,total:f.length}),c.onECSUpdate?.(`${A}: rolling out image tag ${C}`,i);const u=await J(a,r,l,i,C,c,s);if(L.debug(I,"Service rollout completed",{serviceName:i,success:u.success,...u.success?{taskDefinitionArn:u.data.taskDefinitionArn,imageDigest:u.data.imageDigest,durationMs:u.data.durationMs}:{error:v(u.error.message)}}),!u.success){k.push(`${i}: ${v(u.error.message)}`);continue}w[`${i}TaskDefinition`]=u.data.taskDefinitionArn,w[`${i}PreviousTaskDefinition`]=u.data.previousTaskDefinitionArn,w[`${i}ImageTag`]=C,w[`${i}ImageUri`]=u.data.imageUri,w[`${i}EcrRepositoryArn`]=u.data.ecrRepositoryArn,u.data.imageDigest!==void 0&&(w[`${i}ImageDigest`]=u.data.imageDigest)}const M=Date.now()-E;if(k.length>0){const e=f.length-k.length,l=f.length,i=k.length===l?`All ${l} ECS service rollouts failed: ${k.join("; ")}`:`Partial rollout: ${e}/${l} services succeeded. Failures: ${k.join("; ")}`;return{kind:"error",error:new Error(i)}}return{kind:"completed",data:{target:o.appName,deploymentType:"application",outputs:Object.keys(w).length>0?w:void 0,durationMs:M}}})}async function J(n,a,o,t,c,g,E){const p=Date.now(),T=await n.ecsService.describeServices(a,[o]);if(!T.success)return S(new Error(`Failed to describe service ${t}: ${T.error.message}`));const d=T.data[0];if(!d?.taskDefinition)return S(new Error(`Service ${t} has no task definition`));const D=d.taskDefinition,h=await n.ecsService.getLatestTaskDefinition(D);if(!h.success)return S(new Error(`Failed to fetch task definition for ${t}: ${h.error.message}`));const r=h.data;if(!r||!r.containerDefinitions||r.containerDefinitions.length===0)return S(new Error(`Task definition for ${t} has no container definitions`));const m=r.containerDefinitions.find(s=>s.image)?.image;if(!m)return S(new Error(`Task definition for ${t} has no container image \u2014 cannot derive repository URI`));const R=U(m),$=await n.ecrImageInspector.getImageDigest(R,c);let f;if($.success)f=$.data;else{if(E!==void 0)return S(new Error(`Re-based image tag '${c}' (from supplied '${E}') was not found in ECR for ${t}: ${v($.error.message)}. The supplied tag's build has no image for ${t} \u2014 supply that service's own tag, or pass --service to roll a single service.`));g.onLog?.(`Could not resolve image digest for ${t} (${v($.error.message)}); falling back to tag pinning`,"warn")}const w=s=>f!==void 0?`${s}@${f}`:`${s}:${c}`,k=r.containerDefinitions.map(s=>s.image?{...s,image:w(U(s.image))}:s),b=r.family;if(!b)return S(new Error(`Task definition for ${t} has no family`));const M=await n.ecsService.registerTaskDefinition({family:b,taskRoleArn:r.taskRoleArn,executionRoleArn:r.executionRoleArn,networkMode:r.networkMode,containerDefinitions:k,volumes:r.volumes,placementConstraints:r.placementConstraints,requiresCompatibilities:r.requiresCompatibilities,cpu:r.cpu,memory:r.memory,runtimePlatform:r.runtimePlatform,proxyConfiguration:r.proxyConfiguration,inferenceAccelerators:r.inferenceAccelerators,pidMode:r.pidMode,ipcMode:r.ipcMode,ephemeralStorage:r.ephemeralStorage});if(!M.success)return S(new Error(`Failed to register task definition for ${t}: ${M.error.message}`));const e=M.data;g.onTaskDefRegistered?.({serviceName:t,taskDefinitionArn:e,previousTaskDefinitionArn:D,revision:V(e),family:b,...f!==void 0?{imageDigest:f}:{}});const l=await n.ecsService.updateService(a,o,{taskDefinition:e,forceNewDeployment:!0});if(!l.success)return S(new Error(`Failed to update service ${t}: ${l.error.message}`));const i=await n.ecsService.pollDeployment({clusterArn:a,serviceArn:o,waitForCompletion:!0,progressCallback:s=>{const u=s.latestEvent?` \u2014 ${v(s.latestEvent)}`:"";g.onECSProgress?.(`${t}: ${v(s.message)}${u}`,s.percentComplete)}}),A=Date.now()-p;if(!i.success){const s=v(i.error.message);return g.onECSComplete?.({serviceName:t,serviceArn:o,success:!1,taskDefinitionArn:e,durationMs:A,reason:s}),S(new Error(`ECS rollout failed for ${t}: ${s}`))}const y=await n.ecsService.getDeploymentStatus(a,o);g.onECSComplete?.({serviceName:t,serviceArn:o,success:!0,taskDefinitionArn:e,durationMs:A,finalRunningCount:y.success?y.data.runningCount:void 0,finalDesiredCount:y.success?y.data.desiredCount:void 0}),L.debug(I,`Rolled out ${t} successfully`,{serviceArn:o,newTaskDefArn:e,previousTaskDefArn:D,durationMs:A});const x=`${R}:${c}`,C=Q(R);return j({taskDefinitionArn:e,previousTaskDefinitionArn:D,imageUri:x,ecrRepositoryArn:C,imageDigest:f,durationMs:A})}function Q(n){const a=n.match(/^(\d{12})\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com\/(.+)$/);if(!a)return"";const[,o,t,c]=a;return`arn:aws:ecr:${t}:${o}:repository/${c}`}function V(n){const a=n.lastIndexOf(":");if(a<0)return 0;const o=n.slice(a+1);return/^\d+$/.test(o)?parseInt(o,10):0}export{ae as deployCodeOnly,G as lookupContentHashTag,K as rebaseRollbackTagForService};
@@ -10,8 +10,8 @@ export declare const DOCKER_BUILD_STEP_NAME = "Building and pushing Docker image
10
10
  * Reads the post-synth `cdk.out/fjall-manifest.json` to discover the set of
11
11
  * services that need container images, resolves each service's
12
12
  * `(buildContext, dockerfilePath, target)` tuple, groups identical builds,
13
- * and pushes per-service `<service>(-<target>)?-latest` tags shaped to match
14
- * the CDK-synthesised task definitions.
13
+ * and pushes each group to ECR addressed by digest only (no moving tag), so
14
+ * the repository can stay IMMUTABLE.
15
15
  *
16
16
  * After each group's push, the helper derives a content-hash tag from the
17
17
  * digest (`deriveContentHashTag` at `@fjall/util`) and applies it via
@@ -1 +1 @@
1
- import{isAbsolute as $,join as N,dirname as T,resolve as E}from"node:path";import{existsSync as R}from"node:fs";import{success as b,failure as h}from"@fjall/generator";import{deriveContentHashTag as w,maskSensitiveOutput as k,toKebab as A}from"@fjall/util";import{logger as P}from"@fjall/util/logger";import{parseDockerServicesFromManifest as x}from"@fjall/util/manifest";import{STEP_IDS as C}from"../../types/stepDefinitions.js";const S="dockerBuildHelper",v="Building and pushing Docker image";function I(e,n,r){const t=N(n,e);if(R(t))return{path:t,base:n};if(r!==""&&r!==n){const i=N(r,e);if(R(i))return{path:i,base:r}}return{path:t,base:n}}function L(e,n,r){const t=e.docker.path,i=e.docker.context;let o,s;if($(t))o=t,s=n;else{const a=I(t,n,r);o=a.path,s=a.base}return{buildContext:i?$(i)?E(i):E(s,i):E(T(o)),dockerfilePath:o,target:e.docker.target,resolvedViaFallback:s!==n}}function B(e){return`${e.buildContext}|${e.dockerfilePath}|${e.target??""}`}function _(e,n){return`Dockerfile not found for service '${e}' at ${n}. Checked the synth directory and the working directory. If your Dockerfile lives at the project root with the CDK app in a subdirectory, set an absolute docker.context (the build context must share the Dockerfile's directory).`}function K(e){return e.startsWith("cn-")?"amazonaws.com.cn":"amazonaws.com"}function H(e,n,r){return`${e}.dkr.ecr.${n}.${K(n)}/${A(r)}`}function j(e,n){const r=n.name.toLowerCase(),t=n.docker.target,i=t?`-${t.toLowerCase()}`:"";return[`${e}:${r}${i}-latest`]}function F(e,n,r,t){const i=new Map;for(const o of e){const s=L(o,n,t),u=B(s),a=j(r,o),d=i.get(u);d?(d.members.push(o),d.latestTags.push(...a)):i.set(u,{identity:s,members:[o],latestTags:[...a]})}return Array.from(i.values())}async function U(e,n,r,t,i){const o=await e.buildAndPush({appName:n.appName,appPath:n.path,region:r,accountId:t},(s,u,a,d)=>{i.onDockerProgress?.(k(s),u,a,d)});return o.success?b({imageUri:o.data.imageUri,imageTag:o.data.imageTag}):h(o.error)}async function M(e,n,r,t,i,o,s){const u=e.members[0];if(!R(e.identity.dockerfilePath))return h(new Error(_(u.name,e.identity.dockerfilePath)));e.identity.resolvedViaFallback&&s.onLog?.(`Building service '${u.name}' from ${e.identity.dockerfilePath} (resolved at the project root).`,"info");const a={appName:r.appName,appPath:e.identity.buildContext,region:t,accountId:i,buildContext:e.identity.buildContext,dockerfilePath:e.identity.dockerfilePath,imageTags:e.latestTags,serviceName:u.name,...e.identity.target!==void 0&&{target:e.identity.target},...u.docker.buildArgs!==void 0&&{buildArgs:u.docker.buildArgs}},d=await n.buildAndPush(a,(p,l,g,D)=>{s.onDockerProgress?.(k(p),l,g,D)});if(!d.success)return h(d.error);const m=d.data.imageDigest;if(typeof m!="string"||!m.startsWith("sha256:"))return h(new Error(`Buildx push succeeded but returned an invalid digest: ${k(String(m))}`));const y={},c=[];for(const p of e.members){const l=p.docker.target,g=w(m,p.name,l);if(!g.success)return h(g.error);const D=g.data,O=p.name;y[O]=D,c.push(`${o}:${D}`)}const f=await n.tagByDigest({sourceImage:o,digest:m,tags:c});return f.success?b({contentHashTagsByService:y}):h(f.error)}async function Q(e,n,r,t){const i=e.dockerProvider;if(!i)return b({});const o=n.awsProvider.getAccountId();if(!o)return t.onLog?.("Skipping Docker build \u2014 account ID not available","warn"),b({});const s=n.awsProvider.getRegion(),u=N(r.path,"cdk.out"),a=x(u),d=H(o,s,r.appName);if(P.debug(S,"runDockerBuild starting",{appName:r.appName,appPath:r.path,accountId:o,region:s,cdkOutPath:u,manifestServiceCount:a.length,manifestServices:a.map(c=>({name:c.name,path:c.docker.path,context:c.docker.context,target:c.docker.target})),stepId:C.DOCKER_OPERATIONS,stepName:v}),t.onStepStart?.(C.DOCKER_OPERATIONS,v),a.length===0){t.onLog?.("Initialising ECR repository\u2026","info");const c=await i.initialiseECR({appName:r.appName,region:s,accountId:o});c.success||t.onLog?.(`ECR initialisation failed: ${k(c.error.message)}`,"warn"),t.onLog?.("Building and pushing Docker image\u2026","info");const f=await U(i,r,s,o,t);if(!f.success){P.debug(S,"Docker buildAndPush (legacy) failed",{appName:r.appName,error:k(f.error.message)}),t.onStepComplete?.(C.DOCKER_OPERATIONS,v,"error");const p=new Error(k(f.error.message));return t.onError?.(p),h(p)}return t.onStepComplete?.(C.DOCKER_OPERATIONS,v,"completed"),t.onLog?.(`Docker image pushed: ${f.data.imageUri}`,"info"),b({})}const m=F(a,r.path,d,e.workingDirectory);t.onLog?.(`Building and pushing ${a.length} service image(s) in ${m.length} group(s)\u2026`,"info");const y={};for(let c=0;c<m.length;c++){const f=m[c],p=f.members.map(g=>g.name).join(", ");t.onLog?.(`Building group ${c+1}/${m.length} (${p})\u2026`,"info");const l=await M(f,i,r,s,o,d,t);if(!l.success){P.debug(S,"Docker buildAndPush failed",{appName:r.appName,leader:f.members[0]?.name,members:p,error:k(l.error.message)}),t.onStepComplete?.(C.DOCKER_OPERATIONS,v,"error");const g=new Error(k(l.error.message));return t.onError?.(g),h(g)}for(const[g,D]of Object.entries(l.data.contentHashTagsByService))y[g]=D}return P.debug(S,"Docker buildAndPush succeeded",{appName:r.appName,services:a.map(c=>c.name),groupCount:m.length,contentHashTags:y}),t.onStepComplete?.(C.DOCKER_OPERATIONS,v,"completed"),t.onLog?.(`Docker images pushed for ${a.length} service(s)`,"info"),b(y)}export{v as DOCKER_BUILD_STEP_NAME,Q as runDockerBuild};
1
+ import{isAbsolute as O,join as E,dirname as A,resolve as N}from"node:path";import{existsSync as R}from"node:fs";import{success as b,failure as h}from"@fjall/generator";import{deriveContentHashTag as w,maskSensitiveOutput as l,toKebab as x}from"@fjall/util";import{logger as C}from"@fjall/util/logger";import{parseDockerServicesFromManifest as I}from"@fjall/util/manifest";import{STEP_IDS as v}from"../../types/stepDefinitions.js";const S="dockerBuildHelper",P="Building and pushing Docker image";function T(e,n,r){const t=E(n,e);if(R(t))return{path:t,base:n};if(r!==""&&r!==n){const i=E(r,e);if(R(i))return{path:i,base:r}}return{path:t,base:n}}function B(e,n,r){const t=e.docker.path,i=e.docker.context;let o,s;if(O(t))o=t,s=n;else{const u=T(t,n,r);o=u.path,s=u.base}return{buildContext:i?O(i)?N(i):N(s,i):N(A(o)),dockerfilePath:o,target:e.docker.target,resolvedViaFallback:s!==n}}function L(e){return`${e.buildContext}|${e.dockerfilePath}|${e.target??""}`}function _(e,n){return`Dockerfile not found for service '${e}' at ${n}. Checked the synth directory and the working directory. If your Dockerfile lives at the project root with the CDK app in a subdirectory, set an absolute docker.context (the build context must share the Dockerfile's directory).`}function K(e){return e.startsWith("cn-")?"amazonaws.com.cn":"amazonaws.com"}function H(e,n,r){return`${e}.dkr.ecr.${n}.${K(n)}/${x(r)}`}function j(e,n,r){const t=new Map;for(const i of e){const o=B(i,n,r),s=L(o),c=t.get(s);c?c.members.push(i):t.set(s,{identity:o,members:[i]})}return Array.from(t.values())}async function F(e,n,r,t,i){const o=await e.buildAndPush({appName:n.appName,appPath:n.path,region:r,accountId:t},(s,c,u,y)=>{i.onDockerProgress?.(l(s),c,u,y)});return o.success?b({imageUri:o.data.imageUri,imageTag:o.data.imageTag}):h(o.error)}async function U(e,n,r,t,i,o,s){const c=e.members[0];if(!R(e.identity.dockerfilePath))return h(new Error(_(c.name,e.identity.dockerfilePath)));e.identity.resolvedViaFallback&&s.onLog?.(`Building service '${c.name}' from ${e.identity.dockerfilePath} (resolved at the project root).`,"info");const u={appName:r.appName,appPath:e.identity.buildContext,region:t,accountId:i,buildContext:e.identity.buildContext,dockerfilePath:e.identity.dockerfilePath,pushByDigest:!0,serviceName:c.name,...e.identity.target!==void 0&&{target:e.identity.target},...c.docker.buildArgs!==void 0&&{buildArgs:c.docker.buildArgs}},y=await n.buildAndPush(u,(p,f,d,D)=>{s.onDockerProgress?.(l(p),f,d,D)});if(!y.success)return h(y.error);const m=y.data.imageDigest;if(typeof m!="string"||!m.startsWith("sha256:"))return h(new Error(`Buildx push succeeded but returned an invalid digest: ${l(String(m))}`));const k={},a=[];for(const p of e.members){const f=p.docker.target,d=w(m,p.name,f);if(!d.success)return h(d.error);const D=d.data,$=p.name;k[$]=D,a.push(`${o}:${D}`)}const g=await n.tagByDigest({sourceImage:o,digest:m,tags:a});return g.success?b({contentHashTagsByService:k}):h(g.error)}async function J(e,n,r,t){const i=e.dockerProvider;if(!i)return b({});const o=n.awsProvider.getAccountId();if(!o)return t.onLog?.("Skipping Docker build \u2014 account ID not available","warn"),b({});const s=n.awsProvider.getRegion(),c=E(r.path,"cdk.out"),u=I(c),y=H(o,s,r.appName);if(C.debug(S,"runDockerBuild starting",{appName:r.appName,appPath:r.path,accountId:o,region:s,cdkOutPath:c,manifestServiceCount:u.length,manifestServices:u.map(a=>({name:a.name,path:a.docker.path,context:a.docker.context,target:a.docker.target})),stepId:v.DOCKER_OPERATIONS,stepName:P}),t.onStepStart?.(v.DOCKER_OPERATIONS,P),u.length===0){t.onLog?.("Initialising ECR repository\u2026","info");const a=await i.initialiseECR({appName:r.appName,region:s,accountId:o});a.success||t.onLog?.(`ECR initialisation failed: ${l(a.error.message)}`,"warn"),t.onLog?.("Building and pushing Docker image\u2026","info");const g=await F(i,r,s,o,t);if(!g.success){C.debug(S,"Docker buildAndPush (legacy) failed",{appName:r.appName,error:l(g.error.message)}),t.onStepComplete?.(v.DOCKER_OPERATIONS,P,"error");const p=new Error(l(g.error.message));return t.onError?.(p),h(p)}return t.onStepComplete?.(v.DOCKER_OPERATIONS,P,"completed"),t.onLog?.(`Docker image pushed: ${g.data.imageUri}`,"info"),b({})}const m=j(u,r.path,e.workingDirectory);t.onLog?.(`Building and pushing ${u.length} service image(s) in ${m.length} group(s)\u2026`,"info");const k={};for(let a=0;a<m.length;a++){const g=m[a],p=g.members.map(d=>d.name).join(", ");t.onLog?.(`Building group ${a+1}/${m.length} (${p})\u2026`,"info");const f=await U(g,i,r,s,o,y,t);if(!f.success){C.debug(S,"Docker buildAndPush failed",{appName:r.appName,leader:g.members[0]?.name,members:p,error:l(f.error.message)}),t.onStepComplete?.(v.DOCKER_OPERATIONS,P,"error");const d=new Error(l(f.error.message));return t.onError?.(d),h(d)}for(const[d,D]of Object.entries(f.data.contentHashTagsByService))k[d]=D}return C.debug(S,"Docker buildAndPush succeeded",{appName:r.appName,services:u.map(a=>a.name),groupCount:m.length,contentHashTags:k}),t.onStepComplete?.(v.DOCKER_OPERATIONS,P,"completed"),t.onLog?.(`Docker images pushed for ${u.length} service(s)`,"info"),b(k)}export{P as DOCKER_BUILD_STEP_NAME,J as runDockerBuild};
@@ -41,6 +41,15 @@ export interface DockerBuildParams {
41
41
  * from `imageTag` / `appName`.
42
42
  */
43
43
  imageTags?: readonly string[];
44
+ /**
45
+ * Push the image to ECR addressed by digest only, creating no tag. The
46
+ * caller derives a content-hash tag from `DockerBuildResult.imageDigest` and
47
+ * applies it via `tagByDigest`. This is the deploy path's default: it keeps
48
+ * the ECR repo IMMUTABLE (a moving `-latest` tag would collide on the second
49
+ * push; digest-only pushes and unique content-hash tags never do). When set,
50
+ * `imageTags` is ignored.
51
+ */
52
+ pushByDigest?: boolean;
44
53
  /** Optional buildArgs forwarded to buildx. */
45
54
  buildArgs?: Readonly<Record<string, string>>;
46
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/deploy-core",
3
- "version": "2.20.0",
3
+ "version": "2.20.1",
4
4
  "description": "Shared deployment engine for Fjall — used by CLI and webapp worker",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
@@ -78,8 +78,8 @@
78
78
  "@aws-sdk/client-sqs": "^3.1067.0",
79
79
  "@aws-sdk/client-sso-admin": "^3.1038.0",
80
80
  "@aws-sdk/client-sts": "^3.1038.0",
81
- "@fjall/generator": "^2.20.0",
82
- "@fjall/util": "^2.20.0",
81
+ "@fjall/generator": "^2.20.1",
82
+ "@fjall/util": "^2.20.1",
83
83
  "@smithy/node-http-handler": "^4.6.1",
84
84
  "tsx": "^4.21.0",
85
85
  "zod": "^4.4.3"
@@ -89,5 +89,5 @@
89
89
  "typescript": "^6.0.3",
90
90
  "vitest": "^4.1.5"
91
91
  },
92
- "gitHead": "ec556d44a8a32fbdc1a2e1ca3c1f22224b0d27c2"
92
+ "gitHead": "397880361ea533450ea5c888f7e7b9593254f663"
93
93
  }