@fjall/deploy-core 2.23.1 → 2.25.0

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.
Files changed (54) hide show
  1. package/dist/.minified +1 -1
  2. package/dist/src/aws/index.d.ts +2 -2
  3. package/dist/src/aws/index.js +1 -1
  4. package/dist/src/aws/organisations/identityCentreSnapshot.d.ts +47 -0
  5. package/dist/src/aws/organisations/identityCentreSnapshot.js +1 -0
  6. package/dist/src/aws/organisations/index.d.ts +5 -1
  7. package/dist/src/aws/organisations/index.js +1 -1
  8. package/dist/src/aws/organisations/pagination.d.ts +28 -0
  9. package/dist/src/aws/organisations/pagination.js +1 -0
  10. package/dist/src/index.d.ts +9 -5
  11. package/dist/src/index.js +1 -1
  12. package/dist/src/orchestration/application/applicationDeploy.js +1 -1
  13. package/dist/src/orchestration/application/applicationDeployHelpers.d.ts +11 -12
  14. package/dist/src/orchestration/application/applicationDeployHelpers.js +2 -2
  15. package/dist/src/orchestration/application/approvalGate.d.ts +3 -1
  16. package/dist/src/orchestration/application/approvalGate.js +1 -1
  17. package/dist/src/orchestration/application/artefactCollection.d.ts +18 -0
  18. package/dist/src/orchestration/application/artefactCollection.js +1 -0
  19. package/dist/src/orchestration/application/buildSecretSession.d.ts +8 -0
  20. package/dist/src/orchestration/application/buildSecretSession.js +1 -1
  21. package/dist/src/orchestration/application/codeOnlyDeploy.d.ts +13 -0
  22. package/dist/src/orchestration/application/codeOnlyDeploy.js +1 -1
  23. package/dist/src/orchestration/application/deploySessionPolicy.d.ts +46 -0
  24. package/dist/src/orchestration/application/deploySessionPolicy.js +1 -0
  25. package/dist/src/orchestration/application/dockerBuildHelper.d.ts +21 -5
  26. package/dist/src/orchestration/application/dockerBuildHelper.js +1 -1
  27. package/dist/src/orchestration/application/serviceImageTagsRollback.d.ts +56 -0
  28. package/dist/src/orchestration/application/serviceImageTagsRollback.js +1 -0
  29. package/dist/src/orchestration/application/taskDefinitionRoll.d.ts +33 -0
  30. package/dist/src/orchestration/application/taskDefinitionRoll.js +1 -0
  31. package/dist/src/orchestration/deploy.js +1 -1
  32. package/dist/src/orchestration/dockerInterface.d.ts +5 -2
  33. package/dist/src/orchestration/index.d.ts +5 -0
  34. package/dist/src/orchestration/index.js +1 -1
  35. package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.js +1 -1
  36. package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.js +1 -1
  37. package/dist/src/orchestration/restart/restartApplication.d.ts +69 -0
  38. package/dist/src/orchestration/restart/restartApplication.js +1 -0
  39. package/dist/src/orchestration/restart/secretsDrift.d.ts +54 -0
  40. package/dist/src/orchestration/restart/secretsDrift.js +1 -0
  41. package/dist/src/services/infrastructure/CdkCommandRunner.js +2 -2
  42. package/dist/src/services/infrastructure/CdkService.d.ts +1 -1
  43. package/dist/src/services/infrastructure/CdkService.js +2 -2
  44. package/dist/src/services/infrastructure/CdkServiceTypes.d.ts +9 -0
  45. package/dist/src/services/infrastructure/CloudFormationService.d.ts +17 -0
  46. package/dist/src/services/infrastructure/CloudFormationService.js +1 -1
  47. package/dist/src/services/infrastructure/EcrImageInspectorService.d.ts +11 -1
  48. package/dist/src/services/infrastructure/EcrImageInspectorService.js +1 -1
  49. package/dist/src/services/infrastructure/SsmParameterMetadataService.d.ts +23 -0
  50. package/dist/src/services/infrastructure/SsmParameterMetadataService.js +1 -0
  51. package/dist/src/types/approval.d.ts +15 -0
  52. package/dist/src/types/index.d.ts +1 -1
  53. package/dist/src/types/params.d.ts +39 -1
  54. package/package.json +5 -4
@@ -41,6 +41,15 @@ export interface CdkOptions {
41
41
  */
42
42
  parameters?: Record<string, string>;
43
43
  timeout?: number;
44
+ /**
45
+ * Name of a pre-existing customer-managed IAM policy passed to
46
+ * `cdk bootstrap --custom-permissions-boundary <name>` — bounds the CDK
47
+ * `cfn-exec-role` (the second admin surface). CDK LOOKS THIS UP BY NAME; the
48
+ * policy MUST already exist in the target account (created by the
49
+ * oidc-connector stack — Phase E2), so the caller only sets it once the
50
+ * boundary is known to exist. Absent/empty ⇒ today's behaviour (no boundary).
51
+ */
52
+ permissionsBoundary?: string;
44
53
  passThroughCDK?: boolean;
45
54
  stacks?: string[];
46
55
  noLookups?: boolean;
@@ -93,4 +93,21 @@ export declare class CloudFormationService {
93
93
  pollIntervalMs?: number;
94
94
  onProgress?: (message: string) => void;
95
95
  }): Promise<Result<void, CloudFormationError>>;
96
+ /**
97
+ * Converge a subset of a stack's parameters to new values, preserving every
98
+ * other parameter via `UsePreviousValue` (F4b — post-rollback CFN ImageTag
99
+ * reconcile). Keys in `parameterOverrides` absent from the stack's current
100
+ * parameters are reported as `skippedKeys` rather than failing the call —
101
+ * a stale key (a service the stack no longer has) should not block
102
+ * reconciling the keys that do exist.
103
+ */
104
+ updateStackParameters(stackName: string, parameterOverrides: Record<string, string>, abortSignal?: AbortSignal): Promise<Result<{
105
+ updated: boolean;
106
+ skippedKeys: string[];
107
+ }, CloudFormationError>>;
108
+ /**
109
+ * Poll stack status until the parameter-only update completes, fails, or
110
+ * times out.
111
+ */
112
+ private waitForUpdateStackParametersComplete;
96
113
  }
@@ -1 +1 @@
1
- import{CloudFormationClient as d,DeleteStackCommand as h,DescribeStacksCommand as m,GetTemplateCommand as k,ListExportsCommand as y}from"@aws-sdk/client-cloudformation";import{stackStatusMap as S}from"../../aws/utils/stackStatus.js";import{maskSensitiveOutput as f}from"@fjall/util";import{success as i,failure as c}from"@fjall/generator";import{BaseServiceError as T}from"../../types/errors/ServiceError.js";import{logger as x}from"@fjall/util/logger";import{getErrorMessage as p,sleep as E}from"@fjall/util";import{STACK_NOT_FOUND_PATTERN as w}from"@fjall/util/aws";import{isCleanableState as C}from"../../types/constants.js";import{extractErrorName as O}from"../../aws/organisations/types.js";class l extends T{errorType;stackName;stackStatus;constructor(t,e,s,n,r,o=!1){super(`CFN_${e.toUpperCase()}`,t,r,o),this.errorType=e,this.stackName=s,this.stackStatus=n}}class I{aws;constructor(t){this.aws=t}classifyAwsError(t,e,s){const n=O(t),r=f(p(t));return n==="CredentialsError"||n==="UnauthorizedError"?new l(`AWS credentials error: ${r}`,"auth_error",s,void 0,t,!1):n==="Throttling"||n==="TooManyRequestsException"?new l(`AWS rate limit exceeded: ${r}`,"throttled",s,void 0,t,!0):n==="NetworkingError"||n==="ENOTFOUND"?new l(`Network error: ${r}`,"network_error",s,void 0,t,!0):new l(`${e}: ${r}`,"unknown",s,void 0,t)}async getStackOutputs(t,e){e?.onStackCheck?.(t);const s=this.aws.getClient(d),n=new m({StackName:t});try{const o=(await s.send(n)).Stacks?.[0];if(!o?.Outputs)return i([]);const a=o.Outputs.map(u=>({OutputKey:u.OutputKey,OutputValue:u.OutputValue,ExportName:u.ExportName}));return e?.onOutputsRetrieved?.(t,a.length),i(a)}catch(r){if(r instanceof Error&&r.name==="ValidationError"&&r.message?.includes(w))return e?.onStackNotFound?.(t),i([]);const o=f(p(r));return c(new l(`Failed to get outputs for stack ${t}: ${o}`,"unknown",t,void 0,r))}}async getStackStatus(t,e){e?.onStackCheck?.(t);const s=this.aws.getClient(d),n=new m({StackName:t});try{const o=(await s.send(n)).Stacks?.[0];if(!o)return i({status:"DOES_NOT_EXIST",safeToRedeploy:"Yes",description:"Stack does not exist yet"});const a=o.StackStatus||"UNKNOWN",u=S[a]||S.UNKNOWN;return e?.onStackFound?.(t,a),i({status:a,safeToRedeploy:u.safeToRedeploy,description:u.description,statusReason:o.StackStatusReason})}catch(r){return r instanceof Error&&r.name==="ValidationError"&&r.message?.includes(w)?i({status:"DOES_NOT_EXIST",safeToRedeploy:"Yes",description:"Stack does not exist yet"}):c(this.classifyAwsError(r,`Failed to get stack status for ${t}`,t))}}async listAllExports(t){const e=this.aws.getClient(d),s=[];try{let n;do{const r=new y({NextToken:n}),o=await e.send(r),a=o.Exports||[];for(const u of a)u.Name&&u.Value&&s.push({Name:u.Name,Value:u.Value});if(t?.(a))break;n=o.NextToken}while(n);return i(s)}catch(n){const r=f(p(n));return c(new l(`Failed to list exports: ${r}`,"unknown",void 0,void 0,n,!1))}}async getExportsByNames(t){if(t.length===0)return i(new Map);const e=new Set(t),s=new Map,n=await this.listAllExports(r=>{for(const o of r)o.Name&&e.has(o.Name)&&o.Value&&s.set(o.Name,o.Value);return s.size>=e.size});return n.success?i(s):c(n.error)}async listExports(t){const e=await this.listAllExports();return e.success&&t?.onExportsRetrieved?.(e.data.length),e}async deleteStack(t){const e=this.aws.getClient(d);try{return await e.send(new h({StackName:t})),i(void 0)}catch(s){return c(this.classifyAwsError(s,`Failed to delete stack ${t}`,t))}}async stackExists(t,e){const s=e??this.aws.getClient(d);try{const r=(await s.send(new m({StackName:t}))).Stacks?.[0]?.StackStatus;return!!r&&r!=="REVIEW_IN_PROGRESS"&&!C(r)}catch(n){return n instanceof Error&&n.message?.includes(w)?!1:(x.debug("CloudFormationService","Error checking stack existence, assuming exists",{stackName:t,error:p(n)}),!0)}}async getTemplate(t){const e=this.aws.getClient(d);try{const s=await e.send(new k({StackName:t,TemplateStage:"Original"}));return i(s.TemplateBody??"")}catch(s){return s instanceof Error&&s.message?.includes(w)?c(new l(`Stack ${t} not found`,"stack_not_found",t)):c(this.classifyAwsError(s,`Failed to get template for stack ${t}`,t))}}async waitForDeleteComplete(t,e){const s=e?.timeoutMs??6e5,n=e?.pollIntervalMs??5e3,r=Date.now();for(;Date.now()-r<s;){const o=await this.getStackStatus(t);if(!o.success){if(o.error.recoverable){e?.onProgress?.(`Transient error polling stack ${t}, retrying: ${f(o.error.message)}`),await E(n);continue}return c(o.error)}const a=o.data?.status;if(a==="DELETE_COMPLETE"||a==="DOES_NOT_EXIST")return i(void 0);if(a==="DELETE_FAILED")return c(new l(`Stack ${t} deletion failed: ${o.data?.statusReason||"unknown reason"}`,"stack_failed",t,a,void 0,!1));e?.onProgress?.(`Stack ${t} status: ${a??"unknown"}`),await E(n)}return c(new l(`Timed out waiting for stack ${t} deletion after ${Math.round(s/1e3)}s`,"timeout",t,void 0,void 0,!0))}}export{l as CloudFormationError,I as CloudFormationService};
1
+ import{CloudFormationClient as f,DeleteStackCommand as _,DescribeStacksCommand as m,GetTemplateCommand as A,ListExportsCommand as O,UpdateStackCommand as P}from"@aws-sdk/client-cloudformation";import{stackStatusMap as T}from"../../aws/utils/stackStatus.js";import{maskSensitiveOutput as w}from"@fjall/util";import{success as d,failure as c}from"@fjall/generator";import{BaseServiceError as $}from"../../types/errors/ServiceError.js";import{logger as M}from"@fjall/util/logger";import{getErrorMessage as S,sleep as E}from"@fjall/util";import{STACK_NOT_FOUND_PATTERN as g}from"@fjall/util/aws";import{isCleanableState as F}from"../../types/constants.js";import{composeSdkAbortSignal as C,extractErrorName as R,isAborted as D}from"../../aws/organisations/types.js";class l extends ${errorType;stackName;stackStatus;constructor(e,r,t,n,s,a=!1){super(`CFN_${r.toUpperCase()}`,e,s,a),this.errorType=r,this.stackName=t,this.stackStatus=n}}class Y{aws;constructor(e){this.aws=e}classifyAwsError(e,r,t){const n=R(e),s=w(S(e));return n==="CredentialsError"||n==="UnauthorizedError"?new l(`AWS credentials error: ${s}`,"auth_error",t,void 0,e,!1):n==="Throttling"||n==="TooManyRequestsException"?new l(`AWS rate limit exceeded: ${s}`,"throttled",t,void 0,e,!0):n==="NetworkingError"||n==="ENOTFOUND"?new l(`Network error: ${s}`,"network_error",t,void 0,e,!0):new l(`${r}: ${s}`,"unknown",t,void 0,e)}async getStackOutputs(e,r){r?.onStackCheck?.(e);const t=this.aws.getClient(f),n=new m({StackName:e});try{const a=(await t.send(n)).Stacks?.[0];if(!a?.Outputs)return d([]);const o=a.Outputs.map(u=>({OutputKey:u.OutputKey,OutputValue:u.OutputValue,ExportName:u.ExportName}));return r?.onOutputsRetrieved?.(e,o.length),d(o)}catch(s){if(s instanceof Error&&s.name==="ValidationError"&&s.message?.includes(g))return r?.onStackNotFound?.(e),d([]);const a=w(S(s));return c(new l(`Failed to get outputs for stack ${e}: ${a}`,"unknown",e,void 0,s))}}async getStackStatus(e,r){r?.onStackCheck?.(e);const t=this.aws.getClient(f),n=new m({StackName:e});try{const a=(await t.send(n)).Stacks?.[0];if(!a)return d({status:"DOES_NOT_EXIST",safeToRedeploy:"Yes",description:"Stack does not exist yet"});const o=a.StackStatus||"UNKNOWN",u=T[o]||T.UNKNOWN;return r?.onStackFound?.(e,o),d({status:o,safeToRedeploy:u.safeToRedeploy,description:u.description,statusReason:a.StackStatusReason})}catch(s){return s instanceof Error&&s.name==="ValidationError"&&s.message?.includes(g)?d({status:"DOES_NOT_EXIST",safeToRedeploy:"Yes",description:"Stack does not exist yet"}):c(this.classifyAwsError(s,`Failed to get stack status for ${e}`,e))}}async listAllExports(e){const r=this.aws.getClient(f),t=[];try{let n;do{const s=new O({NextToken:n}),a=await r.send(s),o=a.Exports||[];for(const u of o)u.Name&&u.Value&&t.push({Name:u.Name,Value:u.Value});if(e?.(o))break;n=a.NextToken}while(n);return d(t)}catch(n){const s=w(S(n));return c(new l(`Failed to list exports: ${s}`,"unknown",void 0,void 0,n,!1))}}async getExportsByNames(e){if(e.length===0)return d(new Map);const r=new Set(e),t=new Map,n=await this.listAllExports(s=>{for(const a of s)a.Name&&r.has(a.Name)&&a.Value&&t.set(a.Name,a.Value);return t.size>=r.size});return n.success?d(t):c(n.error)}async listExports(e){const r=await this.listAllExports();return r.success&&e?.onExportsRetrieved?.(r.data.length),r}async deleteStack(e){const r=this.aws.getClient(f);try{return await r.send(new _({StackName:e})),d(void 0)}catch(t){return c(this.classifyAwsError(t,`Failed to delete stack ${e}`,e))}}async stackExists(e,r){const t=r??this.aws.getClient(f);try{const s=(await t.send(new m({StackName:e}))).Stacks?.[0]?.StackStatus;return!!s&&s!=="REVIEW_IN_PROGRESS"&&!F(s)}catch(n){return n instanceof Error&&n.message?.includes(g)?!1:(M.debug("CloudFormationService","Error checking stack existence, assuming exists",{stackName:e,error:S(n)}),!0)}}async getTemplate(e){const r=this.aws.getClient(f);try{const t=await r.send(new A({StackName:e,TemplateStage:"Original"}));return d(t.TemplateBody??"")}catch(t){return t instanceof Error&&t.message?.includes(g)?c(new l(`Stack ${e} not found`,"stack_not_found",e)):c(this.classifyAwsError(t,`Failed to get template for stack ${e}`,e))}}async waitForDeleteComplete(e,r){const t=r?.timeoutMs??6e5,n=r?.pollIntervalMs??5e3,s=Date.now();for(;Date.now()-s<t;){const a=await this.getStackStatus(e);if(!a.success){if(a.error.recoverable){r?.onProgress?.(`Transient error polling stack ${e}, retrying: ${w(a.error.message)}`),await E(n);continue}return c(a.error)}const o=a.data?.status;if(o==="DELETE_COMPLETE"||o==="DOES_NOT_EXIST")return d(void 0);if(o==="DELETE_FAILED")return c(new l(`Stack ${e} deletion failed: ${a.data?.statusReason||"unknown reason"}`,"stack_failed",e,o,void 0,!1));r?.onProgress?.(`Stack ${e} status: ${o??"unknown"}`),await E(n)}return c(new l(`Timed out waiting for stack ${e} deletion after ${Math.round(t/1e3)}s`,"timeout",e,void 0,void 0,!0))}async updateStackParameters(e,r,t){const n=this.aws.getClient(f);let s;try{s=await n.send(new m({StackName:e}),{abortSignal:C(t)})}catch(i){return c(this.classifyAwsError(i,`Failed to describe stack ${e} before parameter reconcile`,e))}const a=s.Stacks?.[0];if(!a)return c(new l(`Stack ${e} not found`,"stack_not_found",e));const o=a.Parameters??[],u=new Set(o.map(i=>i.ParameterKey).filter(i=>i!==void 0)),k=Object.entries(r),h=k.filter(([i])=>!u.has(i)).map(([i])=>i),y=new Map(k.filter(([i])=>u.has(i)));if(y.size===0)return d({updated:!1,skippedKeys:h});const x=o.filter(i=>i.ParameterKey!==void 0).map(i=>y.has(i.ParameterKey)?{ParameterKey:i.ParameterKey,ParameterValue:y.get(i.ParameterKey)}:{ParameterKey:i.ParameterKey,UsePreviousValue:!0});try{await n.send(new P({StackName:e,UsePreviousTemplate:!0,Parameters:x,Capabilities:["CAPABILITY_IAM","CAPABILITY_NAMED_IAM"]}),{abortSignal:C(t)})}catch(i){return i instanceof Error&&i.name==="ValidationError"&&i.message?.includes("No updates are to be performed")?d({updated:!1,skippedKeys:h}):c(this.classifyAwsError(i,`Failed to update parameters for stack ${e}`,e))}return this.waitForUpdateStackParametersComplete(e,h,t)}async waitForUpdateStackParametersComplete(e,r,t){const a=Date.now();for(;Date.now()-a<6e5;){if(D(t))return c(new l(`Aborted waiting for stack ${e} parameter update`,"timeout",e,void 0,void 0,!0));const o=await this.getStackStatus(e);if(!o.success){if(o.error.recoverable){await E(5e3);continue}return c(o.error)}const u=o.data?.status??"UNKNOWN";if(u==="UPDATE_COMPLETE")return d({updated:!0,skippedKeys:r});if(I(u))return c(new l(`Stack ${e} parameter update failed: ${o.data?.statusReason||"unknown reason"}`,"stack_failed",e,u,void 0,!1));await E(5e3)}return c(new l(`Timed out waiting for stack ${e} parameter update after ${Math.round(6e5/1e3)}s`,"timeout",e,void 0,void 0,!0))}}function I(p){return p.endsWith("_IN_PROGRESS")?!1:p.startsWith("UPDATE_ROLLBACK_")||p==="UPDATE_FAILED"}export{l as CloudFormationError,Y as CloudFormationService};
@@ -28,5 +28,15 @@ export declare class EcrImageInspectorService {
28
28
  * third-party registries). Returns a failure only when the ECR call
29
29
  * itself errors or the tag exists but ECR returned no digest.
30
30
  */
31
- getImageDigest(imageUri: string, imageTag: string): Promise<Result<string | undefined, Error>>;
31
+ getImageDigest(imageUri: string, imageTag: string, abortSignal?: AbortSignal): Promise<Result<string | undefined, Error>>;
32
+ /**
33
+ * Reverse lookup: the tags attached to an image digest. Needed by the
34
+ * restart operation — after a digest-pinned rollback the live container
35
+ * image reference carries no tag, but the restart Release's artefacts must
36
+ * record one for later `serviceImageTags` rollbacks to consume.
37
+ *
38
+ * Returns `success(undefined)` for non-ECR URIs (caller degrades),
39
+ * `success([])` when the image exists but is untagged.
40
+ */
41
+ getImageTagsByDigest(imageUri: string, imageDigest: string, abortSignal?: AbortSignal): Promise<Result<string[] | undefined, Error>>;
32
42
  }
@@ -1 +1 @@
1
- import{ECRClient as m,BatchGetImageCommand as p}from"@aws-sdk/client-ecr";import{success as n,failure as s}from"@fjall/generator";import{logger as g}from"@fjall/util/logger";import{getErrorMessage as I}from"@fjall/util";const E="EcrImageInspector",C=/^(\d{12})\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com\/(.+)$/;function R(t){const e=t.lastIndexOf("@");if(e>0)return t.slice(0,e);const r=t.lastIndexOf(":");return r>0?t.slice(0,r):t}class G{awsProvider;constructor(e){this.awsProvider=e}async getImageDigest(e,r){const c=R(e).match(C);if(!c)return g.debug(E,"Image URI is not ECR \u2014 skipping digest lookup",{imageUri:e}),n(void 0);const[,,,o]=c;if(!o)return n(void 0);try{const a=await this.awsProvider.getClient(m).send(new p({repositoryName:o,imageIds:[{imageTag:r}]})),d=a.failures??[];if(d.length>0){const l=d.map(f=>`${f.failureCode??"unknown"}:${f.failureReason??""}`).join("; ");return s(new Error(`ECR BatchGetImage rejected ${o}:${r} \u2014 ${l}`))}const u=a.images?.[0]?.imageId?.imageDigest;return u?n(u):s(new Error(`ECR BatchGetImage returned no digest for ${o}:${r}`))}catch(i){return s(new Error(`ECR digest lookup failed for ${o}:${r} \u2014 ${I(i)}`))}}}export{G as EcrImageInspectorService,R as stripTagOrDigest};
1
+ import{ECRClient as m,BatchGetImageCommand as h,DescribeImagesCommand as k}from"@aws-sdk/client-ecr";import{success as o,failure as c}from"@fjall/generator";import{logger as f}from"@fjall/util/logger";import{getErrorMessage as p}from"@fjall/util";import{composeSdkAbortSignal as I}from"../../aws/organisations/types.js";const E="EcrImageInspector",C=/^(\d{12})\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com\/(.+)$/;function $(n){const r=n.lastIndexOf("@");if(r>0)return n.slice(0,r);const e=n.lastIndexOf(":");return e>0?n.slice(0,e):n}class G{awsProvider;constructor(r){this.awsProvider=r}async getImageDigest(r,e,d){const s=$(r).match(C);if(!s)return f.debug(E,"Image URI is not ECR \u2014 skipping digest lookup",{imageUri:r}),o(void 0);const[,,,t]=s;if(!t)return o(void 0);try{const u=await this.awsProvider.getClient(m).send(new h({repositoryName:t,imageIds:[{imageTag:e}]}),{abortSignal:I(d)}),a=u.failures??[];if(a.length>0){const R=a.map(l=>`${l.failureCode??"unknown"}:${l.failureReason??""}`).join("; ");return c(new Error(`ECR BatchGetImage rejected ${t}:${e} \u2014 ${R}`))}const g=u.images?.[0]?.imageId?.imageDigest;return g?o(g):c(new Error(`ECR BatchGetImage returned no digest for ${t}:${e}`))}catch(i){return c(new Error(`ECR digest lookup failed for ${t}:${e} \u2014 ${p(i)}`))}}async getImageTagsByDigest(r,e,d){const s=$(r).match(C);if(!s)return f.debug(E,"Image URI is not ECR \u2014 skipping tag lookup by digest",{imageUri:r}),o(void 0);const[,,,t]=s;if(!t)return o(void 0);try{const a=(await this.awsProvider.getClient(m).send(new k({repositoryName:t,imageIds:[{imageDigest:e}]}),{abortSignal:I(d)})).imageDetails?.[0];return a?o(a.imageTags??[]):c(new Error(`ECR DescribeImages returned no image for ${t}@${e}`))}catch(i){return c(new Error(`ECR tag lookup failed for ${t}@${e} \u2014 ${p(i)}`))}}}export{G as EcrImageInspectorService,$ as stripTagOrDigest};
@@ -0,0 +1,23 @@
1
+ import { type Result } from "@fjall/generator";
2
+ import type { AwsProvider } from "../../aws/AwsProvider.js";
3
+ export interface SsmParameterMetadata {
4
+ name: string;
5
+ version?: number;
6
+ lastModifiedDate?: Date;
7
+ }
8
+ /**
9
+ * Metadata-only SSM reads for the secrets-drift probe. Deliberately uses
10
+ * DescribeParameters (never GetParameter/GetParameters): drift needs only
11
+ * `LastModifiedDate` + `Version`, and a value-bearing call would put secret
12
+ * contents in memory for no reason.
13
+ */
14
+ export declare class SsmParameterMetadataService {
15
+ private readonly awsProvider;
16
+ constructor(awsProvider: AwsProvider);
17
+ /**
18
+ * Describe parameters by exact name. Names absent from the result simply
19
+ * do not exist (or are not visible) — callers treat them as unknown, not
20
+ * as an error.
21
+ */
22
+ describeParametersByName(names: string[], abortSignal?: AbortSignal): Promise<Result<SsmParameterMetadata[], Error>>;
23
+ }
@@ -0,0 +1 @@
1
+ import{SSMClient as u,DescribeParametersCommand as l}from"@aws-sdk/client-ssm";import{success as m,failure as p}from"@fjall/generator";import{getErrorMessage as S,maskSensitiveOutput as P}from"@fjall/util";import{composeSdkAbortSignal as b,isAborted as c}from"../../aws/organisations/types.js";const o=50;class v{awsProvider;constructor(r){this.awsProvider=r}async describeParametersByName(r,i){if(r.length===0)return m([]);try{const a=this.awsProvider.getClient(u),n=[];for(let t=0;t<r.length&&!c(i);t+=o){const f=r.slice(t,t+o);let s;do{if(c(i))break;const d=await a.send(new l({ParameterFilters:[{Key:"Name",Option:"Equals",Values:f}],MaxResults:o,...s!==void 0?{NextToken:s}:{}}),{abortSignal:b(i)});for(const e of d.Parameters??[])e.Name!==void 0&&n.push({name:e.Name,...e.Version!==void 0?{version:Number(e.Version)}:{},...e.LastModifiedDate!==void 0?{lastModifiedDate:e.LastModifiedDate}:{}});s=d.NextToken}while(s!==void 0)}return m(n)}catch(a){return p(new Error(`SSM DescribeParameters failed: ${P(S(a))}`))}}}export{v as SsmParameterMetadataService};
@@ -10,6 +10,21 @@
10
10
  * backward-compatible: the engine deploys exactly as it does today.
11
11
  */
12
12
  import type { DeployPlan } from "../orchestration/application/plan/types.js";
13
+ /**
14
+ * Both detection signals behind a computed plan, distinguished so a parser can
15
+ * see WHY the gate stopped: hash drift (templates vs the last recorded deploy)
16
+ * is what engages the exit-2 outcome even when the resource-level diff against
17
+ * the live stacks is empty. Neither signal drives the exit code differently —
18
+ * this is observability, not control flow.
19
+ */
20
+ export interface ApprovalGatePlanDetail {
21
+ /** Stacks whose synthesised template hash differs from the recorded deploy state. */
22
+ hashDriftStacks: string[];
23
+ /** Count of resource-level changes diffed against the live stacks. */
24
+ resourceChangeCount: number;
25
+ /** Stacks with at least one resource-level change against the live stacks. */
26
+ resourceChangedStacks: string[];
27
+ }
13
28
  /** What the gate hands a surface resolver so it can decide. */
14
29
  export interface ApprovalRequest {
15
30
  /** Target app / deployment name. */
@@ -11,7 +11,7 @@ export type { ProgressEvent, ProgressEventType, ResourceEvent, AwsAuthResult, Ca
11
11
  export { TRAIL_MIGRATION_PHASES, TRAIL_MIGRATION_STATUSES } from "./events.js";
12
12
  export type { ApiClientInterface, EntitlementsData } from "./apiClient.js";
13
13
  export type { DeployParams, DeployOptions, DeploymentType, DeployResult, DestroyParams, DestroyOptions, DestroyResult } from "./params.js";
14
- export type { ApprovalGate, ApprovalRequest, GateResolution } from "./approval.js";
14
+ export type { ApprovalGate, ApprovalGatePlanDetail, ApprovalRequest, GateResolution } from "./approval.js";
15
15
  export type { OrgConfig, ProviderAccount, RootAccessManagementMode, SSOSession } from "./config/orgConfig.js";
16
16
  export type { Entitlements } from "./config/entitlements.js";
17
17
  export type { StackOutput, StackOutputsRecord, DeploymentContext, StepOutput, ApplicationDeploymentContext, CallerIdentity } from "./deployment/index.js";
@@ -1,11 +1,12 @@
1
1
  import type { Result } from "@fjall/generator";
2
+ import type { DeployMode, ServiceArtefact } from "@fjall/util";
2
3
  import type { AwsCredentials, DeployIdentity, ScopedBuildSecretSessionMinter } from "./credentials.js";
3
4
  import type { DeployCallbacks } from "./callbacks.js";
4
5
  import type { ApiClientInterface } from "./apiClient.js";
5
6
  import type { OrgConfig } from "./config/orgConfig.js";
6
7
  import type { DockerProvider } from "../orchestration/dockerInterface.js";
7
8
  import type { DomainDeployProvider } from "../orchestration/domainInterface.js";
8
- import type { ApprovalGate } from "./approval.js";
9
+ import type { ApprovalGate, ApprovalGatePlanDetail } from "./approval.js";
9
10
  import type { FailureAnalysis } from "@fjall/util/aws";
10
11
  import type { DockerBuildSecretRef } from "@fjall/util/manifest/schemas";
11
12
  export interface DeployParams {
@@ -77,6 +78,16 @@ export interface DeployParams {
77
78
  approvalGate?: ApprovalGate;
78
79
  }
79
80
  export interface DeployOptions {
81
+ /**
82
+ * Explicit deploy mode. When supplied it is authoritative — the engine
83
+ * branches on it alone. When omitted the engine derives it for legacy
84
+ * callers: `imageTag` present ⇒ `"rollback"`, else `deployOnly` ⇒
85
+ * `"code-only"`, else `"full"`. An explicit `"full"`/`"code-only"`
86
+ * combined with `imageTag` is rejected up-front (--image-tag requires
87
+ * rollback mode), making the redrive mode/imageTag mismatch (F9)
88
+ * unrepresentable.
89
+ */
90
+ mode?: DeployMode;
80
91
  skipConfirmation?: boolean;
81
92
  force?: boolean;
82
93
  cascade?: boolean;
@@ -102,6 +113,14 @@ export interface DeployOptions {
102
113
  * build + push and rolls ECS to `<repo>:<imageTag>`. Implies `deployOnly`.
103
114
  */
104
115
  imageTag?: string;
116
+ /**
117
+ * Exact per-service image tags for rollback mode (F12), keyed by the bare
118
+ * service name recorded in a Release's `ServiceArtefact[]`. Mutually
119
+ * exclusive with `imageTag` — each service rolls to its EXACT supplied tag,
120
+ * bypassing the `rebaseRollbackTagForService` prefix heuristic entirely.
121
+ * Implies `deployOnly`.
122
+ */
123
+ serviceImageTags?: Record<string, string>;
105
124
  /**
106
125
  * Approval-gate: require an explicit approve decision before mutating. The
107
126
  * engine only gates when a gate is engaged (resolver injected, `planOnly`, or
@@ -151,6 +170,17 @@ export interface DeployResult {
151
170
  deploymentType: DeploymentType;
152
171
  /** Stack outputs (website URL, OIDC role ARN, etc.) */
153
172
  outputs?: Record<string, string>;
173
+ /**
174
+ * Typed per-service image identity for every service this deploy rolled or
175
+ * pushed (`ServiceArtefactSchema` at `@fjall/util`). Populated on ALL
176
+ * application deploy modes — code-only and rollback from the explicit
177
+ * RegisterTaskDefinition rollout, full deploys from the build step plus a
178
+ * best-effort post-deploy ECS resolution (a resolution failure degrades
179
+ * `taskDefinitionArn` to absent, never fails the deploy). Empty for
180
+ * organisation deploys, cancelled/rejected/awaiting outcomes, and deploys
181
+ * that pushed no images.
182
+ */
183
+ artefacts: ServiceArtefact[];
154
184
  /** Per-account outputs from cascade deploys (accountId → stack outputs). */
155
185
  cascadeOutputs?: Array<{
156
186
  accountId: string;
@@ -208,6 +238,14 @@ export interface DeployResult {
208
238
  * plan.
209
239
  */
210
240
  assemblyDigest?: string;
241
+ /**
242
+ * Approval-gate: both detection signals behind the gate outcome (template-hash
243
+ * drift vs the recorded deploy state; resource-level diff vs the live stacks).
244
+ * Present whenever the gate computed a plan — always with `awaitingApproval`,
245
+ * and with `rejected` only when a resolver declined a computed plan (a refused
246
+ * resume token never computed one).
247
+ */
248
+ planDetail?: ApprovalGatePlanDetail;
211
249
  }
212
250
  /**
213
251
  * Parameters for the destroy entry point.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/deploy-core",
3
- "version": "2.23.1",
3
+ "version": "2.25.0",
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",
@@ -72,6 +72,7 @@
72
72
  "@aws-sdk/client-ec2": "^3.1038.0",
73
73
  "@aws-sdk/client-ecr": "^3.1039.0",
74
74
  "@aws-sdk/client-iam": "^3.1038.0",
75
+ "@aws-sdk/client-identitystore": "^3.1079.0",
75
76
  "@aws-sdk/client-kms": "^3.1065.0",
76
77
  "@aws-sdk/client-organizations": "^3.1038.0",
77
78
  "@aws-sdk/client-ram": "^3.1038.0",
@@ -81,8 +82,8 @@
81
82
  "@aws-sdk/client-ssm": "^3.1038.0",
82
83
  "@aws-sdk/client-sso-admin": "^3.1038.0",
83
84
  "@aws-sdk/client-sts": "^3.1038.0",
84
- "@fjall/generator": "^2.23.1",
85
- "@fjall/util": "^2.23.1",
85
+ "@fjall/generator": "^2.25.0",
86
+ "@fjall/util": "^2.25.0",
86
87
  "@smithy/node-http-handler": "^4.6.1",
87
88
  "aws-cdk": "^2.1128.1",
88
89
  "tsx": "^4.21.0",
@@ -93,5 +94,5 @@
93
94
  "typescript": "^6.0.3",
94
95
  "vitest": "^4.1.5"
95
96
  },
96
- "gitHead": "749133c6c5cfe24a09a401869f2193c7d177a324"
97
+ "gitHead": "7c1a329184064aefa557c2c09de0965c4f8cd4fb"
97
98
  }