@fjall/deploy-core 2.20.1 → 2.22.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.
- package/dist/.minified +1 -1
- package/dist/src/aws/AwsProvider.d.ts +9 -0
- package/dist/src/aws/index.d.ts +3 -1
- package/dist/src/aws/index.js +1 -1
- package/dist/src/aws/regions/regionOptStatus.d.ts +26 -0
- package/dist/src/aws/regions/regionOptStatus.js +1 -0
- package/dist/src/aws/targetReadiness.d.ts +9 -0
- package/dist/src/aws/targetReadiness.js +1 -1
- package/dist/src/aws/utils/cloudformationEventHelpers.js +1 -1
- package/dist/src/aws/utils/cloudformationEventTypes.d.ts +6 -0
- package/dist/src/aws/utils/cloudformationEvents.d.ts +1 -0
- package/dist/src/aws/utils/cloudformationEvents.js +1 -1
- package/dist/src/index.d.ts +11 -3
- package/dist/src/index.js +1 -1
- package/dist/src/orchestration/application/applicationDeploy.js +1 -1
- package/dist/src/orchestration/application/applicationDeployHelpers.js +2 -2
- package/dist/src/orchestration/application/approvalGate.d.ts +44 -0
- package/dist/src/orchestration/application/approvalGate.js +1 -0
- package/dist/src/orchestration/application/buildArgResolver.d.ts +72 -0
- package/dist/src/orchestration/application/buildArgResolver.js +1 -0
- package/dist/src/orchestration/application/buildGroupValidator.d.ts +64 -0
- package/dist/src/orchestration/application/buildGroupValidator.js +1 -0
- package/dist/src/orchestration/application/buildSecretResolver.d.ts +57 -0
- package/dist/src/orchestration/application/buildSecretResolver.js +1 -0
- package/dist/src/orchestration/application/buildSecretSession.d.ts +40 -0
- package/dist/src/orchestration/application/buildSecretSession.js +1 -0
- package/dist/src/orchestration/application/codeOnlyDeploy.js +1 -1
- package/dist/src/orchestration/application/dockerBuildHelper.js +1 -1
- package/dist/src/orchestration/application/plan/approvalToken.d.ts +40 -0
- package/dist/src/orchestration/application/plan/approvalToken.js +1 -0
- package/dist/src/orchestration/application/plan/assemblyDigest.d.ts +21 -0
- package/dist/src/orchestration/application/plan/assemblyDigest.js +2 -0
- package/dist/src/orchestration/application/plan/buildDeployPlan.d.ts +14 -0
- package/dist/src/orchestration/application/plan/buildDeployPlan.js +1 -0
- package/dist/src/orchestration/application/plan/classify.d.ts +37 -0
- package/dist/src/orchestration/application/plan/classify.js +1 -0
- package/dist/src/orchestration/application/plan/computeDeployPlan.d.ts +24 -0
- package/dist/src/orchestration/application/plan/computeDeployPlan.js +1 -0
- package/dist/src/orchestration/application/plan/index.d.ts +8 -0
- package/dist/src/orchestration/application/plan/index.js +1 -0
- package/dist/src/orchestration/application/plan/renderDeployPlan.d.ts +18 -0
- package/dist/src/orchestration/application/plan/renderDeployPlan.js +1 -0
- package/dist/src/orchestration/application/plan/types.d.ts +54 -0
- package/dist/src/orchestration/application/plan/types.js +0 -0
- package/dist/src/orchestration/deploy.js +1 -1
- package/dist/src/orchestration/destroy.js +1 -1
- package/dist/src/orchestration/dockerBuildParamsKeys.d.ts +2 -0
- package/dist/src/orchestration/dockerBuildParamsKeys.js +1 -0
- package/dist/src/orchestration/dockerInterface.d.ts +34 -0
- package/dist/src/orchestration/index.d.ts +8 -1
- package/dist/src/orchestration/index.js +1 -1
- package/dist/src/orchestration/serviceFactory.d.ts +7 -0
- package/dist/src/orchestration/serviceFactory.js +1 -1
- package/dist/src/orchestration/stepLifecycle.d.ts +7 -1
- package/dist/src/orchestration/stepLifecycle.js +1 -1
- package/dist/src/services/infrastructure/CdkEventMonitoring.d.ts +3 -0
- package/dist/src/services/infrastructure/CdkEventMonitoring.js +1 -1
- package/dist/src/services/infrastructure/CdkProcessManager.d.ts +10 -1
- package/dist/src/services/infrastructure/CdkProcessManager.js +3 -3
- package/dist/src/services/infrastructure/CdkService.d.ts +1 -0
- package/dist/src/services/infrastructure/CdkService.js +2 -2
- package/dist/src/services/infrastructure/CdkServiceTypes.d.ts +15 -0
- package/dist/src/services/infrastructure/CloudFormationService.d.ts +11 -0
- package/dist/src/services/infrastructure/CloudFormationService.js +1 -1
- package/dist/src/services/infrastructure/cancelInFlightStack.d.ts +22 -0
- package/dist/src/services/infrastructure/cancelInFlightStack.js +3 -0
- package/dist/src/types/approval.d.ts +53 -0
- package/dist/src/types/approval.js +0 -0
- package/dist/src/types/callbackKeys.d.ts +1 -1
- package/dist/src/types/callbackKeys.js +1 -1
- package/dist/src/types/callbacks.d.ts +18 -4
- package/dist/src/types/config/entitlements.d.ts +7 -0
- package/dist/src/types/deployEmitter.d.ts +85 -0
- package/dist/src/types/deployEmitter.js +1 -0
- package/dist/src/types/deployEvent.d.ts +907 -0
- package/dist/src/types/deployEvent.js +1 -0
- package/dist/src/types/deployEventParse.d.ts +52 -0
- package/dist/src/types/deployEventParse.js +1 -0
- package/dist/src/types/deployEventTypeGuards.d.ts +10 -0
- package/dist/src/types/deployEventTypeGuards.js +1 -0
- package/dist/src/types/index.d.ts +6 -1
- package/dist/src/types/index.js +1 -1
- package/dist/src/types/params.d.ts +103 -0
- package/package.json +8 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{existsSync as a,statSync as u}from"node:fs";import{success as f,failure as t}from"@fjall/generator";import{BUILDKIT_SECRET_ID_PATTERN as b}from"@fjall/util/manifest/schemas";function c(e){return n({buildArgs:e.buildArgs??null,buildSecrets:e.buildSecrets??null})}function n(e){return Array.isArray(e)?`[${e.map(n).join(",")}]`:e!==null&&typeof e=="object"?`{${Object.keys(e).sort().map(i=>`${JSON.stringify(i)}:${n(e[i])}`).join(",")}}`:JSON.stringify(e)}function w(e){const o=e.buildSecrets??[];for(const r of o)if(!b.test(r.id))return t(new Error(`Build secret id "${r.id}" for service '${e.serviceName}' contains characters outside the allowed BuildKit set [A-Za-z0-9_.-]; rename it (no comma, '=', or whitespace, which break the buildx --secret argv).`));const i=new Set;for(const r of o){if(i.has(r.id))return t(new Error(`Duplicate build secret id "${r.id}" for service '${e.serviceName}'; each buildSecrets[].id must be unique within a build group (the resolver writes each value to a temp file keyed by id, so a duplicate is silently overwritten).`));i.add(r.id)}const l=e.buildArgs??{};for(const r of Object.keys(l))if(i.has(r))return t(new Error(`buildArg key "${r}" for service '${e.serviceName}' collides with a buildSecrets[].id; a public --build-arg would shadow the secret mount of the same name. Rename one of them.`));if(!a(e.buildContext)||!u(e.buildContext).isDirectory())return t(new Error(`Build context "${e.buildContext}" for service '${e.serviceName}' is not an existing directory.`));const d=e.members;if(d!==void 0&&d.length>0){const r=c({serviceName:e.serviceName,...e.buildArgs!==void 0&&{buildArgs:e.buildArgs},...e.buildSecrets!==void 0&&{buildSecrets:e.buildSecrets}});for(const s of d)if(s.serviceName!==e.serviceName&&c(s)!==r)return t(new Error(`Service '${s.serviceName}' shares a build group with leader '${e.serviceName}' but declares different docker buildArgs/buildSecrets. Only the group leader's docker inputs are resolved and built, so '${s.serviceName}' would ship an image built with the leader's inputs, not its own. Give it a distinct Dockerfile path, context, or target so it builds independently, or align its buildArgs/buildSecrets with the leader.`))}return f(void 0)}export{w as validateBuildGroup};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type Result } from "@fjall/generator";
|
|
2
|
+
import type { DockerBuildSecretRef, DockerBuildArgValue } from "@fjall/util/manifest/schemas";
|
|
3
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
4
|
+
import type { ResolvedBuildSecret } from "../dockerInterface.js";
|
|
5
|
+
import { type BuildSecretSessionContext } from "./buildSecretSession.js";
|
|
6
|
+
/**
|
|
7
|
+
* Outcome of resolving a manifest's `docker.buildSecrets` refs. `resolved` is
|
|
8
|
+
* ready to hand to `DockerBuildParams.buildSecrets`; `cleanup()` removes the
|
|
9
|
+
* temp directory holding the 0600 value files and MUST be awaited in a `finally`
|
|
10
|
+
* (wrap the await in `.catch(...)` — a throw from cleanup must not replace the
|
|
11
|
+
* build result).
|
|
12
|
+
*/
|
|
13
|
+
export interface ResolvedBuildSecrets {
|
|
14
|
+
resolved: ResolvedBuildSecret[];
|
|
15
|
+
cleanup: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve each build-time secret REFERENCE to its VALUE just-in-time, writing
|
|
19
|
+
* the value to a 0600 temp file and returning a `{ kind: "file", path }` handle
|
|
20
|
+
* for a BuildKit `--secret` mount. The value reaches buildx ONLY via that mount
|
|
21
|
+
* (tmpfs, one `RUN`), never `--build-arg` — so it cannot enter an image layer,
|
|
22
|
+
* `docker history`, or provenance.
|
|
23
|
+
*
|
|
24
|
+
* Fail-CLOSED: an unresolvable ref aborts the build at this cheap pre-build
|
|
25
|
+
* point with a remediation message naming the FIELD/KEY (an identifier — never
|
|
26
|
+
* the value). The resolved value is NEVER logged. On any failure the temp dir
|
|
27
|
+
* is removed before returning.
|
|
28
|
+
*
|
|
29
|
+
* Sources:
|
|
30
|
+
* - `ssm` → `GetParameter({ WithDecryption: true })`
|
|
31
|
+
* - `secretsManager` → `GetSecretValue` (by name or ARN; optional JSON `field`)
|
|
32
|
+
* - `env` → `process.env[name]`, rejecting undefined AND empty string
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Project the sourced (`ssm` / `secretsManager`) values out of a `buildArgs`
|
|
36
|
+
* map into `DockerBuildSecretRef`s, so the same scoped-session policy covers
|
|
37
|
+
* both the buildArg reads and the build-secret file-mount reads. `env`-sourced
|
|
38
|
+
* and plain-string values contribute nothing — they never touch AWS.
|
|
39
|
+
*/
|
|
40
|
+
export declare function sourcedRefsFromBuildArgs(buildArgs: Record<string, DockerBuildArgValue> | undefined): DockerBuildSecretRef[];
|
|
41
|
+
/**
|
|
42
|
+
* Narrow the AWS identity to a least-privilege build-secret read session when
|
|
43
|
+
* the provider supports it (CLI). When the provider cannot self-scope (the
|
|
44
|
+
* worker's pre-assumed OIDC credentials — `assumeScopedBuildSecretSession`
|
|
45
|
+
* absent) OR no app context is supplied, falls back to the unscoped provider.
|
|
46
|
+
* A policy that is too large to fit the inline limit fails closed here.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveBuildSecretSessionProvider(awsProvider: AwsProvider, refs: readonly DockerBuildSecretRef[], appContext?: BuildSecretSessionContext): Promise<Result<AwsProvider>>;
|
|
49
|
+
export declare function resolveBuildSecrets(awsProvider: AwsProvider, refs: readonly DockerBuildSecretRef[], abortSignal?: AbortSignal, appContext?: BuildSecretSessionContext): Promise<Result<ResolvedBuildSecrets>>;
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a single secret/sourced REFERENCE to its string VALUE. Shared by the
|
|
52
|
+
* build-secret path (file-mount) and the public-but-sensitive `buildArgs`
|
|
53
|
+
* sourced-value path — both read the same SSM/SM/env sources under the same
|
|
54
|
+
* (ideally scoped) AWS identity. The value is NEVER logged; failures name the
|
|
55
|
+
* identifier only.
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolveSecretRefValue(awsProvider: AwsProvider, ref: DockerBuildSecretRef, abortSignal?: AbortSignal): Promise<Result<string>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{mkdtemp as $,writeFile as B,rm as E}from"node:fs/promises";import{tmpdir as R}from"node:os";import{join as w}from"node:path";import{SecretsManagerClient as b,GetSecretValueCommand as x}from"@aws-sdk/client-secrets-manager";import{SSMClient as F,GetParameterCommand as N}from"@aws-sdk/client-ssm";import{success as a,failure as i}from"@fjall/generator";import{getErrorMessage as f,maskSensitiveOutput as m}from"@fjall/util";import{composeSdkAbortSignal as M}from"../../aws/organisations/types.js";import{buildBuildSecretSessionPolicy as O}from"./buildSecretSession.js";const A="fjall-build-secret-read",v=3e4;function q(n){return n===void 0?[]:Object.entries(n).flatMap(([e,r])=>typeof r=="string"||r.env!==void 0?[]:r.ssm!==void 0?[{id:e,ssm:r.ssm}]:r.secretsManager!==void 0?[{id:e,secretsManager:r.secretsManager}]:[])}function C(n){return n.flatMap(e=>e.ssm!==void 0?[{ssm:e.ssm}]:e.secretsManager!==void 0?[{...e.secretsManager.name!==void 0&&{secretsManagerName:e.secretsManager.name},...e.secretsManager.arn!==void 0&&{secretsManagerArn:e.secretsManager.arn}}]:[])}async function _(n,e,r){if(r===void 0||n.assumeScopedBuildSecretSession===void 0)return a(n);const t=O(r,C(e));if(!t.success)return i(t.error);try{const s=await n.assumeScopedBuildSecretSession(t.data,A);return a(s)}catch(s){return i(new Error(`Failed to assume scoped build-secret session: ${m(f(s))}`))}}async function z(n,e,r,t){if(e.length===0)return a({resolved:[],cleanup:()=>Promise.resolve()});const s=await _(n,e,t);if(!s.success)return i(s.error);const c=s.data;let u;try{const o=await $(w(R(),"fjall-build-secret-"));u=o;const p=()=>E(o,{recursive:!0,force:!0}),S=[];for(const d of e){const l=await h(c,d,r);if(!l.success)return await p(),i(l.error);const g=w(o,d.id);await B(g,l.data,{mode:384}),S.push({id:d.id,source:{kind:"file",path:g}})}return a({resolved:S,cleanup:p})}catch(o){return u!==void 0&&await E(u,{recursive:!0,force:!0}).catch(()=>{}),i(new Error(`Failed to resolve build secrets: ${m(f(o))}`))}}async function H(n,e,r){return h(n,e,r)}async function h(n,e,r){if(e.ssm!==void 0)return j(n,e.ssm,r);if(e.secretsManager!==void 0)return D(n,e.id,e.secretsManager,r);if(e.env!==void 0){const t=process.env[e.env];return t===void 0||t===""?i(new Error(`Build secret "${e.id}" reads env var "${e.env}", which is unset or empty.`)):a(t)}return i(new Error(`Build secret "${e.id}" has no resolvable source.`))}async function j(n,e,r){const t=n.getClient(F);try{const c=(await t.send(new N({Name:e,WithDecryption:!0}),{abortSignal:M(r,v)})).Parameter?.Value;return c===void 0||c===""?i(new Error(`SSM parameter "${e}" returned no value.`)):a(c)}catch(s){return i(y("SSM parameter",e,s))}}async function D(n,e,r,t){const s=r.arn??r.name;if(s===void 0)return i(new Error(`Build secret "${e}" secretsManager has neither name nor arn.`));const c=n.getClient(b);try{const o=(await c.send(new x({SecretId:s}),{abortSignal:M(t,v)})).SecretString;return o===void 0||o===""?i(new Error(`Secret "${s}" returned no string value.`)):r.field===void 0?a(o):T(o,r.field,s)}catch(u){return i(y("secret",s,u))}}function T(n,e,r){let t;try{t=JSON.parse(n)}catch{return i(new Error(`Secret "${r}" is not valid JSON; cannot extract field.`))}if(typeof t!="object"||t===null||Array.isArray(t)||!(e in t))return i(new Error(`Secret "${r}" has no field "${e}".`));const s=t[e];return typeof s!="string"||s===""?i(new Error(`Secret "${r}" field "${e}" is empty or not a string.`)):a(s)}function y(n,e,r){const t=r instanceof Error?r.name:"";return t==="ResourceNotFoundException"||t==="ParameterNotFound"?new Error(`${n} "${e}" does not exist in this account/region \u2014 create it or fix the reference.`):t==="AccessDeniedException"||t==="AccessDenied"?new Error(`The deploy identity lacks read access to ${n} "${e}" \u2014 grant the build-time read on this reference.`):new Error(`Failed to resolve ${n} "${e}": ${m(f(r))}`)}export{_ as resolveBuildSecretSessionProvider,z as resolveBuildSecrets,H as resolveSecretRefValue,q as sourcedRefsFromBuildArgs};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Result } from "@fjall/generator";
|
|
2
|
+
/**
|
|
3
|
+
* A single secret/parameter the build needs to read, normalised from either a
|
|
4
|
+
* `docker.buildSecrets` ref or a sourced `docker.buildArgs` value. Exactly one
|
|
5
|
+
* of `ssm` / `secretsManagerName` / `secretsManagerArn` is set; `env`-sourced
|
|
6
|
+
* values never reach here (they read host env, not AWS).
|
|
7
|
+
*/
|
|
8
|
+
export interface BuildSecretRef {
|
|
9
|
+
ssm?: string;
|
|
10
|
+
secretsManagerName?: string;
|
|
11
|
+
secretsManagerArn?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface BuildSecretSessionContext {
|
|
14
|
+
appName: string;
|
|
15
|
+
region: string;
|
|
16
|
+
accountId: string;
|
|
17
|
+
partition: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Derive the `aws` / `aws-cn` partition from a region. China regions use the
|
|
21
|
+
* `aws-cn` partition; everything else (incl. GovCloud `us-gov-*`, which is
|
|
22
|
+
* still `aws-us-gov` — out of scope here, callers pass it explicitly) is `aws`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function partitionForRegion(region: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Build a least-privilege inline session policy scoping a build identity down to
|
|
27
|
+
* exactly the SSM parameters and Secrets Manager secrets a build reads. The
|
|
28
|
+
* baseline grants the app's own prefix (`parameter/<kebab-app>/*` and
|
|
29
|
+
* `secret:fjall-<kebab-app>-*`); any ref ARN that falls OUTSIDE that prefix is
|
|
30
|
+
* added as an explicit resource so cross-app shared secrets still resolve.
|
|
31
|
+
*
|
|
32
|
+
* Fails closed when the serialised policy would exceed the AWS inline-policy
|
|
33
|
+
* limit — a build with that many out-of-prefix refs must be split rather than
|
|
34
|
+
* granted a widened scope.
|
|
35
|
+
*
|
|
36
|
+
* SECURITY: the SM ARN baseline ends in `-*` (never a bare `*`) so the wildcard
|
|
37
|
+
* consumes only the AWS-generated 6-char suffix, not sibling secrets — see
|
|
38
|
+
* generator-standards.md "IAM Secrets Manager ARN scoping".
|
|
39
|
+
*/
|
|
40
|
+
export declare function buildBuildSecretSessionPolicy(ctx: BuildSecretSessionContext, refs: readonly BuildSecretRef[]): Result<string>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{success as $,failure as g}from"@fjall/generator";import{toKebab as p}from"@fjall/util";const l=2048;function w(s){return s.startsWith("cn-")?"aws-cn":"aws"}function R(s,f){const{region:r,accountId:n,partition:a}=s,i=p(s.appName),u=`arn:${a}:ssm:${r}:${n}:parameter/${i}/*`,m=`arn:${a}:secretsmanager:${r}:${n}:secret:fjall-${i}-*`,t=new Set;for(const e of f){if(e.ssm!==void 0&&e.ssm!==""){const c=e.ssm.startsWith("/")?e.ssm.slice(1):e.ssm;c.startsWith(`${i}/`)||t.add(`arn:${a}:ssm:${r}:${n}:parameter/${c}`)}if(e.secretsManagerArn!==void 0&&e.secretsManagerArn!==""){const c=m.replace(/\*$/,"");e.secretsManagerArn.startsWith(c)||t.add(e.secretsManagerArn)}e.secretsManagerName!==void 0&&e.secretsManagerName!==""&&!e.secretsManagerName.startsWith(`fjall-${i}-`)&&t.add(`arn:${a}:secretsmanager:${r}:${n}:secret:${e.secretsManagerName}-*`)}const d=[{Sid:"FjallBuildSsmRead",Effect:"Allow",Action:["ssm:GetParameter","ssm:GetParameters"],Resource:[u,...[...t].filter(S)]},{Sid:"FjallBuildSecretsManagerRead",Effect:"Allow",Action:["secretsmanager:GetSecretValue"],Resource:[m,...[...t].filter(A)]},{Sid:"FjallBuildKmsDecrypt",Effect:"Allow",Action:["kms:Decrypt"],Resource:"*",Condition:{StringEquals:{"kms:ViaService":[`ssm.${r}.amazonaws.com`,`secretsmanager.${r}.amazonaws.com`]}}}],o=JSON.stringify({Version:"2012-10-17",Statement:d});return o.length>l?g(new Error(`Build-secret session policy is ${o.length} chars, exceeding the ${l}-char AWS inline-policy limit. Reduce the number of out-of-prefix secret/parameter references for this build.`)):$(o)}function S(s){return s.includes(":ssm:")}function A(s){return s.includes(":secretsmanager:")}export{R as buildBuildSecretSessionPolicy,w as partitionForRegion};
|
|
@@ -1 +1 @@
|
|
|
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
|
|
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 b=g?.serviceName?.toLowerCase(),$=b===""?void 0:b,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=[],R=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,R),C!==d?(s=d,c.onLog?.(`Re-based image tag ${d} \u2192 ${C} 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");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}}},n.abortSignal)}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 b=U(m),$=await n.ecrImageInspector.getImageDigest(b,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),R=r.family;if(!R)return S(new Error(`Task definition for ${t} has no family`));const M=await n.ecsService.registerTaskDefinition({family:R,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:R,...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=`${b}:${c}`,C=Q(b);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};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{isAbsolute as
|
|
1
|
+
import{isAbsolute as _,join as B,dirname as j,resolve as $}from"node:path";import{existsSync as x}from"node:fs";import{success as R,failure as l}from"@fjall/generator";import{deriveContentHashTag as F,maskSensitiveOutput as f,toKebab as I}from"@fjall/util";import{evaluateBakeGuard as U,evaluateResolvedBuildArgValues as G,acknowledgedBuildArgKeys as H,BAKE_GUARD_EXPOSURE_CLAUSE as V}from"@fjall/util/docker";import{logger as w}from"@fjall/util/logger";import{parseDockerServicesFromManifest as M}from"@fjall/util/manifest";import{STEP_IDS as k}from"../../types/stepDefinitions.js";import{resolveBuildSecrets as W,resolveSecretRefValue as z,resolveBuildSecretSessionProvider as X,sourcedRefsFromBuildArgs as Y}from"./buildSecretResolver.js";import{partitionForRegion as q}from"./buildSecretSession.js";import{resolveBuildArgs as J}from"./buildArgResolver.js";import{validateBuildGroup as Q}from"./buildGroupValidator.js";const P="dockerBuildHelper",A="Building and pushing Docker image";function Z(e,o,r){const t=B(o,e);if(x(t))return{path:t,base:o};if(r!==""&&r!==o){const a=B(r,e);if(x(a))return{path:a,base:r}}return{path:t,base:o}}function ee(e,o,r){const t=e.docker.path,a=e.docker.context;let s,n;if(_(t))s=t,n=o;else{const c=Z(t,o,r);s=c.path,n=c.base}return{buildContext:a?_(a)?$(a):$(n,a):$(j(s)),dockerfilePath:s,target:e.docker.target,resolvedViaFallback:n!==o}}function te(e){return`${e.buildContext}|${e.dockerfilePath}|${e.target??""}`}function re(e,o){return`Dockerfile not found for service '${e}' at ${o}. 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 ne(e){return e.startsWith("cn-")?"amazonaws.com.cn":"amazonaws.com"}function oe(e,o,r){return`${e}.dkr.ecr.${o}.${ne(o)}/${I(r)}`}function ie(e,o,r){const t=new Map;for(const a of e){const s=ee(a,o,r),n=te(s),g=t.get(n);g?g.members.push(a):t.set(n,{identity:s,members:[a]})}return Array.from(t.values())}async function se(e,o,r,t,a){const s=await e.buildAndPush({appName:o.appName,appPath:o.path,region:r,accountId:t},(n,g,c,v)=>{a.onDockerProgress?.(f(n),g,c,v)});return s.success?R({imageUri:s.data.imageUri,imageTag:s.data.imageTag}):l(s.error)}async function ae(e,o,r,t,a,s,n,g,c,v,p,h){const d=e.members[0];if(!x(e.identity.dockerfilePath))return l(new Error(re(d.name,e.identity.dockerfilePath)));e.identity.resolvedViaFallback&&n.onLog?.(`Building service '${d.name}' from ${e.identity.dockerfilePath} (resolved at the project root).`,"info");const i=[...d.docker.buildSecrets??[],...h??[]],m=Q({serviceName:d.name,dockerfilePath:e.identity.dockerfilePath,buildContext:e.identity.buildContext,...e.identity.target!==void 0&&{target:e.identity.target},...d.docker.buildArgs!==void 0&&{buildArgs:d.docker.buildArgs},...i.length>0&&{buildSecrets:i},members:e.members.map(u=>({serviceName:u.name,...u.docker.buildArgs!==void 0&&{buildArgs:u.docker.buildArgs},...u.docker.buildSecrets!==void 0&&{buildSecrets:u.docker.buildSecrets}}))});if(!m.success)return l(m.error);const b=U(d.docker.buildArgs,I(r.appName));for(const u of b.warnings)n.onLog?.(f(u.message),"warn");if(b.findings.length>0)return l(new Error(f(b.findings.map(u=>u.message).join(" "))));const y=i,S=Y(d.docker.buildArgs),C={appName:r.appName,region:t,accountId:a,partition:q(t)},N=await X(g,[...y,...S],C);if(!N.success)return l(N.error);const T=N.data,E=await J({manifestBuildArgs:d.docker.buildArgs,serviceDir:e.identity.buildContext,appDir:r.path,...v!==void 0&&{stage:v},shellEnv:process.env,...p!==void 0&&{flagBuildArgs:p},resolveSourcedValue:(u,K)=>z(T,{id:u,...K},c)});if(!E.success)return l(E.error);const O=E.data.buildArgs;for(const u of E.data.inferredDotenvKeys)n.onLog?.(f(`buildArg "${u}" was auto-declared from a .env file and will be baked into the image \u2014 ${V}.`),"warn");const L=G(O,I(r.appName),H(d.docker.buildArgs));if(L.length>0)return l(new Error(f(L.map(u=>u.message).join(" "))));let D;if(y.length>0){const u=await W(T,y,c);if(!u.success)return l(u.error);D=u.data}try{const u={appName:r.appName,appPath:e.identity.buildContext,region:t,accountId:a,buildContext:e.identity.buildContext,dockerfilePath:e.identity.dockerfilePath,pushByDigest:!0,serviceName:d.name,...e.identity.target!==void 0&&{target:e.identity.target},...Object.keys(O).length>0&&{buildArgs:O},...D!==void 0&&{buildSecrets:D.resolved}};return await de(e,u,o,s,n)}finally{D!==void 0&&await D.cleanup().catch(()=>n.onLog?.("Build-secret cleanup failed.","warn"))}}async function de(e,o,r,t,a){const s=await r.buildAndPush(o,(p,h,d,i)=>{a.onDockerProgress?.(f(p),h,d,i)});if(!s.success)return l(s.error);const n=s.data.imageDigest;if(typeof n!="string"||!n.startsWith("sha256:"))return l(new Error(`Buildx push succeeded but returned an invalid digest: ${f(String(n))}`));const g={},c=[];for(const p of e.members){const h=p.docker.target,d=F(n,p.name,h);if(!d.success)return l(d.error);const i=d.data,m=p.name;g[m]=i,c.push(`${t}:${i}`)}const v=await r.tagByDigest({sourceImage:t,digest:n,tags:c});return v.success?R({contentHashTagsByService:g}):l(v.error)}async function ke(e,o,r,t){const a=e.dockerProvider;if(!a)return R({});const s=o.awsProvider.getAccountId();if(!s)return t.onLog?.("Skipping Docker build \u2014 account ID not available","warn"),R({});const n=o.awsProvider.getRegion(),g=B(r.path,"cdk.out"),c=M(g),v=oe(s,n,r.appName);if(w.debug(P,"runDockerBuild starting",{appName:r.appName,appPath:r.path,accountId:s,region:n,cdkOutPath:g,manifestServiceCount:c.length,manifestServices:c.map(i=>({name:i.name,path:i.docker.path,context:i.docker.context,target:i.docker.target})),stepId:k.DOCKER_OPERATIONS,stepName:A}),t.onStepStart?.(k.DOCKER_OPERATIONS,A),c.length===0){t.onLog?.("Initialising ECR repository\u2026","info");const i=await a.initialiseECR({appName:r.appName,region:n,accountId:s});i.success||t.onLog?.(`ECR initialisation failed: ${f(i.error.message)}`,"warn"),t.onLog?.("Building and pushing Docker image\u2026","info");const m=await se(a,r,n,s,t);if(!m.success){w.debug(P,"Docker buildAndPush (legacy) failed",{appName:r.appName,error:f(m.error.message)}),t.onStepComplete?.(k.DOCKER_OPERATIONS,A,"error");const b=new Error(f(m.error.message));return t.onError?.(b),l(b)}return t.onStepComplete?.(k.DOCKER_OPERATIONS,A,"completed"),t.onLog?.(`Docker image pushed: ${m.data.imageUri}`,"info"),R({})}const p=await a.preflight?.();if(p!==void 0&&!p.success){t.onStepComplete?.(k.DOCKER_OPERATIONS,A,"error");const i=new Error(f(p.error.message));return t.onError?.(i),l(i)}const h=ie(c,r.path,e.workingDirectory);t.onLog?.(`Building and pushing ${c.length} service image(s) in ${h.length} group(s)\u2026`,"info");const d={};for(let i=0;i<h.length;i++){const m=h[i],b=m.members.map(S=>S.name).join(", ");t.onLog?.(`Building group ${i+1}/${h.length} (${b})\u2026`,"info");const y=await ae(m,a,r,n,s,v,t,o.awsProvider,e.abortSignal,e.options?.environment,e.options?.flagBuildArgs,e.options?.flagBuildSecrets);if(!y.success){w.debug(P,"Docker buildAndPush failed",{appName:r.appName,leader:m.members[0]?.name,members:b,error:f(y.error.message)}),t.onStepComplete?.(k.DOCKER_OPERATIONS,A,"error");const S=new Error(f(y.error.message));return t.onError?.(S),l(S)}for(const[S,C]of Object.entries(y.data.contentHashTagsByService))d[S]=C}return w.debug(P,"Docker buildAndPush succeeded",{appName:r.appName,services:c.map(i=>i.name),groupCount:h.length,contentHashTags:d}),t.onStepComplete?.(k.DOCKER_OPERATIONS,A,"completed"),t.onLog?.(`Docker images pushed for ${c.length} service(s)`,"info"),R(d)}export{A as DOCKER_BUILD_STEP_NAME,ke as runDockerBuild};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deploy approval token — a capability the engine hands the approver and
|
|
3
|
+
* re-checks before it mutates. Wire format mirrors the MCP planToken:
|
|
4
|
+
* `${planId}.${expMs}.${hmacHex}`.
|
|
5
|
+
*
|
|
6
|
+
* The token's `planId` IS the {@link digestHead} of the assembly digest, so the
|
|
7
|
+
* binding to a specific synthesised plan is intrinsic and SECRET-FREE: the
|
|
8
|
+
* verifier recomputes the digest from a fresh synth and the planId must match.
|
|
9
|
+
* This is the load-bearing, cross-process no-TOCTOU guarantee (CLI plan→apply
|
|
10
|
+
* and MCP plan→apply are separate processes). The HMAC is secondary
|
|
11
|
+
* tamper-evidence — meaningful only when a STABLE secret is configured via
|
|
12
|
+
* `FJALL_APPROVAL_TOKEN_SECRET` (set on the webapp worker / MCP host); with the
|
|
13
|
+
* per-process random fallback it cannot be reproduced cross-process, so
|
|
14
|
+
* verification falls back to the digest match alone.
|
|
15
|
+
*/
|
|
16
|
+
export declare const DEFAULT_APPROVAL_TTL_MS: number;
|
|
17
|
+
export declare const APPROVAL_TOKEN_PATTERN: RegExp;
|
|
18
|
+
export interface SignApprovalTokenOptions {
|
|
19
|
+
readonly assemblyDigest: string;
|
|
20
|
+
readonly ttlMs?: number;
|
|
21
|
+
readonly now?: () => number;
|
|
22
|
+
}
|
|
23
|
+
export interface SignedApprovalToken {
|
|
24
|
+
readonly token: string;
|
|
25
|
+
readonly expiresAt: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function signApprovalToken(opts: SignApprovalTokenOptions): SignedApprovalToken;
|
|
28
|
+
export type VerifyApprovalTokenResult = {
|
|
29
|
+
readonly ok: true;
|
|
30
|
+
} | {
|
|
31
|
+
readonly ok: false;
|
|
32
|
+
readonly reason: "malformed" | "superseded" | "expired" | "tampered";
|
|
33
|
+
};
|
|
34
|
+
export interface VerifyApprovalTokenOptions {
|
|
35
|
+
readonly token: string;
|
|
36
|
+
/** The freshly recomputed assembly digest the apply will execute against. */
|
|
37
|
+
readonly assemblyDigest: string;
|
|
38
|
+
readonly now?: () => number;
|
|
39
|
+
}
|
|
40
|
+
export declare function verifyApprovalToken(opts: VerifyApprovalTokenOptions): VerifyApprovalTokenResult;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createHmac as c,randomBytes as l,timingSafeEqual as m}from"node:crypto";import{digestHead as i}from"./assemblyDigest.js";const a=16,p=900*1e3,x=/^([0-9a-f]{32})\.([0-9]{1,16})\.([0-9a-f]{32})$/;function A(){const e=process.env.FJALL_APPROVAL_TOKEN_SECRET;return e!==void 0&&e!==""?{secret:Buffer.from(e,"utf8"),stable:!0}:{secret:l(32),stable:!1}}const{secret:u,stable:E}=A();function k(e){const t=i(e.assemblyDigest),r=e.now??Date.now,o=e.ttlMs??p,n=r()+o,s=c("sha256",u).update(`${t}.${n}`).digest().subarray(0,a).toString("hex");return{token:`${t}.${n}.${s}`,expiresAt:new Date(n).toISOString()}}function g(e){const t=x.exec(e.token);if(t===null)return{ok:!1,reason:"malformed"};const r=t[1],o=t[2],n=t[3];if(r===void 0||o===void 0||n===void 0)return{ok:!1,reason:"malformed"};if(r!==i(e.assemblyDigest))return{ok:!1,reason:"superseded"};const s=parseInt(o,10);if(!Number.isFinite(s))return{ok:!1,reason:"malformed"};if((e.now??Date.now)()>s)return{ok:!1,reason:"expired"};if(E){const d=c("sha256",u).update(`${r}.${o}`).digest().subarray(0,a),f=Buffer.from(n,"hex");if(f.length!==a||!m(d,f))return{ok:!1,reason:"tampered"}}return{ok:!0}}export{x as APPROVAL_TOKEN_PATTERN,p as DEFAULT_APPROVAL_TTL_MS,k as signApprovalToken,g as verifyApprovalToken};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembly digest — a deterministic fold over the per-stack template hashes that
|
|
3
|
+
* the detection pipeline already computes (TemplateHashService). Bound into the
|
|
4
|
+
* approval token and re-checked before apply, it is the secret-free guarantee
|
|
5
|
+
* that the plan the approver saw is the plan that executes (no TOCTOU): if any
|
|
6
|
+
* stack's synthesised template changed between plan and apply, the digest
|
|
7
|
+
* diverges and the gate refuses the stale token.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Fold the stack-name → template-hash map into a single sha256 hex digest.
|
|
11
|
+
* Entries are sorted by stack name so the digest is independent of map order,
|
|
12
|
+
* and each pair is framed with separators so `{ab, c}` cannot collide with
|
|
13
|
+
* `{a, bc}`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function computeAssemblyDigest(stackHashes: ReadonlyMap<string, string>): string;
|
|
16
|
+
/**
|
|
17
|
+
* The short head of the digest embedded in the approval token wire format
|
|
18
|
+
* (`signPlanToken` reuses the planToken shape). The full digest stays in the
|
|
19
|
+
* durable plan record; the token carries only enough to bind it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function digestHead(assemblyDigest: string): string;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{createHash as o}from"node:crypto";function c(a){const r=[...a.entries()].sort(([e],[s])=>e<s?-1:e>s?1:0),t=o("sha256");for(const[e,s]of r)t.update(e),t.update("\0"),t.update(s),t.update(`
|
|
2
|
+
`);return t.digest("hex")}function d(a){return o("sha256").update(a).digest("hex").slice(0,32)}export{c as computeAssemblyDigest,d as digestHead};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure deploy-plan builder: given already-fetched old/new template pairs per
|
|
3
|
+
* stack and the assembly digest, diff each pair with `@aws-cdk/cloudformation-diff`
|
|
4
|
+
* and accumulate a typed {@link DeployPlan}. No IO — fixture-testable in full.
|
|
5
|
+
*/
|
|
6
|
+
import type { DeployPlan } from "./types.js";
|
|
7
|
+
export interface StackTemplatePair {
|
|
8
|
+
stackName: string;
|
|
9
|
+
/** The live template; `{}` when the stack does not yet exist (all creations). */
|
|
10
|
+
oldTemplate: unknown;
|
|
11
|
+
/** The freshly synthesised cdk.out template. */
|
|
12
|
+
newTemplate: unknown;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildDeployPlan(stacks: StackTemplatePair[], assemblyDigest: string): DeployPlan;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{diffTemplate as l}from"@aws-cdk/cloudformation-diff";import{classifyImpact as y,derivePropertyChanges as h,isDataLoss as g}from"./classify.js";import{renderPlanSummary as C}from"./renderDeployPlan.js";function T(){return{create:0,update:0,replace:0,delete:0,read:0,"no-op":0}}function w(p,u){const t=[],n=T();let s=!1,c=!1;for(const r of p){const a=l(r.oldTemplate??{},r.newTemplate??{});a.iamChanges.permissionsBroadened&&(s=!0),a.securityGroupChanges.hasChanges&&(c=!0),a.resources.forEachDifference((f,o)=>{const e=y(o.changeImpact);if(n[e.action]+=1,e.action==="no-op")return;const i=o.newResourceType??o.oldResourceType??"AWS::Unknown::Resource";t.push({stack:r.stackName,address:f,resourceType:i,action:e.action,replacementMode:e.replacementMode,destructive:e.destructive,retained:e.retained,dataLoss:g({action:e.action,retained:e.retained,resourceType:i}),propertyChanges:h(o.propertyUpdates)})})}const d=t.length,m=t.some(r=>r.destructive);return{changes:t,changeCount:d,counts:n,hasDestructiveChanges:m,iamBroadening:s,securityGroupBroadening:c,assemblyDigest:u,summary:C({counts:n,changes:t})}}export{w as buildDeployPlan};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure classifiers mapping `@aws-cdk/cloudformation-diff` output onto the deploy
|
|
3
|
+
* contract's vocabulary. No IO — every function here is a fixture-testable map.
|
|
4
|
+
*/
|
|
5
|
+
import { ResourceImpact } from "@aws-cdk/cloudformation-diff";
|
|
6
|
+
import type { PlanAction, PlanPropertyChange, ReplacementMode } from "../../../types/deployEvent.js";
|
|
7
|
+
export interface ImpactClassification {
|
|
8
|
+
action: PlanAction;
|
|
9
|
+
replacementMode: ReplacementMode;
|
|
10
|
+
/** Replace OR destroy — a real mutation loss. Orphan/retain is NOT destructive. */
|
|
11
|
+
destructive: boolean;
|
|
12
|
+
/** WILL_ORPHAN — removed from the stack but physically retained. */
|
|
13
|
+
retained: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Map a cloudformation-diff ResourceImpact to our plan vocabulary. The enum is
|
|
17
|
+
* EXACT-pinned via the blessed trio, so the switch is exhaustive; an unrecognised
|
|
18
|
+
* impact (a deliberate bump shipping a new member) fails closed — flagged
|
|
19
|
+
* destructive and conditional so the gate prompts rather than waving it through.
|
|
20
|
+
*/
|
|
21
|
+
export declare function classifyImpact(impact: ResourceImpact): ImpactClassification;
|
|
22
|
+
export declare function isStatefulResourceType(resourceType: string): boolean;
|
|
23
|
+
/** Data is lost when a stateful resource is replaced or destroyed (not retained). */
|
|
24
|
+
export declare function isDataLoss(args: {
|
|
25
|
+
action: PlanAction;
|
|
26
|
+
retained: boolean;
|
|
27
|
+
resourceType: string;
|
|
28
|
+
}): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Derive per-property change detail from a cloudformation-diff `propertyUpdates`
|
|
31
|
+
* map. Records only the property PATH and whether it forces replacement — never
|
|
32
|
+
* the old/new VALUES, which can embed credentials. Capped at 100 entries.
|
|
33
|
+
*/
|
|
34
|
+
export declare function derivePropertyChanges(propertyUpdates: Record<string, {
|
|
35
|
+
changeImpact?: ResourceImpact;
|
|
36
|
+
isDifferent?: boolean;
|
|
37
|
+
}> | undefined): PlanPropertyChange[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{ResourceImpact as t}from"@aws-cdk/cloudformation-diff";function l(e){switch(e){case t.WILL_CREATE:return{action:"create",replacementMode:"none",destructive:!1,retained:!1};case t.WILL_UPDATE:return{action:"update",replacementMode:"none",destructive:!1,retained:!1};case t.WILL_REPLACE:return{action:"replace",replacementMode:"will",destructive:!0,retained:!1};case t.MAY_REPLACE:return{action:"replace",replacementMode:"may",destructive:!0,retained:!1};case t.WILL_DESTROY:return{action:"delete",replacementMode:"none",destructive:!0,retained:!1};case t.WILL_ORPHAN:return{action:"delete",replacementMode:"none",destructive:!1,retained:!0};case t.WILL_IMPORT:return{action:"read",replacementMode:"none",destructive:!1,retained:!1};case t.NO_CHANGE:return{action:"no-op",replacementMode:"none",destructive:!1,retained:!1};default:{const n=e;return{action:"replace",replacementMode:"conditional",destructive:!0,retained:!1}}}}const c=["AWS::RDS::","AWS::DynamoDB::Table","AWS::DynamoDB::GlobalTable","AWS::S3::Bucket","AWS::EFS::FileSystem","AWS::ElastiCache::","AWS::Redshift::","AWS::DocDB::","AWS::Neptune::","AWS::OpenSearchService::Domain","AWS::Elasticsearch::Domain","AWS::MSK::Cluster","AWS::QLDB::Ledger","AWS::Timestream::","AWS::EC2::Volume","AWS::FSx::"];function i(e){return c.some(n=>e.startsWith(n))}function u(e){return e.retained||e.action!=="replace"&&e.action!=="delete"?!1:i(e.resourceType)}const o=new Set([t.WILL_REPLACE,t.MAY_REPLACE]);function d(e){if(e===void 0)return[];const n=[];for(const[r,a]of Object.entries(e))if(a.isDifferent!==!1&&(n.push({path:r,forcesReplacement:a.changeImpact!==void 0&&o.has(a.changeImpact)}),n.length>=100))break;return n}export{l as classifyImpact,d as derivePropertyChanges,u as isDataLoss,i as isStatefulResourceType};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IO wrapper around {@link buildDeployPlan}: fetches each changed stack's live
|
|
3
|
+
* template via the CloudFormation read surface, parses it, and delegates the
|
|
4
|
+
* pure diff/classification to the builder. A stack that does not yet exist
|
|
5
|
+
* contributes an empty baseline (every resource is a creation).
|
|
6
|
+
*/
|
|
7
|
+
import { type Result } from "@fjall/generator";
|
|
8
|
+
import type { CloudFormationError } from "../../../services/infrastructure/CloudFormationService.js";
|
|
9
|
+
import type { DeployPlan } from "./types.js";
|
|
10
|
+
/** The narrow CFN read surface the gate needs — CloudFormationService satisfies it. */
|
|
11
|
+
export interface CfnTemplateReader {
|
|
12
|
+
getTemplate(stackName: string): Promise<Result<string, CloudFormationError>>;
|
|
13
|
+
}
|
|
14
|
+
export interface ComputeDeployPlanStack {
|
|
15
|
+
stackName: string;
|
|
16
|
+
/** The synthesised cdk.out template, already parsed from JSON. */
|
|
17
|
+
newTemplate: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface ComputeDeployPlanParams {
|
|
20
|
+
cfnService: CfnTemplateReader;
|
|
21
|
+
stacks: ComputeDeployPlanStack[];
|
|
22
|
+
assemblyDigest: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function computeDeployPlan(params: ComputeDeployPlanParams): Promise<Result<DeployPlan, Error>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{success as o,failure as n}from"@fjall/generator";import{maskSensitiveOutput as i}from"@fjall/util";import{buildDeployPlan as m}from"./buildDeployPlan.js";function p(t){const r=t.trim();if(r==="")return o({});try{return o(JSON.parse(r))}catch(e){return n(new Error(`Failed to parse deployed template: ${i(e instanceof Error?e.message:String(e))}`))}}async function d(t){const r=[];for(const e of t.stacks){const s=await t.cfnService.getTemplate(e.stackName);let c={};if(s.success){const a=p(s.data);if(!a.success)return n(a.error);c=a.data}else if(s.error.errorType!=="stack_not_found")return n(s.error);r.push({stackName:e.stackName,oldTemplate:c,newTemplate:e.newTemplate})}return o(m(r,t.assemblyDigest))}export{d as computeDeployPlan};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Deploy-plan computation + rendering — the approval gate's read-side core. */
|
|
2
|
+
export type { DeployPlan, DeployPlanResourceChange } from "./types.js";
|
|
3
|
+
export { buildDeployPlan, type StackTemplatePair } from "./buildDeployPlan.js";
|
|
4
|
+
export { computeDeployPlan, type CfnTemplateReader, type ComputeDeployPlanParams, type ComputeDeployPlanStack } from "./computeDeployPlan.js";
|
|
5
|
+
export { computeAssemblyDigest, digestHead } from "./assemblyDigest.js";
|
|
6
|
+
export { classifyImpact, derivePropertyChanges, isDataLoss, isStatefulResourceType, type ImpactClassification } from "./classify.js";
|
|
7
|
+
export { renderPlanSummary, renderPlanLines, toWirePlanChanges } from "./renderDeployPlan.js";
|
|
8
|
+
export { signApprovalToken, verifyApprovalToken, DEFAULT_APPROVAL_TTL_MS, APPROVAL_TOKEN_PATTERN, type SignApprovalTokenOptions, type SignedApprovalToken, type VerifyApprovalTokenOptions, type VerifyApprovalTokenResult } from "./approvalToken.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{buildDeployPlan as o}from"./buildDeployPlan.js";import{computeDeployPlan as a}from"./computeDeployPlan.js";import{computeAssemblyDigest as s,digestHead as l}from"./assemblyDigest.js";import{classifyImpact as m,derivePropertyChanges as i,isDataLoss as P,isStatefulResourceType as f}from"./classify.js";import{renderPlanSummary as T,renderPlanLines as y,toWirePlanChanges as d}from"./renderDeployPlan.js";import{signApprovalToken as x,verifyApprovalToken as L,DEFAULT_APPROVAL_TTL_MS as c,APPROVAL_TOKEN_PATTERN as g}from"./approvalToken.js";export{g as APPROVAL_TOKEN_PATTERN,c as DEFAULT_APPROVAL_TTL_MS,o as buildDeployPlan,m as classifyImpact,s as computeAssemblyDigest,a as computeDeployPlan,i as derivePropertyChanges,l as digestHead,P as isDataLoss,f as isStatefulResourceType,y as renderPlanLines,T as renderPlanSummary,x as signApprovalToken,d as toWirePlanChanges,L as verifyApprovalToken};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared deploy-plan rendering — the single source of truth for how a computed
|
|
3
|
+
* plan is described, consumed by the CLI text path, the Ink approval screen, the
|
|
4
|
+
* non-interactive output and the wire `plan.available` summary. One renderer
|
|
5
|
+
* keeps every surface byte-identical (cross-system parity by construction).
|
|
6
|
+
*/
|
|
7
|
+
import type { PlanChange } from "../../../types/deployEvent.js";
|
|
8
|
+
import type { DeployPlan } from "./types.js";
|
|
9
|
+
/**
|
|
10
|
+
* A one-line plan summary, e.g.
|
|
11
|
+
* "2 to create, 1 to replace, 1 to destroy (2 destructive, 1 with data loss)".
|
|
12
|
+
*/
|
|
13
|
+
export declare function renderPlanSummary(plan: Pick<DeployPlan, "counts" | "changes">): string;
|
|
14
|
+
/** One line per resource change, grouped by action, e.g.
|
|
15
|
+
* "± replace AWS::ECS::Service WebAppCompute/Service (data loss)". */
|
|
16
|
+
export declare function renderPlanLines(plan: Pick<DeployPlan, "changes">): string[];
|
|
17
|
+
/** Map a computed plan's changes down to the wire `plan.available` PlanChange[]. */
|
|
18
|
+
export declare function toWirePlanChanges(plan: Pick<DeployPlan, "changes">): PlanChange[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const p={create:0,update:1,replace:2,delete:3,read:4,"no-op":5},c={create:"+",update:"~",replace:"\xB1",delete:"-",read:">","no-op":" "};function i(a){const{counts:e}=a,t=[];if(e.create>0&&t.push(`${e.create} to create`),e.update>0&&t.push(`${e.update} to update`),e.replace>0&&t.push(`${e.replace} to replace`),e.delete>0&&t.push(`${e.delete} to destroy`),e.read>0&&t.push(`${e.read} to read`),t.length===0)return"No changes";const s=a.changes.filter(n=>n.destructive).length,o=a.changes.filter(n=>n.dataLoss).length,r=[];s>0&&r.push(`${s} destructive`),o>0&&r.push(`${o} with data loss`);const d=t.join(", ");return r.length>0?`${d} (${r.join(", ")})`:d}function u(a){return a.changes.slice().sort((e,t)=>p[e.action]-p[t.action]||`${e.stack}/${e.address}`.localeCompare(`${t.stack}/${t.address}`)).map(e=>{const t=[];e.replacementMode==="may"&&t.push("may replace"),e.dataLoss&&t.push("data loss"),e.retained&&t.push("retained");const s=t.length>0?` (${t.join(", ")})`:"";return`${c[e.action]} ${e.action.padEnd(7)} ${e.resourceType} ${e.stack}/${e.address}${s}`})}function l(a){return a.changes.map(e=>({address:e.address,resourceType:e.resourceType,action:e.action,stack:e.stack,replacementMode:e.replacementMode,dataLoss:e.dataLoss,...e.propertyChanges.length>0&&{propertyChanges:e.propertyChanges}}))}export{u as renderPlanLines,i as renderPlanSummary,l as toWirePlanChanges};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine-internal deploy-plan types — the typed, resource-level "here's exactly
|
|
3
|
+
* what will change" structure the approval gate computes from the frozen cdk.out
|
|
4
|
+
* assembly diffed against the live CloudFormation templates.
|
|
5
|
+
*
|
|
6
|
+
* Richer than the wire `plan.available` PlanChange (it carries `destructive` and
|
|
7
|
+
* `retained`, which the renderer needs but the wire derives); `renderDeployPlan`
|
|
8
|
+
* maps a DeployPlan down to the wire shape.
|
|
9
|
+
*/
|
|
10
|
+
import type { ChangeCounts, PlanAction, PlanPropertyChange, ReplacementMode } from "../../../types/deployEvent.js";
|
|
11
|
+
/** A single resource change in a computed deploy plan. */
|
|
12
|
+
export interface DeployPlanResourceChange {
|
|
13
|
+
/** The stack the resource belongs to. */
|
|
14
|
+
stack: string;
|
|
15
|
+
/** Construct path / logical id. */
|
|
16
|
+
address: string;
|
|
17
|
+
/** CloudFormation resource type, e.g. `AWS::ECS::Service`. */
|
|
18
|
+
resourceType: string;
|
|
19
|
+
/** Mapped plan action. */
|
|
20
|
+
action: PlanAction;
|
|
21
|
+
/** Replacement axis: will / may / conditional / none. */
|
|
22
|
+
replacementMode: ReplacementMode;
|
|
23
|
+
/** Replace/destroy of a stateful resource whose data would not survive. */
|
|
24
|
+
dataLoss: boolean;
|
|
25
|
+
/** Replace OR destroy — a true mutation loss (orphan/retain is NOT destructive). */
|
|
26
|
+
destructive: boolean;
|
|
27
|
+
/** WILL_ORPHAN — removed from the stack but physically retained (DeletionPolicy). */
|
|
28
|
+
retained: boolean;
|
|
29
|
+
/** Per-property change detail — paths only, never values (no secret leak). */
|
|
30
|
+
propertyChanges: PlanPropertyChange[];
|
|
31
|
+
}
|
|
32
|
+
/** A fully computed deploy plan, ready to render and to gate on. */
|
|
33
|
+
export interface DeployPlan {
|
|
34
|
+
/** Every non-no-op resource change across all changed stacks. */
|
|
35
|
+
changes: DeployPlanResourceChange[];
|
|
36
|
+
/** Count of `changes` (excludes no-op). */
|
|
37
|
+
changeCount: number;
|
|
38
|
+
/** Per-action tally — every action enumerated (never derived by subtraction). */
|
|
39
|
+
counts: ChangeCounts;
|
|
40
|
+
/** Any change is a replace or a destroy. */
|
|
41
|
+
hasDestructiveChanges: boolean;
|
|
42
|
+
/** Any stack's IAM changes broaden permissions (cloudformation-diff). */
|
|
43
|
+
iamBroadening: boolean;
|
|
44
|
+
/** Any stack has security-group rule changes (conservative review signal). */
|
|
45
|
+
securityGroupBroadening: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Digest of the synthesised assembly (folded from per-stack template hashes).
|
|
48
|
+
* Bound into the approval token; re-checked before apply to close the TOCTOU
|
|
49
|
+
* window — the approved plan must be the plan that executes.
|
|
50
|
+
*/
|
|
51
|
+
assemblyDigest: string;
|
|
52
|
+
/** Human one-line summary, e.g. "2 to create, 1 to replace (1 destructive)". */
|
|
53
|
+
summary: string;
|
|
54
|
+
}
|
|
File without changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{success as
|
|
1
|
+
import{success as u,failure as y}from"@fjall/generator";import{isApplicationOperation as f}from"../types/operations.js";import{isAborted as w}from"../aws/organisations/types.js";import{checkRegionEnabled as b,regionDisabledMessage as A}from"../aws/regions/regionOptStatus.js";import{createDeployServices as k}from"./serviceFactory.js";import{resolveOperation as m}from"./resolveOperation.js";import{deployApplication as D}from"./application/applicationDeploy.js";import{deployOrganisation as v}from"./organisation/organisationDeploy.js";async function N(e){const s=Date.now(),l=await b(e.awsCredentials.region,e.awsCredentials,e.abortSignal);if(l==="disabled"){const i=new Error(A(e.awsCredentials.region));return e.callbacks.onError?.(i),y(i)}l==="enabling"&&e.callbacks.onProgress?.({type:"warning",message:`Region ${e.awsCredentials.region} is still activating (opt-in status ENABLING); the deploy may fail until activation completes.`});let r;const g=e.callbacks.onFailureAnalysis,a={...e,callbacks:{...e.callbacks,onFailureAnalysis:i=>{r=i,g?.(i)}}},c=k(a),o=await m(e.target,e.workingDirectory);if(!o.success)return e.callbacks.onError?.(o.error),o;const n=o.data,p=f(n)?"application":"organisation",t=f(n)?await D(a,c,n):await v(a,c,n);if(w(e.abortSignal))return u({target:e.target,deploymentType:p,cancelled:!0,durationMs:Date.now()-s});if(!t.success)return t;const d=r!==void 0&&t.data.failureAnalysis===void 0?{...t.data,failureAnalysis:r}:t.data;return u({...d,...d.durationMs===void 0?{durationMs:Date.now()-s}:{}})}export{N as deploy};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{success as
|
|
1
|
+
import{success as s,failure as l}from"@fjall/generator";import{isApplicationOperation as u,isOrganisationOperation as g,getApplicationStackName as p,getOrganisationStackName as y,APPLICATION_STACKS as b}from"../types/operations.js";import{isAborted as d}from"../aws/organisations/types.js";import{createDeployServices as k}from"./serviceFactory.js";import{resolveOperation as w}from"./resolveOperation.js";import{destroyApplication as S}from"./application/applicationDestroy.js";import{destroyOrganisation as D}from"./organisation/organisationDestroy.js";import{checkActiveDeployments as v}from"./activeDeploymentGuard.js";async function I(t){const i=Date.now(),n=k({target:t.target,workingDirectory:t.workingDirectory,awsCredentials:t.awsCredentials,callbacks:t.callbacks,options:{verbose:t.options?.verbose,force:t.options?.force,cascade:t.options?.cascade,skipConfirmation:t.options?.skipConfirmation},orgConfig:t.orgConfig,identity:t.identity,...t.abortSignal!==void 0?{abortSignal:t.abortSignal}:{}}),r=await w(t.target,t.workingDirectory);if(!r.success)return t.callbacks.onError?.(r.error),r;const e=r.data;if(!t.options?.force){const o=u(e)?Object.values(b).map(f=>p(e.appName,f)):[y(e.type)],c=await v(o,n.cfnService);if(!c.success)return t.callbacks.onError?.(c.error),l(c.error)}if(u(e)){const o=await S(t,n,e);return d(t.abortSignal)?s({target:t.target,deploymentType:"application",cancelled:!0,durationMs:Date.now()-i}):o.success&&o.data.durationMs===void 0?s({...o.data,durationMs:Date.now()-i}):o}if(g(e)){const o=await D(t,n,e);return d(t.abortSignal)?s({target:t.target,deploymentType:"organisation",cancelled:!0,durationMs:Date.now()-i}):o}const a=new Error(`Unsupported destroy target: ${t.target}`);return t.callbacks.onError?.(a),l(a)}export{I as destroy};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e={appName:!0,appPath:!0,region:!0,accountId:!0,imageTag:!0,services:!0,platform:!0,cacheRepoUri:!0,serviceName:!0,target:!0,dockerfilePath:!0,buildContext:!0,imageTags:!0,pushByDigest:!0,buildArgs:!0,buildSecrets:!0},t=Object.keys(e);export{t as ALL_DOCKER_BUILD_PARAM_KEYS};
|
|
@@ -5,6 +5,23 @@ export interface DockerServiceConfig {
|
|
|
5
5
|
name: string;
|
|
6
6
|
docker: DockerBuild;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* A build-time secret RESOLVED for buildx — the value has been fetched JIT and
|
|
10
|
+
* is now addressed either by a 0600 temp-file path (`kind: "file"`) or a
|
|
11
|
+
* build-host env-var name (`kind: "env"`). This shape carries NO secret value
|
|
12
|
+
* itself; it points buildx at where to read one for a `--secret` mount. It maps
|
|
13
|
+
* 1:1 onto `BuildxBuildArgs.secrets[].source` (the providers project it verbatim).
|
|
14
|
+
*/
|
|
15
|
+
export interface ResolvedBuildSecret {
|
|
16
|
+
id: string;
|
|
17
|
+
source: {
|
|
18
|
+
kind: "file";
|
|
19
|
+
path: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: "env";
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
8
25
|
export interface DockerBuildParams {
|
|
9
26
|
appName: string;
|
|
10
27
|
appPath: string;
|
|
@@ -52,6 +69,14 @@ export interface DockerBuildParams {
|
|
|
52
69
|
pushByDigest?: boolean;
|
|
53
70
|
/** Optional buildArgs forwarded to buildx. */
|
|
54
71
|
buildArgs?: Readonly<Record<string, string>>;
|
|
72
|
+
/**
|
|
73
|
+
* Optional build-time SECRETS, already RESOLVED just-in-time by deploy-core's
|
|
74
|
+
* `resolveBuildSecrets`. Each entry points buildx at a 0600 temp file or a
|
|
75
|
+
* build-host env var for a `--secret` mount — never a `--build-arg`, so the
|
|
76
|
+
* value never enters an image layer, `docker history`, or provenance. The
|
|
77
|
+
* provider projects these 1:1 onto `BuildxBuildArgs.secrets`.
|
|
78
|
+
*/
|
|
79
|
+
buildSecrets?: readonly ResolvedBuildSecret[];
|
|
55
80
|
}
|
|
56
81
|
export interface DockerBuildResult {
|
|
57
82
|
imageUri: string;
|
|
@@ -112,6 +137,15 @@ export interface TagByDigestParams {
|
|
|
112
137
|
* both wrap @fjall/util DockerCli (buildx CLI subprocess).
|
|
113
138
|
*/
|
|
114
139
|
export interface DockerProvider {
|
|
140
|
+
/**
|
|
141
|
+
* Optional one-shot environment preflight, called ONCE at the top of a
|
|
142
|
+
* multi-group run before any group builds. Implementations verify the docker
|
|
143
|
+
* daemon is reachable and buildx is available, failing closed so a missing
|
|
144
|
+
* toolchain surfaces once up-front rather than mid-wave. Absent → skipped
|
|
145
|
+
* (today's behaviour). The CLI provider implements it; the webapp worker
|
|
146
|
+
* counterpart is a release-coupled follow-up (worker repo).
|
|
147
|
+
*/
|
|
148
|
+
preflight?(): Promise<Result<void>>;
|
|
115
149
|
buildAndPush(params: DockerBuildParams, onProgress?: DockerProgressCallback, onBuildxEvent?: (event: BuildxProgressEvent) => void): Promise<Result<DockerBuildResult>>;
|
|
116
150
|
initialiseECR(params: ECRInitParams): Promise<Result<ECRInitResult>>;
|
|
117
151
|
/**
|
|
@@ -29,7 +29,11 @@ export { restoreAndReconcileQuarantinedBucket } from "./unlock/restoreAndReconci
|
|
|
29
29
|
export type { RestoreAndReconcileInput, RestoreAndReconcileReport, ReconcileOutcome } from "./unlock/restoreAndReconcileQuarantinedBucket.js";
|
|
30
30
|
export { parseAccountsConfiguration, flattenAccountsToEnvironments, extractAllAccountNames, accountsConfigToOUTree, isStringArray, isAccountsConfig, isOuOnlyAccountBucket, OU_ONLY_ACCOUNT_BUCKETS } from "./organisation/accountsConfig.js";
|
|
31
31
|
export type { AccountsConfig } from "./organisation/accountsConfig.js";
|
|
32
|
-
export type { DockerProvider, DockerProgressCallback, DockerServiceConfig, DockerBuildParams, DockerBuildResult, ECRInitParams, ECRInitResult, TagImagesParams, TagImagesResult, TagByDigestParams } from "./dockerInterface.js";
|
|
32
|
+
export type { DockerProvider, DockerProgressCallback, DockerServiceConfig, DockerBuildParams, DockerBuildResult, ResolvedBuildSecret, ECRInitParams, ECRInitResult, TagImagesParams, TagImagesResult, TagByDigestParams } from "./dockerInterface.js";
|
|
33
|
+
export { resolveBuildSecrets, resolveSecretRefValue, resolveBuildSecretSessionProvider, sourcedRefsFromBuildArgs, type ResolvedBuildSecrets } from "./application/buildSecretResolver.js";
|
|
34
|
+
export { buildBuildSecretSessionPolicy, partitionForRegion, type BuildSecretRef, type BuildSecretSessionContext } from "./application/buildSecretSession.js";
|
|
35
|
+
export { resolveBuildArgs, type ResolveBuildArgsContext, type ResolveBuildArgsResult, type SourcedBuildArgResolver } from "./application/buildArgResolver.js";
|
|
36
|
+
export { validateBuildGroup, type ValidateBuildGroupInput, type BuildGroupMemberInputs } from "./application/buildGroupValidator.js";
|
|
33
37
|
export type { DomainDeployProvider, DomainConfig, DomainDeployResult } from "./domainInterface.js";
|
|
34
38
|
export type { DeployServices } from "./serviceFactory.js";
|
|
35
39
|
export type { DetectionResult } from "./application/detectionPipeline.js";
|
|
@@ -37,3 +41,6 @@ export { runOpenNextBuild } from "./application/openNextBuild.js";
|
|
|
37
41
|
export { runOrganisationSetup, ORG_SETUP_PHASES } from "./organisation/organisationSetup.js";
|
|
38
42
|
export type { OrgSetupPhase, OrgSetupCallbacks, OrgSetupConfig, OrgSetupResult } from "./organisation/organisationSetup.js";
|
|
39
43
|
export * from "./builders/index.js";
|
|
44
|
+
export { buildDeployPlan, computeDeployPlan, computeAssemblyDigest, digestHead, classifyImpact, derivePropertyChanges, isDataLoss, isStatefulResourceType, renderPlanSummary, renderPlanLines, toWirePlanChanges, signApprovalToken, verifyApprovalToken, DEFAULT_APPROVAL_TTL_MS, APPROVAL_TOKEN_PATTERN } from "./application/plan/index.js";
|
|
45
|
+
export type { DeployPlan, DeployPlanResourceChange, StackTemplatePair, CfnTemplateReader, ComputeDeployPlanParams, ComputeDeployPlanStack, ImpactClassification, SignApprovalTokenOptions, SignedApprovalToken, VerifyApprovalTokenOptions, VerifyApprovalTokenResult } from "./application/plan/index.js";
|
|
46
|
+
export { runApprovalGate, type RunApprovalGateParams, type ApprovalGateOutcome } from "./application/approvalGate.js";
|