@fjall/deploy-core 2.19.6 → 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
- 152 files minified at 2026-06-22T23:49:11.490Z
1
+ 153 files minified at 2026-06-23T21:59:07.691Z
@@ -0,0 +1,28 @@
1
+ import { type IAMClient } from "@aws-sdk/client-iam";
2
+ import { type Result } from "@fjall/generator";
3
+ import type { DeployCallbacks } from "../../types/callbacks.js";
4
+ export interface OidcProviderTeardownResult {
5
+ deleted: boolean;
6
+ reason: string;
7
+ }
8
+ /**
9
+ * Reference-counted teardown of the account-global `https://fjall.io` OIDC
10
+ * provider. The provider is an IAM singleton shared by every Fjall deploy role
11
+ * in the account and is now Retain-on-stack-delete (adopt-if-exists), so the
12
+ * only safe deleter is one that confirms NOTHING still federates it.
13
+ *
14
+ * Deletes only when (a) the provider exists and (b) no IAM role's trust policy
15
+ * references its ARN. ListRoles returns the URL-encoded
16
+ * AssumeRolePolicyDocument inline, so a single paginated sweep answers the
17
+ * reference count without a GetRole per role. Any surviving reference — a
18
+ * surviving connect/account stack's deploy role — short-circuits to a no-op.
19
+ *
20
+ * Failure messages stay raw — callers mask at their output boundary.
21
+ */
22
+ export declare function deleteOidcProviderIfUnreferenced(client: IAMClient, abortSignal?: AbortSignal): Promise<Result<OidcProviderTeardownResult>>;
23
+ /**
24
+ * Best-effort wrapper around {@link deleteOidcProviderIfUnreferenced}: surfaces
25
+ * the outcome via callbacks/logger and swallows failures. A cleanup miss leaves
26
+ * a harmless orphaned provider and must never block a teardown.
27
+ */
28
+ export declare function cleanupOrphanedOidcProvider(client: IAMClient, callbacks: DeployCallbacks, abortSignal?: AbortSignal): Promise<void>;
@@ -0,0 +1 @@
1
+ import{ListOpenIDConnectProvidersCommand as f,ListRolesCommand as u,DeleteOpenIDConnectProviderCommand as m}from"@aws-sdk/client-iam";import{success as d,failure as v}from"@fjall/generator";import{maskSensitiveOutput as I,getErrorMessage as O}from"@fjall/util";import{logger as a}from"@fjall/util/logger";import{composeSdkAbortSignal as s,isAborted as c}from"./types.js";const w="fjall.io",D=`:oidc-provider/${w}`;async function C(i,e){try{if(c(e))return d({deleted:!1,reason:"aborted"});const r=((await i.send(new f({}),{abortSignal:s(e)})).OpenIDConnectProviderList??[]).map(o=>o.Arn).find(o=>typeof o=="string"&&o.endsWith(D));if(r===void 0)return d({deleted:!1,reason:"provider not present"});let t;do{if(c(e))return d({deleted:!1,reason:"aborted"});const o=await i.send(new u({Marker:t}),{abortSignal:s(e)});for(const l of o.Roles??[]){const p=l.AssumeRolePolicyDocument;if(typeof p=="string"&&decodeURIComponent(p).includes(r))return d({deleted:!1,reason:`still referenced by role ${l.RoleName??"(unknown)"}`})}t=o.IsTruncated===!0?o.Marker:void 0}while(t!==void 0);return await i.send(new m({OpenIDConnectProviderArn:r}),{abortSignal:s(e)}),d({deleted:!0,reason:"deleted orphaned provider"})}catch(n){return n instanceof Error&&n.name==="NoSuchEntityException"?d({deleted:!1,reason:"provider not present"}):v(new Error(`Failed to evaluate OIDC provider teardown: ${O(n)}`))}}async function P(i,e,n){const r=await C(i,n);if(!r.success){const t=I(r.error.message);a.debug("oidcTeardown","OIDC provider teardown probe failed",{error:t}),e.onLog?.(`OIDC provider cleanup skipped: ${t}`,"warn");return}if(r.data.deleted){a.info("oidcTeardown","Deleted orphaned account-global OIDC provider"),e.onLog?.("Removed the orphaned Fjall OIDC provider","info");return}a.debug("oidcTeardown","OIDC provider retained",{reason:r.data.reason})}export{P as cleanupOrphanedOidcProvider,C as deleteOidcProviderIfUnreferenced};
@@ -8,5 +8,14 @@ import { type OrgDetails } from "./types.js";
8
8
  export declare function ensureOrganisationExists(client: OrganizationsClient, abortSignal?: AbortSignal): Promise<Result<OrgDetails>>;
9
9
  /**
10
10
  * Describe the current AWS Organisation, returning null if none exists.
11
+ *
12
+ * Read-only — never creates. With `tolerateAccessDenied`, an AccessDenied from
13
+ * either DescribeOrganization or ListRoots also resolves to null: a standalone
14
+ * account whose role lacks `organizations:*` is not in — and does not need — an
15
+ * organisation, so a solo deploy proceeds standalone rather than failing. The
16
+ * default (flag absent) still rethrows AccessDenied so the org/cascade paths
17
+ * surface a genuine permissions error.
11
18
  */
12
- export declare function describeOrganisation(client: OrganizationsClient, abortSignal?: AbortSignal): Promise<Result<OrgDetails | null>>;
19
+ export declare function describeOrganisation(client: OrganizationsClient, abortSignal?: AbortSignal, opts?: {
20
+ tolerateAccessDenied?: boolean;
21
+ }): Promise<Result<OrgDetails | null>>;
@@ -1 +1 @@
1
- import{DescribeOrganizationCommand as f,CreateOrganizationCommand as I,ListRootsCommand as w}from"@aws-sdk/client-organizations";import{success as a,failure as n}from"@fjall/generator";import{getErrorMessage as d}from"@fjall/util";import{extractErrorName as g,composeSdkAbortSignal as c,AWS_ERROR_NAMES as l}from"./types.js";async function y(t,o){try{let r=!1,e=await u(t,o);if(!e)try{e=(await t.send(new I({FeatureSet:"ALL"}),{abortSignal:c(o)})).Organization??null,r=!0}catch(i){if(g(i)==="AlreadyInOrganizationException"){if(e=await u(t,o),!e)return n(new Error("Account is already in an organisation but DescribeOrganization returned nothing"))}else throw i}if(!e?.Id||!e.MasterAccountId)return n(new Error("Organisation is missing required fields (Id or MasterAccountId)"));const s=await m(t,o);return s.success?a({orgId:e.Id,rootId:s.data,managementAccountId:e.MasterAccountId,created:r}):s}catch(r){return n(new Error(`Failed to ensure organisation exists: ${d(r)}`))}}async function N(t,o){try{const r=await u(t,o);if(!r)return a(null);if(!r.Id||!r.MasterAccountId)return n(new Error("Organisation is missing required fields (Id or MasterAccountId)"));const e=await m(t,o);return e.success?a({orgId:r.Id,rootId:e.data,managementAccountId:r.MasterAccountId,created:!1}):e}catch(r){return n(new Error(`Failed to describe organisation: ${d(r)}`))}}async function m(t,o){const e=(await t.send(new w({}),{abortSignal:c(o)})).Roots??[];return e.length===0||!e[0]?.Id?n(new Error("No organisation root found")):a(e[0].Id)}async function u(t,o){try{return(await t.send(new f({}),{abortSignal:c(o)})).Organization??null}catch(r){if(g(r)===l.ORGS_NOT_IN_USE)return null;throw r}}export{N as describeOrganisation,y as ensureOrganisationExists};
1
+ import{DescribeOrganizationCommand as m,CreateOrganizationCommand as I,ListRootsCommand as w}from"@aws-sdk/client-organizations";import{success as s,failure as a}from"@fjall/generator";import{getErrorMessage as g}from"@fjall/util";import{extractErrorName as c,isAccessDenied as f,composeSdkAbortSignal as u,AWS_ERROR_NAMES as A}from"./types.js";async function h(e,t){try{let n=!1,r=await d(e,t);if(!r)try{r=(await e.send(new I({FeatureSet:"ALL"}),{abortSignal:u(t)})).Organization??null,n=!0}catch(i){if(c(i)==="AlreadyInOrganizationException"){if(r=await d(e,t),!r)return a(new Error("Account is already in an organisation but DescribeOrganization returned nothing"))}else throw i}if(!r?.Id||!r.MasterAccountId)return a(new Error("Organisation is missing required fields (Id or MasterAccountId)"));const o=await l(e,t);return o.success?s({orgId:r.Id,rootId:o.data,managementAccountId:r.MasterAccountId,created:n}):o}catch(n){return a(new Error(`Failed to ensure organisation exists: ${g(n)}`))}}async function M(e,t,n){try{const r=await d(e,t,n);if(!r)return s(null);if(!r.Id||!r.MasterAccountId)return a(new Error("Organisation is missing required fields (Id or MasterAccountId)"));const o=await l(e,t);return o.success?s({orgId:r.Id,rootId:o.data,managementAccountId:r.MasterAccountId,created:!1}):o}catch(r){return n?.tolerateAccessDenied===!0&&f(c(r))?s(null):a(new Error(`Failed to describe organisation: ${g(r)}`))}}async function l(e,t){const r=(await e.send(new w({}),{abortSignal:u(t)})).Roots??[];return r.length===0||!r[0]?.Id?a(new Error("No organisation root found")):s(r[0].Id)}async function d(e,t,n){try{return(await e.send(new m({}),{abortSignal:u(t)})).Organization??null}catch(r){const o=c(r);if(o===A.ORGS_NOT_IN_USE||n?.tolerateAccessDenied===!0&&f(o))return null;throw r}}export{M as describeOrganisation,h as ensureOrganisationExists};
@@ -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
  }
@@ -1 +1 @@
1
- import{join as B}from"path";import{logger as f}from"@fjall/util/logger";import{maskSensitiveOutput as i,getErrorMessage as g}from"@fjall/util";import{stubCallerIdentity as I}from"../../types/deployment/index.js";import{CloudFormationClient as P,DescribeStacksCommand as _}from"@aws-sdk/client-cloudformation";import{S3Client as v}from"@aws-sdk/client-s3";import{BackupClient as x}from"@aws-sdk/client-backup";import{accountHasDisasterRecovery as F,describeSurvivingBackupVault as M,formatSurvivingVaultWarning as K}from"../../aws/organisations/backup.js";import{composeSdkAbortSignal as L}from"../../aws/organisations/types.js";import{ORGANISATION_TYPES as R,getOrganisationStackName as V}from"../../types/operations.js";import{CdkContextBuilder as j}from"../../services/supporting/CdkContextBuilder.js";import{regionSuffix as U}from"../../types/config/FjallState.js";import{buildParamsContext as G,assumeCascadeRole as H,forwardOutput as E}from"../contextHelpers.js";import{cascadeOperationKey as W}from"./cascadeHelpers.js";import{capturedSweepBuckets as Y,cleanupFailedStack as q,preEmptyStackBuckets as z,sweepOrphanedDestroyBuckets as N}from"../stackCleanup.js";import{STACK_NOT_FOUND_PATTERN as J,STACK_FAILED_STATE_PATTERN as Q}from"../../types/constants.js";async function Se(a,n,c,e,t,r,o,T){const S=Date.now(),u=W(e.name,r),l=(s,p=s)=>(o.onCascadeAccountComplete?.(u,!1,p,r),{accountName:e.name,accountId:e.id,region:r,success:!1,duration:Date.now()-S,error:s});o.onCascadeAccountStart?.(u,e.id,r,t);const y=await H(n.awsProvider,e.id,r,`fjall-cascade-destroy-${e.name}`);if(!y.success){const s=i(y.error.message);return l(`AssumeRole failed: ${s}`,s)}const{provider:d,credentials:w}=y.data,O=B(c.path,`cdk.out.${e.id}.${U(r)}`),k=j.buildDeploymentContext({deployType:t,target:c.target,path:c.path,assemblyDir:O,region:r,accountName:e.name,callerIdentity:I(e.id),...G({orgConfig:a.orgConfig,identity:a.identity,region:r,primaryRegion:T?.primaryRegion})},{verbose:a.options?.verbose},a.orgConfig);o.onCascadeAccountPhaseChange?.(u,"synth",r);const A=await n.cdkService.runCdkSynth(k,E(o));if(!A.success)return l(i(`Synth failed: ${A.error}`));o.onCascadeAccountPhaseChange?.(u,"destroy",r);const m=V(t==="platform"?R.PLATFORM:R.ACCOUNT);F(e.environment,a.orgConfig?.disasterRecoveryRegion)&&await X(d.getClient(x),r,o,a.abortSignal);const $=await z(d.getClient(P),d.getClient(v),m,o,a.abortSignal),C=Y($),D=await n.cdkService.runCdkDestroy(k,m,E(o),s=>o.onCascadeAccountResourceProgress?.(u,s,r),d,!0,w);if(!D.success){const s=D.error;if(s.includes(Q)){f.warn("cascadeDestroy",`CDK destroy failed on ${m} in failed state, retrying via CloudFormation API`,{region:r,account:e.name});try{await q(m,r,w,{accountId:e.id,abortSignal:a.abortSignal},o)}catch(b){const h=`cleanupFailedStack threw for ${m}: ${i(g(b))}`;f.warn("cascadeDestroy",h),o.onLog?.(h,"warn")}const p=await Z(m,d.getClient(P),a.abortSignal);return p.deleted?(C.length>0&&await N(d.getClient(v),C,o,a.abortSignal),o.onCascadeAccountComplete?.(u,!0,void 0,r),{accountName:e.name,accountId:e.id,region:r,success:!0,duration:Date.now()-S}):p.error?l(i(p.error)):l(i(`Stack ${m} cleanup attempted but stack still exists in ${r}`))}return l(i(s))}return C.length>0&&await N(d.getClient(v),C,o,a.abortSignal),o.onCascadeAccountComplete?.(u,!0,void 0,r),{accountName:e.name,accountId:e.id,region:r,success:!0,duration:Date.now()-S}}async function X(a,n,c,e){try{const t=await M(a,n,e);if(!t.success){f.debug("cascadeDestroy","Backup-vault survival probe failed",{region:n,error:i(t.error.message)});return}if(t.data===null)return;c.onProgress?.({type:"warning",message:K(t.data),metadata:{source:"backup-vault-survival"}}),f.warn("cascadeDestroy","Backup vault survives destroy",{region:n,vaultName:t.data.vaultName,recoveryPointCount:t.data.recoveryPointCount,lockPermanent:t.data.lockPermanent})}catch(t){f.debug("cascadeDestroy","Backup-vault survival probe threw",{region:n,error:i(g(t))})}}async function Z(a,n,c){try{const t=(await n.send(new _({StackName:a}),{abortSignal:L(c)})).Stacks?.[0]?.StackStatus;return!t||t==="DELETE_COMPLETE"?{deleted:!0}:{deleted:!1,error:`Stack still in ${t} after cleanup attempt`}}catch(e){if(e instanceof Error&&e.message?.includes(J))return{deleted:!0};const t=i(g(e));return f.debug("cascadeDestroy","Stack verification failed",{error:t}),{deleted:!1,error:`Stack verification failed: ${t}`}}}export{Se as destroyCascadeAccount};
1
+ import{join as b}from"path";import{logger as f}from"@fjall/util/logger";import{maskSensitiveOutput as s,getErrorMessage as v}from"@fjall/util";import{stubCallerIdentity as B}from"../../types/deployment/index.js";import{CloudFormationClient as P,DescribeStacksCommand as _}from"@aws-sdk/client-cloudformation";import{S3Client as w}from"@aws-sdk/client-s3";import{BackupClient as x}from"@aws-sdk/client-backup";import{IAMClient as F}from"@aws-sdk/client-iam";import{accountHasDisasterRecovery as M,describeSurvivingBackupVault as K,formatSurvivingVaultWarning as L}from"../../aws/organisations/backup.js";import{cleanupOrphanedOidcProvider as V}from"../../aws/organisations/oidcProvider.js";import{composeSdkAbortSignal as j}from"../../aws/organisations/types.js";import{ORGANISATION_TYPES as E,getOrganisationStackName as U}from"../../types/operations.js";import{CdkContextBuilder as G}from"../../services/supporting/CdkContextBuilder.js";import{regionSuffix as H}from"../../types/config/FjallState.js";import{buildParamsContext as W,assumeCascadeRole as Y,forwardOutput as N}from"../contextHelpers.js";import{cascadeOperationKey as q}from"./cascadeHelpers.js";import{capturedSweepBuckets as z,cleanupFailedStack as J,preEmptyStackBuckets as Q,sweepOrphanedDestroyBuckets as O}from"../stackCleanup.js";import{STACK_NOT_FOUND_PATTERN as X,STACK_FAILED_STATE_PATTERN as Z}from"../../types/constants.js";async function we(a,n,d,e,t,r,o,y){const S=Date.now(),c=q(e.name,r),l=(i,p=i)=>(o.onCascadeAccountComplete?.(c,!1,p,r),{accountName:e.name,accountId:e.id,region:r,success:!1,duration:Date.now()-S,error:i});o.onCascadeAccountStart?.(c,e.id,r,t);const g=await Y(n.awsProvider,e.id,r,`fjall-cascade-destroy-${e.name}`);if(!g.success){const i=s(g.error.message);return l(`AssumeRole failed: ${i}`,i)}const{provider:u,credentials:k}=g.data,T=b(d.path,`cdk.out.${e.id}.${H(r)}`),A=G.buildDeploymentContext({deployType:t,target:d.target,path:d.path,assemblyDir:T,region:r,accountName:e.name,callerIdentity:B(e.id),...W({orgConfig:a.orgConfig,identity:a.identity,region:r,primaryRegion:y?.primaryRegion})},{verbose:a.options?.verbose},a.orgConfig);o.onCascadeAccountPhaseChange?.(c,"synth",r);const D=await n.cdkService.runCdkSynth(A,N(o));if(!D.success)return l(s(`Synth failed: ${D.error}`));o.onCascadeAccountPhaseChange?.(c,"destroy",r);const m=U(t==="platform"?E.PLATFORM:E.ACCOUNT);M(e.environment,a.orgConfig?.disasterRecoveryRegion)&&await ee(u.getClient(x),r,o,a.abortSignal);const I=await Q(u.getClient(P),u.getClient(w),m,o,a.abortSignal),C=z(I),h=await n.cdkService.runCdkDestroy(A,m,N(o),i=>o.onCascadeAccountResourceProgress?.(c,i,r),u,!0,k);if(!h.success){const i=h.error;if(i.includes(Z)){f.warn("cascadeDestroy",`CDK destroy failed on ${m} in failed state, retrying via CloudFormation API`,{region:r,account:e.name});try{await J(m,r,k,{accountId:e.id,abortSignal:a.abortSignal},o)}catch($){const R=`cleanupFailedStack threw for ${m}: ${s(v($))}`;f.warn("cascadeDestroy",R),o.onLog?.(R,"warn")}const p=await te(m,u.getClient(P),a.abortSignal);return p.deleted?(C.length>0&&await O(u.getClient(w),C,o,a.abortSignal),o.onCascadeAccountComplete?.(c,!0,void 0,r),{accountName:e.name,accountId:e.id,region:r,success:!0,duration:Date.now()-S}):p.error?l(s(p.error)):l(s(`Stack ${m} cleanup attempted but stack still exists in ${r}`))}return l(s(i))}return C.length>0&&await O(u.getClient(w),C,o,a.abortSignal),(y?.primaryRegion===void 0||r===y.primaryRegion)&&await V(u.getClient(F),o,a.abortSignal),o.onCascadeAccountComplete?.(c,!0,void 0,r),{accountName:e.name,accountId:e.id,region:r,success:!0,duration:Date.now()-S}}async function ee(a,n,d,e){try{const t=await K(a,n,e);if(!t.success){f.debug("cascadeDestroy","Backup-vault survival probe failed",{region:n,error:s(t.error.message)});return}if(t.data===null)return;d.onProgress?.({type:"warning",message:L(t.data),metadata:{source:"backup-vault-survival"}}),f.warn("cascadeDestroy","Backup vault survives destroy",{region:n,vaultName:t.data.vaultName,recoveryPointCount:t.data.recoveryPointCount,lockPermanent:t.data.lockPermanent})}catch(t){f.debug("cascadeDestroy","Backup-vault survival probe threw",{region:n,error:s(v(t))})}}async function te(a,n,d){try{const t=(await n.send(new _({StackName:a}),{abortSignal:j(d)})).Stacks?.[0]?.StackStatus;return!t||t==="DELETE_COMPLETE"?{deleted:!0}:{deleted:!1,error:`Stack still in ${t} after cleanup attempt`}}catch(e){if(e instanceof Error&&e.message?.includes(X))return{deleted:!0};const t=s(v(e));return f.debug("cascadeDestroy","Stack verification failed",{error:t}),{deleted:!1,error:`Stack verification failed: ${t}`}}}export{we as destroyCascadeAccount};
@@ -1,3 +1,3 @@
1
- import{CloudFormationClient as k}from"@aws-sdk/client-cloudformation";import{S3Client as T}from"@aws-sdk/client-s3";import{success as D,failure as N}from"@fjall/generator";import{maskSensitiveOutput as O,getErrorMessage as M,mapSettledWithConcurrency as _}from"@fjall/util";import{stubCallerIdentity as b}from"../../types/deployment/index.js";import{ORGANISATION_TYPES as v,getOrganisationStackName as L}from"../../types/operations.js";import{CdkContextBuilder as x}from"../../services/supporting/CdkContextBuilder.js";import{buildParamsContext as B,synthOrFail as G,forwardOutput as Y,forwardResourceProgress as j}from"../contextHelpers.js";import{destroyCascadeAccount as $}from"./cascadeDestroyHelpers.js";import{capturedSweepBuckets as F,preEmptyStackBuckets as U,sweepOrphanedDestroyBuckets as H}from"../stackCleanup.js";import{partitionAccounts as W,buildRegionList as X,buildAccountRegionPairs as q,cascadeHomeRegion as z,CASCADE_MAX_CONCURRENCY as J}from"./cascadeHelpers.js";import{projectScalarSummary as K}from"./cascadeSummary.js";import{STEP_IDS as Q}from"../../types/stepDefinitions.js";const E=Q.ORG_DESTROY,I="Destroying organisation infrastructure";async function lt(o,r,c){const S=Date.now(),{callbacks:t}=o,l=o.orgConfig?.providerAccounts??[],g=z(o.orgConfig),m=X(o.orgConfig),{platformAccount:u,memberAccounts:f}=W(l),h=o.options?.cascade!==!1;t.onLog?.(`Destroying organisation infrastructure (${l.length} accounts, ${m.length} region(s))`,"info");const n=[],p=[],w=[];let R;if(h){t.onCascadeStart?.();const A=Date.now();if(f.length>0){t.onCascadePhaseStart?.("accounts");const a=q(f,m),d=await _(a,J,({account:s,region:i})=>$(o,r,c,s,"account",i,t,{primaryRegion:g}));for(let s=0;s<d.length;s++){const i=d[s],e=a[s];if(!(!i||!e))if(i.status==="fulfilled")if(i.value.success)p.push(`Account-${e.account.name}-${e.region}`),w.push({accountId:e.account.id,accountName:e.account.name,region:e.region,result:"succeeded",durationMs:i.value.duration});else{const C=O(i.value.error??"Unknown error");w.push({accountId:e.account.id,accountName:e.account.name,region:e.region,result:"failed",durationMs:i.value.duration,error:C}),n.push({accountId:e.account.id,error:C})}else{const C=O(M(i.reason));w.push({accountId:e.account.id,accountName:e.account.name,region:e.region,result:"failed",durationMs:0,error:C}),n.push({accountId:e.account.id,error:C})}}t.onCascadePhaseComplete?.("accounts")}if(u){t.onCascadePhaseStart?.("platform");const a=await $(o,r,c,u,"platform",u.region??g,t);if(a.success)p.push("Platform"),R={accountId:u.id,result:"succeeded",durationMs:a.duration};else{const d=O(a.error??"Platform destroy failed");n.push({accountId:u.id,error:d}),R={accountId:u.id,result:"failed",durationMs:a.duration,error:d}}t.onCascadePhaseComplete?.("platform")}const y={members:w,...R!==void 0?{platform:R}:{},domainsDeployed:!1,errors:n,totalDurationMs:Date.now()-A};if(t.onCascadeComplete?.(K(y)),t.onCascadeLedger?.(y),n.length>0){const a=n.map(s=>` ${s.accountId}: ${s.error}`).join(`
1
+ import{CloudFormationClient as $}from"@aws-sdk/client-cloudformation";import{S3Client as T}from"@aws-sdk/client-s3";import{IAMClient as _}from"@aws-sdk/client-iam";import{success as P,failure as M}from"@fjall/generator";import{maskSensitiveOutput as O,getErrorMessage as k,mapSettledWithConcurrency as v}from"@fjall/util";import{stubCallerIdentity as b}from"../../types/deployment/index.js";import{ORGANISATION_TYPES as L,getOrganisationStackName as x}from"../../types/operations.js";import{CdkContextBuilder as B}from"../../services/supporting/CdkContextBuilder.js";import{buildParamsContext as G,synthOrFail as Y,forwardOutput as j,forwardResourceProgress as F}from"../contextHelpers.js";import{destroyCascadeAccount as N}from"./cascadeDestroyHelpers.js";import{cleanupOrphanedOidcProvider as U}from"../../aws/organisations/oidcProvider.js";import{capturedSweepBuckets as H,preEmptyStackBuckets as W,sweepOrphanedDestroyBuckets as X}from"../stackCleanup.js";import{partitionAccounts as q,buildRegionList as z,buildAccountRegionPairs as J,cascadeHomeRegion as K,CASCADE_MAX_CONCURRENCY as Q}from"./cascadeHelpers.js";import{projectScalarSummary as V}from"./cascadeSummary.js";import{STEP_IDS as Z}from"../../types/stepDefinitions.js";const D=Z.ORG_DESTROY,E="Destroying organisation infrastructure";async function Ct(o,r,c){const S=Date.now(),{callbacks:t}=o,l=o.orgConfig?.providerAccounts??[],g=K(o.orgConfig),m=z(o.orgConfig),{platformAccount:u,memberAccounts:f}=q(l),h=o.options?.cascade!==!1;t.onLog?.(`Destroying organisation infrastructure (${l.length} accounts, ${m.length} region(s))`,"info");const n=[],p=[],w=[];let R;if(h){t.onCascadeStart?.();const A=Date.now();if(f.length>0){t.onCascadePhaseStart?.("accounts");const a=J(f,m),d=await v(a,Q,({account:i,region:s})=>N(o,r,c,i,"account",s,t,{primaryRegion:g}));for(let i=0;i<d.length;i++){const s=d[i],e=a[i];if(!(!s||!e))if(s.status==="fulfilled")if(s.value.success)p.push(`Account-${e.account.name}-${e.region}`),w.push({accountId:e.account.id,accountName:e.account.name,region:e.region,result:"succeeded",durationMs:s.value.duration});else{const y=O(s.value.error??"Unknown error");w.push({accountId:e.account.id,accountName:e.account.name,region:e.region,result:"failed",durationMs:s.value.duration,error:y}),n.push({accountId:e.account.id,error:y})}else{const y=O(k(s.reason));w.push({accountId:e.account.id,accountName:e.account.name,region:e.region,result:"failed",durationMs:0,error:y}),n.push({accountId:e.account.id,error:y})}}t.onCascadePhaseComplete?.("accounts")}if(u){t.onCascadePhaseStart?.("platform");const a=await N(o,r,c,u,"platform",u.region??g,t);if(a.success)p.push("Platform"),R={accountId:u.id,result:"succeeded",durationMs:a.duration};else{const d=O(a.error??"Platform destroy failed");n.push({accountId:u.id,error:d}),R={accountId:u.id,result:"failed",durationMs:a.duration,error:d}}t.onCascadePhaseComplete?.("platform")}const C={members:w,...R!==void 0?{platform:R}:{},domainsDeployed:!1,errors:n,totalDurationMs:Date.now()-A};if(t.onCascadeComplete?.(V(C)),t.onCascadeLedger?.(C),n.length>0){const a=n.map(i=>` ${i.accountId}: ${i.error}`).join(`
2
2
  `),d=new Error(`Cascade destroy completed with ${n.length} failure(s):
3
- ${a}`);t.onError?.(d),t.onLog?.(d.message,"warn")}}if(n.length>0)return t.onLog?.("Skipping organisation root stack destroy due to cascade failures","warn"),D({target:c.target,deploymentType:"organisation",stacksDestroyed:p,durationMs:Date.now()-S,warnings:n.map(y=>`${y.accountId}: ${y.error}`)});t.onStepStart?.(E,I,0,1);const P=await V(o,r,c,r.awsProvider.getRegion(),t);if(P.success)p.push("Organisation"),t.onStepComplete?.(E,I,"completed",0,1);else return t.onStepComplete?.(E,I,"error",0,1),N(P.error);return D({target:c.target,deploymentType:"organisation",stacksDestroyed:p,durationMs:Date.now()-S})}async function V(o,r,c,S,t){const l=x.buildDeploymentContext({deployType:"organisation",target:c.target,path:c.path,region:S,callerIdentity:b(r.awsProvider.getAccountId()),...B({orgConfig:o.orgConfig,identity:o.identity})},{verbose:o.options?.verbose},o.orgConfig),g=L(v.ORGANISATION);t.onLog?.("Synthesising organisation infrastructure\u2026","info");const m=await G(r,l,t,"Organisation synth failed");if(!m.success)return m;const u=await U(r.awsProvider.getClient(k),r.awsProvider.getClient(T),g,t,o.abortSignal),f=F(u);t.onLog?.(`Destroying ${g} stack\u2026`,"info");const h=await r.cdkService.runCdkDestroy(l,g,Y(t),j(t),r.awsProvider,!0);if(!h.success){const n=new Error(O(`Organisation destroy failed: ${h.error}`));return t.onError?.(n),N(n)}return f.length>0&&await H(r.awsProvider.getClient(T),f,t,o.abortSignal),D(void 0)}export{lt as destroyOrganisation};
3
+ ${a}`);t.onError?.(d),t.onLog?.(d.message,"warn")}}if(n.length>0)return t.onLog?.("Skipping organisation root stack destroy due to cascade failures","warn"),P({target:c.target,deploymentType:"organisation",stacksDestroyed:p,durationMs:Date.now()-S,warnings:n.map(C=>`${C.accountId}: ${C.error}`)});t.onStepStart?.(D,E,0,1);const I=await tt(o,r,c,r.awsProvider.getRegion(),t);if(I.success)p.push("Organisation"),t.onStepComplete?.(D,E,"completed",0,1);else return t.onStepComplete?.(D,E,"error",0,1),M(I.error);return P({target:c.target,deploymentType:"organisation",stacksDestroyed:p,durationMs:Date.now()-S})}async function tt(o,r,c,S,t){const l=B.buildDeploymentContext({deployType:"organisation",target:c.target,path:c.path,region:S,callerIdentity:b(r.awsProvider.getAccountId()),...G({orgConfig:o.orgConfig,identity:o.identity})},{verbose:o.options?.verbose},o.orgConfig),g=x(L.ORGANISATION);t.onLog?.("Synthesising organisation infrastructure\u2026","info");const m=await Y(r,l,t,"Organisation synth failed");if(!m.success)return m;const u=await W(r.awsProvider.getClient($),r.awsProvider.getClient(T),g,t,o.abortSignal),f=H(u);t.onLog?.(`Destroying ${g} stack\u2026`,"info");const h=await r.cdkService.runCdkDestroy(l,g,j(t),F(t),r.awsProvider,!0);if(!h.success){const n=new Error(O(`Organisation destroy failed: ${h.error}`));return t.onError?.(n),M(n)}return f.length>0&&await X(r.awsProvider.getClient(T),f,t,o.abortSignal),await U(r.awsProvider.getClient(_),t,o.abortSignal),P(void 0)}export{Ct as destroyOrganisation};
@@ -8,5 +8,15 @@ export interface OrgDetailsForSynth {
8
8
  rootId: string;
9
9
  managementAccountId: string;
10
10
  }
11
- export declare function buildOrgContext(params: DeployParams, services: DeployServices, operation: OrganisationOperation, deployType: "organisation" | "platform" | "account", orgDetails: OrgDetailsForSynth, accountName?: string, trailLifecycle?: ProviderAccount["trailLifecycle"], targetIsOrganisationTier?: boolean): import("../../types/deployment/DeploymentTypes.js").DeploymentContext;
11
+ export declare function buildOrgContext(params: DeployParams, services: DeployServices, operation: OrganisationOperation, deployType: "organisation" | "platform" | "account", orgDetails: OrgDetailsForSynth | undefined, accountName?: string, trailLifecycle?: ProviderAccount["trailLifecycle"], targetIsOrganisationTier?: boolean): import("../../types/deployment/DeploymentTypes.js").DeploymentContext;
12
12
  export declare function resolveOrgDetails(services: DeployServices, abortSignal?: AbortSignal): Promise<Result<OrgDetailsForSynth>>;
13
+ /**
14
+ * Resolve org details for a SOLO-estate single-component deploy. Read-only — a
15
+ * standalone account deploy must NEVER create an organisation (the mutating
16
+ * resolveOrgDetails would CreateOrganization for an account not in an org, or
17
+ * fail on AccessDenied for a standalone role that cannot DescribeOrganization).
18
+ * Both outcomes resolve to `undefined`: the Account stack synthesises standalone
19
+ * without org details. Returns details only when the account genuinely has a
20
+ * describable, root-listable organisation.
21
+ */
22
+ export declare function resolveOrgDetailsForSolo(services: DeployServices, abortSignal?: AbortSignal): Promise<OrgDetailsForSynth | undefined>;
@@ -1 +1 @@
1
- import{success as a,failure as s}from"@fjall/generator";import{OrganizationsClient as u}from"@aws-sdk/client-organizations";import{CdkContextBuilder as f}from"../../services/supporting/CdkContextBuilder.js";import{stubCallerIdentity as m}from"../../types/deployment/index.js";import{ensureOrganisationExists as l}from"../../aws/organisations/organisation.js";import{buildParamsContext as I,resolveAccountBootstrapSkipOidc as C}from"../contextHelpers.js";function A(t,n,r,o,i,g,d,c){const e=n.awsProvider.getRegion();return f.buildDeploymentContext({deployType:o,target:r.target,path:r.path,region:e,accountName:g,callerIdentity:m(n.awsProvider.getAccountId()),orgId:i.orgId,rootId:i.rootId,managementAccountId:i.managementAccountId,...I({orgConfig:t.orgConfig,identity:t.identity,skipOidc:C(o,t.orgConfig,t.options?.skipOidc,c),...o!=="organisation"?{region:e,primaryRegion:t.orgConfig?.primaryRegion}:{},trailLifecycle:d})},{verbose:t.options?.verbose,infraOnly:t.options?.infraOnly},t.orgConfig)}async function k(t,n){const r=t.awsProvider.getClient(u),o=await l(r,n);return o.success?a({orgId:o.data.orgId,rootId:o.data.rootId,managementAccountId:o.data.managementAccountId}):s(o.error)}export{A as buildOrgContext,k as resolveOrgDetails};
1
+ import{success as s,failure as u}from"@fjall/generator";import{OrganizationsClient as a}from"@aws-sdk/client-organizations";import{CdkContextBuilder as l}from"../../services/supporting/CdkContextBuilder.js";import{stubCallerIdentity as m}from"../../types/deployment/index.js";import{ensureOrganisationExists as I,describeOrganisation as f}from"../../aws/organisations/organisation.js";import{buildParamsContext as C,resolveAccountBootstrapSkipOidc as O}from"../contextHelpers.js";function w(o,n,r,t,e,d,c,g){const i=n.awsProvider.getRegion();return l.buildDeploymentContext({deployType:t,target:r.target,path:r.path,region:i,accountName:d,callerIdentity:m(n.awsProvider.getAccountId()),orgId:e?.orgId,rootId:e?.rootId,managementAccountId:e?.managementAccountId,...C({orgConfig:o.orgConfig,identity:o.identity,skipOidc:O(t,o.orgConfig,o.options?.skipOidc,g),...t!=="organisation"?{region:i,primaryRegion:o.orgConfig?.primaryRegion}:{},trailLifecycle:c})},{verbose:o.options?.verbose,infraOnly:o.options?.infraOnly},o.orgConfig)}async function P(o,n){const r=o.awsProvider.getClient(a),t=await I(r,n);return t.success?s({orgId:t.data.orgId,rootId:t.data.rootId,managementAccountId:t.data.managementAccountId}):u(t.error)}async function k(o,n){const r=o.awsProvider.getClient(a),t=await f(r,n,{tolerateAccessDenied:!0});if(!(!t.success||t.data===null))return{orgId:t.data.orgId,rootId:t.data.rootId,managementAccountId:t.data.managementAccountId}}export{w as buildOrgContext,P as resolveOrgDetails,k as resolveOrgDetailsForSolo};
@@ -1 +1 @@
1
- import{success as N}from"@fjall/generator";import{BackupClient as w}from"@aws-sdk/client-backup";import{IAMClient as D}from"@aws-sdk/client-iam";import{getOrganisationStackName as I}from"../../types/operations.js";import{accountHasDisasterRecovery as k,describeBackupVaultExists as y}from"../../aws/organisations/backup.js";import{describeAccountGlobalsExist as L,buildMissingAccountGlobalsAdvisory as T}from"../../aws/organisations/accountGlobals.js";import{targetsNonPrimaryRegion as h,synthOrFail as M,bootstrapOrFail as v,forwardOutput as F,forwardResourceProgress as G}from"../contextHelpers.js";import{accountTier as S}from"@fjall/util";import{buildOrgContext as Y,resolveOrgDetails as x}from"./orgContext.js";import{INFRA_STEPS as o,INFRA_STEP_TOTAL as i,failPrepareStep as c,maskAndFail as d,readStackOutputsBestEffort as B}from"./infraSteps.js";async function X(e,r,a,s,E){const{callbacks:t}=e;t.onStepComplete?.(o.CONNECT.id,o.CONNECT.name,"completed",0,i),t.onStepStart?.(o.PREPARE.id,o.PREPARE.name,1,i);const R=r.awsProvider.getRegion(),l=e.orgConfig?.primaryRegion;if(l!==void 0&&h(R,l)){const n=await L(r.awsProvider.getClient(D),e.abortSignal);if(!n.success)return c(t),d(t,n.error.message);if(!n.data)return c(t),d(t,T({target:a.target,deployRegion:R,primaryRegion:l,...e.orgConfig!==void 0?{providerAccounts:e.orgConfig.providerAccounts}:{}}))}const f=await x(r,e.abortSignal);if(!f.success)return c(t),d(t,f.error.message);const u=s==="account"?e.orgConfig?.providerAccounts.find(n=>n.name===a.target):e.orgConfig?.providerAccounts.find(n=>S(n)==="platform"),A=s==="account"&&u!==void 0&&S(u)==="organisation",g=Y(e,r,a,s,f.data,s==="account"?a.target:void 0,u?.trailLifecycle,A),m=e.orgConfig?.disasterRecoveryRegion;if(s==="account"&&(m!==void 0&&m!=="")&&(u===void 0||k(u.environment,m))){const n=await y(r.awsProvider.getClient(w),e.abortSignal);if(!n.success)return c(t),d(t,n.error.message);g.fjallAdoptBackupVault=n.data}t.onLog?.(`Synthesising ${s} infrastructure\u2026`,"info");const p=await M(r,g,t,"CDK synthesis failed");if(!p.success)return c(t),p;const P=await v(r,g,t);if(!P.success)return c(t),P;t.onStepComplete?.(o.PREPARE.id,o.PREPARE.name,"completed",1,i);const C=I(a.type);t.onStepStart?.(o.DEPLOY.id,o.DEPLOY.name,2,i);const O=await r.cdkService.runCdkDeploy(g,C,F(t),G(t),r.awsProvider);if(!O.success)return t.onStepComplete?.(o.DEPLOY.id,o.DEPLOY.name,"error",2,i),d(t,O.error);t.onStepComplete?.(o.DEPLOY.id,o.DEPLOY.name,"completed",2,i);const b=await B(r,t,C,"Failed to read stack outputs (non-critical)");return t.onStepStart?.(o.MONITORING.id,o.MONITORING.name,3,i),t.onStepComplete?.(o.MONITORING.id,o.MONITORING.name,"completed",3,i),N({target:a.target,deploymentType:"organisation",outputs:b,durationMs:Date.now()-E})}export{X as deploySingleComponent};
1
+ import{success as N}from"@fjall/generator";import{BackupClient as w}from"@aws-sdk/client-backup";import{IAMClient as D}from"@aws-sdk/client-iam";import{getOrganisationStackName as I}from"../../types/operations.js";import{accountHasDisasterRecovery as k,describeBackupVaultExists as y}from"../../aws/organisations/backup.js";import{describeAccountGlobalsExist as L,buildMissingAccountGlobalsAdvisory as T}from"../../aws/organisations/accountGlobals.js";import{targetsNonPrimaryRegion as h,synthOrFail as v,bootstrapOrFail as F,forwardOutput as M,forwardResourceProgress as G}from"../contextHelpers.js";import{accountTier as S}from"@fjall/util";import{buildOrgContext as Y,resolveOrgDetails as x,resolveOrgDetailsForSolo as B}from"./orgContext.js";import{hasOrganisationTierAccount as V}from"../../aws/organisations/accountGlobals.js";import{INFRA_STEPS as o,INFRA_STEP_TOTAL as i,failPrepareStep as c,maskAndFail as d,readStackOutputsBestEffort as _}from"./infraSteps.js";async function ot(n,r,a,s,E){const{callbacks:t}=n;t.onStepComplete?.(o.CONNECT.id,o.CONNECT.name,"completed",0,i),t.onStepStart?.(o.PREPARE.id,o.PREPARE.name,1,i);const R=r.awsProvider.getRegion(),l=n.orgConfig?.primaryRegion;if(l!==void 0&&h(R,l)){const e=await L(r.awsProvider.getClient(D),n.abortSignal);if(!e.success)return c(t),d(t,e.error.message);if(!e.data)return c(t),d(t,T({target:a.target,deployRegion:R,primaryRegion:l,...n.orgConfig!==void 0?{providerAccounts:n.orgConfig.providerAccounts}:{}}))}let f;if(V(n.orgConfig?.providerAccounts)){const e=await x(r,n.abortSignal);if(!e.success)return c(t),d(t,e.error.message);f=e.data}else f=await B(r,n.abortSignal);const u=s==="account"?n.orgConfig?.providerAccounts.find(e=>e.name===a.target):n.orgConfig?.providerAccounts.find(e=>S(e)==="platform"),A=s==="account"&&u!==void 0&&S(u)==="organisation",g=Y(n,r,a,s,f,s==="account"?a.target:void 0,u?.trailLifecycle,A),m=n.orgConfig?.disasterRecoveryRegion;if(s==="account"&&(m!==void 0&&m!=="")&&(u===void 0||k(u.environment,m))){const e=await y(r.awsProvider.getClient(w),n.abortSignal);if(!e.success)return c(t),d(t,e.error.message);g.fjallAdoptBackupVault=e.data}t.onLog?.(`Synthesising ${s} infrastructure\u2026`,"info");const p=await v(r,g,t,"CDK synthesis failed");if(!p.success)return c(t),p;const O=await F(r,g,t);if(!O.success)return c(t),O;t.onStepComplete?.(o.PREPARE.id,o.PREPARE.name,"completed",1,i);const P=I(a.type);t.onStepStart?.(o.DEPLOY.id,o.DEPLOY.name,2,i);const C=await r.cdkService.runCdkDeploy(g,P,M(t),G(t),r.awsProvider);if(!C.success)return t.onStepComplete?.(o.DEPLOY.id,o.DEPLOY.name,"error",2,i),d(t,C.error);t.onStepComplete?.(o.DEPLOY.id,o.DEPLOY.name,"completed",2,i);const b=await _(r,t,P,"Failed to read stack outputs (non-critical)");return t.onStepStart?.(o.MONITORING.id,o.MONITORING.name,3,i),t.onStepComplete?.(o.MONITORING.id,o.MONITORING.name,"completed",3,i),N({target:a.target,deploymentType:"organisation",outputs:b,durationMs:Date.now()-E})}export{ot as deploySingleComponent};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/deploy-core",
3
- "version": "2.19.6",
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.19.6",
82
- "@fjall/util": "^2.19.6",
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": "f41aae2245b7e82af7e8e7f5cf098b83b22cc198"
92
+ "gitHead": "397880361ea533450ea5c888f7e7b9593254f663"
93
93
  }