@fjall/deploy-core 3.8.1 → 3.10.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/orchestration/domain/delegatedDomainDeploy.js +1 -1
- package/dist/src/orchestration/domain/types.d.ts +1 -1
- package/dist/src/orchestration/domain/types.js +1 -1
- package/dist/src/orchestration/domain/zoneClassifier.d.ts +50 -4
- package/dist/src/orchestration/domain/zoneClassifier.js +1 -1
- package/dist/src/orchestration/domainInterface.d.ts +13 -0
- package/dist/src/orchestration/organisation/cascadeHelpers.d.ts +11 -0
- package/dist/src/orchestration/organisation/cascadeHelpers.js +1 -1
- package/dist/src/orchestration/organisationDeploy/cascadeExecution.js +1 -1
- package/dist/src/services/infrastructure/CdkService.js +10 -8
- package/dist/src/services/infrastructure/infraTypecheckPreflight.d.ts +40 -0
- package/dist/src/services/infrastructure/infraTypecheckPreflight.js +2 -0
- package/dist/src/types/params.d.ts +3 -2
- package/package.json +4 -4
package/dist/.minified
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
234 files minified at 2026-07-22T07:34:15.296Z
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var $=Object.defineProperty;var d=(e,r)=>$(e,"name",{value:r,configurable:!0});import{success as v,failure as p}from"@fjall/generator";import{getErrorMessage as
|
|
1
|
+
var $=Object.defineProperty;var d=(e,r)=>$(e,"name",{value:r,configurable:!0});import{success as v,failure as p}from"@fjall/generator";import{getErrorMessage as c,maskSensitiveOutput as g}from"@fjall/util";import{isAborted as b}from"../../aws/organisations/types.js";import{waitForNsPropagation as z}from"./nsPropagationGate.js";import{runCaaPreflight as w}from"./caaPreflight.js";import{DomainDelegationError as h}from"./types.js";const C=4;function i(e,r,o){e.onProgress?.({type:"step",message:o,metadata:{source:"domain-delegation",stepIndex:r,totalSteps:C}})}d(i,"step");async function F(e){const{deployer:r,callbacks:o}=e,t=r.zoneName,n=e.retryCommand??`fjall domain deploy ${t}`;let m="deployed",l;const s=await r.zonePhaseState();if(s.success&&s.data.complete)m="resumed",l=s.data.nameServers,i(o,0,`Zone phase for ${t} already deployed \u2014 resuming at the propagation gate`);else{s.success||o.onProgress?.({type:"info",message:`Could not probe the zone-phase state for ${t} (${g(c(s.error))}) \u2014 deploying the zone phase (idempotent)`}),i(o,0,`Deploying ${t} zone phase (phase: "zone" \u2014 child zone + NS delegation, no certificates)`);const a=await r.deployPhase("zone",o);if(!a.success)return p(new h(`Delegated-domain deploy for ${t}: the zone-phase deploy failed: ${g(c(a.error))}. No certificate was requested. Cure: fix the reported stack failure and re-run \`${n}\` \u2014 a completed zone phase is skipped on resume.`,"zone_phase_failed",{zoneName:t},!1,a.error));const S=await r.zonePhaseState();S.success&&(l=S.data.nameServers)}if(b(e.abortSignal))return p(P(t,n));i(o,1,`Waiting for the NS delegation of ${t} to propagate (public resolvers)`);const f=await z({zoneName:t,...l!==void 0?{expectedNameServers:l}:{},...e.propagation?.maxAttempts!==void 0?{maxAttempts:e.propagation.maxAttempts}:{},...e.propagation?.intervalMs!==void 0?{intervalMs:e.propagation.intervalMs}:{},...e.resolvers!==void 0?{resolvers:e.resolvers}:{},...e.sleepFn!==void 0?{sleepFn:e.sleepFn}:{},...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{},callbacks:o,retryCommand:n});if(!f.success)return f;let y="passed";if(e.skipCaaPreflight===!0)y="skipped",o.onProgress?.({type:"warning",message:`CAA preflight for ${t} SKIPPED (skipCaaPreflight) \u2014 if a parent CAA record restricts issuance, ACM will hang until CloudFormation rolls back`});else{i(o,2,`CAA preflight for ${t} (parent chain, public resolvers)`);const a=await w({certificateDomain:t,...e.resolvers!==void 0?{resolvers:e.resolvers}:{},...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{},callbacks:o});if(!a.success)return a}if(b(e.abortSignal))return p(P(t,n));i(o,3,`Deploying ${t} full phase (phase: "full" \u2014 certificates)`);const u=await r.deployPhase("full",o);return u.success?v({zonePhase:m,propagation:f.data,caaPreflight:y,fullPhase:"deployed"}):p(new h(`Delegated-domain deploy for ${t}: the full-phase deploy failed: ${g(c(u.error))}. The zone phase is deployed and the delegation is live. Cure: fix the reported stack failure and re-run \`${n}\` \u2014 the deploy resumes at the propagation gate and re-runs only the full phase.`,"full_phase_failed",{zoneName:t},!1,u.error))}d(F,"deployDelegatedDomain");function P(e,r){return new h(`Delegated-domain deploy for ${e} aborted between phases \u2014 the zone phase (when deployed) is retained. Re-run \`${r}\` to resume.`,"aborted",{zoneName:e},!0)}d(P,"abortedMidDeploy");export{F as deployDelegatedDomain};
|
|
@@ -32,7 +32,7 @@ export type DomainDelegationErrorType = (typeof DOMAIN_DELEGATION_ERROR_TYPES)[n
|
|
|
32
32
|
*/
|
|
33
33
|
export declare class DomainDelegationError extends BaseServiceError {
|
|
34
34
|
readonly errorType: DomainDelegationErrorType;
|
|
35
|
-
constructor(message: string, errorType: DomainDelegationErrorType, details?: unknown, recoverable?: boolean);
|
|
35
|
+
constructor(message: string, errorType: DomainDelegationErrorType, details?: unknown, recoverable?: boolean, cause?: Error);
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
38
|
* One CAA record as node's resolver returns it. Structurally compatible with
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var l=Object.defineProperty;var t=(e,r)=>l(e,"name",{value:r,configurable:!0});import{Resolver as i}from"node:dns/promises";import{BaseServiceError as _}from"../../types/errors/ServiceError.js";const S=["delegation_unreachable","caa_restrictive","caa_unverifiable","zone_phase_failed","full_phase_failed","delegation_role_unusable","template_unreadable","ns_delete_failed","aborted"];class m extends _{static{t(this,"DomainDelegationError")}errorType;constructor(r,o,s,n=!1,a){super(`DOMAIN_${o.toUpperCase()}`,r,s,n,a),this.errorType=o}}const c=["1.1.1.1","8.8.8.8"],p=1e4;function N(e=c){return e.map(r=>{const o=new i({timeout:p,tries:1});return o.setServers([r]),{address:r,resolveNs:t(s=>o.resolveNs(s),"resolveNs"),resolveCaa:t(s=>o.resolveCaa(s),"resolveCaa")}})}t(N,"createPublicResolvers");function v(e){return e.trim().toLowerCase().replace(/\.$/,"")}t(v,"normaliseDnsName");const E=new Set(["ENODATA","ENOTFOUND"]);function O(e){if(!(e instanceof Error))return!1;const r=e.code;return typeof r=="string"&&E.has(r)}t(O,"isDnsEmptyAnswer");export{c as DEFAULT_PUBLIC_RESOLVER_ADDRESSES,S as DOMAIN_DELEGATION_ERROR_TYPES,m as DomainDelegationError,p as PUBLIC_DNS_QUERY_TIMEOUT_MS,N as createPublicResolvers,O as isDnsEmptyAnswer,v as normaliseDnsName};
|
|
@@ -13,6 +13,18 @@ import { type Result } from "@fjall/generator";
|
|
|
13
13
|
* affected records resolve to the conservative bucket — `unknown`, never
|
|
14
14
|
* `residue` — and the report carries a warning naming the gap and its cure.
|
|
15
15
|
*
|
|
16
|
+
* Template evidence is each live Fjall stack's Processed template body, with
|
|
17
|
+
* ONE honest widening for non-JSON bodies (hand-written YAML templates, e.g.
|
|
18
|
+
* the CDK bootstrap stack): a CloudFormation GetTemplateSummary listing no
|
|
19
|
+
* AWS::Route53::* and no Custom::* resource type PROVES the stack claims no
|
|
20
|
+
* record keys, so it indexes as contributing nothing, with no warning and
|
|
21
|
+
* template evidence still complete. The summary is absence-only evidence -
|
|
22
|
+
* it names resource types, never their properties - so a summary listing
|
|
23
|
+
* either namespace, or no readable summary at all, keeps the fail-closed
|
|
24
|
+
* warning, and a summary can never prove a positive claim (the cross-account
|
|
25
|
+
* delegation proof still needs the literal DelegatedZoneName from a JSON
|
|
26
|
+
* template).
|
|
27
|
+
*
|
|
16
28
|
* Evidence is account-local with ONE deliberate exception: callers may name
|
|
17
29
|
* delegated child domains (ClassifyZoneOptions.delegatedChildren) plus a
|
|
18
30
|
* child-account client factory, and the classifier then proves a child's
|
|
@@ -182,6 +194,13 @@ export interface DelegatedChildDomain {
|
|
|
182
194
|
* the child is skipped fail-closed with a warning naming the config cure.
|
|
183
195
|
*/
|
|
184
196
|
readonly accountId?: string;
|
|
197
|
+
/**
|
|
198
|
+
* Region the child's domain stack last deployed into. Threaded through to
|
|
199
|
+
* the ChildAccountClientFactory so child clients are minted where the
|
|
200
|
+
* stack lives; absent, the factory falls back to its own region
|
|
201
|
+
* resolution.
|
|
202
|
+
*/
|
|
203
|
+
readonly region?: string;
|
|
185
204
|
/**
|
|
186
205
|
* Child domain stack name override. Defaults to the deploy-state
|
|
187
206
|
* convention, getDomainStackName(zoneName).
|
|
@@ -193,13 +212,15 @@ export interface ChildAccountClients {
|
|
|
193
212
|
readonly cloudFormation: AwsSdkClientLike;
|
|
194
213
|
}
|
|
195
214
|
/**
|
|
196
|
-
* Mint clients for a delegated child's account.
|
|
215
|
+
* Mint clients for a delegated child's account. `region` names where the
|
|
216
|
+
* child's stack deployed (config-carried, DelegatedChildDomain.region);
|
|
217
|
+
* omitted, the factory chooses its own fallback. Any error, including an
|
|
197
218
|
* interactivity requirement (an expired SSO session must never hang a
|
|
198
219
|
* non-interactive run), resolves the Result to failure; the classifier then
|
|
199
220
|
* warns and leaves that child's NS row 'unknown' without touching its
|
|
200
221
|
* siblings.
|
|
201
222
|
*/
|
|
202
|
-
export type ChildAccountClientFactory = (accountId: string) => Promise<Result<ChildAccountClients, Error>>;
|
|
223
|
+
export type ChildAccountClientFactory = (accountId: string, region?: string) => Promise<Result<ChildAccountClients, Error>>;
|
|
203
224
|
/**
|
|
204
225
|
* Route53 returns names with a trailing dot and octal-escaped bytes
|
|
205
226
|
* (`\052` = `*`). Normalise both directions (live names AND template names)
|
|
@@ -214,6 +235,24 @@ export type ChildAccountClientFactory = (accountId: string) => Promise<Result<Ch
|
|
|
214
235
|
* backslash sequence must stay literal.
|
|
215
236
|
*/
|
|
216
237
|
export declare function normaliseRecordName(name: string): string;
|
|
238
|
+
/**
|
|
239
|
+
* Why a stack's template evidence could not be indexed. Discriminated so
|
|
240
|
+
* callers dispatch on structure, never on message text: readStackRecordKeys
|
|
241
|
+
* keys its GetTemplateSummary fallback off `non-json-template` alone, and
|
|
242
|
+
* buildSatelliteRecordIndex appends its cloudformation:GetTemplate cure only
|
|
243
|
+
* to untyped transport failures (every TemplateEvidenceError message already
|
|
244
|
+
* names its own cure inline).
|
|
245
|
+
*/
|
|
246
|
+
export type TemplateEvidenceFailureReason = "non-json-template" | "non-literal-delegation" | "missing-template-body";
|
|
247
|
+
/**
|
|
248
|
+
* Typed template-evidence failure; the message always names the cure but
|
|
249
|
+
* never the owning stack - each warning-producing caller wraps the message
|
|
250
|
+
* with the stack named, so naming it here too would double it.
|
|
251
|
+
*/
|
|
252
|
+
export declare class TemplateEvidenceError extends Error {
|
|
253
|
+
readonly reason: TemplateEvidenceFailureReason;
|
|
254
|
+
constructor(reason: TemplateEvidenceFailureReason, message: string);
|
|
255
|
+
}
|
|
217
256
|
/**
|
|
218
257
|
* Extract every (name, type, setIdentifier) a CloudFormation template
|
|
219
258
|
* declares via `AWS::Route53::RecordSet` or `AWS::Route53::RecordSetGroup`.
|
|
@@ -238,7 +277,10 @@ export declare function normaliseRecordName(name: string): string;
|
|
|
238
277
|
* and stays success(empty). Deliberately no YAML parsing here: correct
|
|
239
278
|
* CloudFormation YAML requires the short-form intrinsic tag schema, a
|
|
240
279
|
* dependency and correctness surface of its own, so the cure named in the
|
|
241
|
-
* failure is conversion, not a lossy parse.
|
|
280
|
+
* failure is conversion, not a lossy parse. Failures are typed
|
|
281
|
+
* ({@link TemplateEvidenceError}); readStackRecordKeys dispatches on the
|
|
282
|
+
* `non-json-template` reason to mount its absence-only GetTemplateSummary
|
|
283
|
+
* fallback (see module doc).
|
|
242
284
|
*/
|
|
243
285
|
export declare function extractTemplateRecordKeys(templateBody: string): Result<Set<string>, Error>;
|
|
244
286
|
/**
|
|
@@ -246,7 +288,11 @@ export declare function extractTemplateRecordKeys(templateBody: string): Result<
|
|
|
246
288
|
* record their CURRENT deployed templates declare. Read-only; build once per
|
|
247
289
|
* run and share across zones (D6 "cached per run"). Enumeration or template
|
|
248
290
|
* failures degrade to warnings — affected records classify `unknown`, never
|
|
249
|
-
* `satellite` or `residue`.
|
|
291
|
+
* `satellite` or `residue`. A non-JSON template proven record-free by its
|
|
292
|
+
* resource-type summary (see readStackRecordKeys) indexes as contributing
|
|
293
|
+
* nothing with NO warning: warnings here flip templateEvidenceComplete and
|
|
294
|
+
* suppress dangling-alias residue probing zone-wide, so only genuine
|
|
295
|
+
* evidence gaps may land in them.
|
|
250
296
|
*/
|
|
251
297
|
export declare function buildSatelliteRecordIndex(cloudFormation: AwsSdkClientLike, options?: {
|
|
252
298
|
readonly excludeStackNames?: readonly string[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var B=Object.defineProperty;var u=(t,e)=>B(t,"name",{value:e,configurable:!0});import{ListResourceRecordSetsCommand as U}from"@aws-sdk/client-route-53";import{DescribeStacksCommand as P,DescribeStackResourcesCommand as G,GetTemplateCommand as W}from"@aws-sdk/client-cloudformation";import{ListCertificatesCommand as Z,DescribeCertificateCommand as q}from"@aws-sdk/client-acm";import{Resolver as J}from"node:dns/promises";import{success as T,failure as A}from"@fjall/generator";import{maskSensitiveOutput as N,getErrorMessage as y,getDomainExportNames as X,getDomainStackName as Y}from"@fjall/util";import{composeSdkAbortSignal as E,isAborted as I}from"../../aws/organisations/types.js";import{CROSS_ACCOUNT_DELEGATION_RESOURCE_TYPE as V}from"./delegationDestroyMirror.js";const H=5e3,Q=2;async function ee(t,e,n){const a=new J({timeout:H,tries:Q}),r=u(()=>{a.cancel()},"cancel");n?.addEventListener("abort",r,{once:!0});try{return e==="A"?await a.resolve4(t):await a.resolve6(t)}finally{n?.removeEventListener("abort",r)}}u(ee,"resolveWithSystemResolver");function te(t=ee){const e=u(async(n,a,r)=>{try{return(await t(n,a,r)).length>0?"answers":"indeterminate"}catch(c){const o=c.code;return o==="ENOTFOUND"?"nxdomain":o==="ENODATA"?"nodata":"indeterminate"}},"queryFamily");return async(n,a)=>{const r=await e(n,"A",a);if(r==="answers")return"resolves";if(r==="nxdomain")return"nxdomain";if(r==="indeterminate")return"indeterminate";const c=await e(n,"AAAA",a);return c==="answers"?"resolves":c==="nxdomain"?"nxdomain":"indeterminate"}}u(te,"createSystemDnsNameProbe");const ne=te(),j=new Set(["CREATE_COMPLETE","UPDATE_COMPLETE","UPDATE_ROLLBACK_COMPLETE","IMPORT_COMPLETE","IMPORT_ROLLBACK_COMPLETE","UPDATE_IN_PROGRESS","UPDATE_COMPLETE_CLEANUP_IN_PROGRESS","UPDATE_ROLLBACK_IN_PROGRESS","UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS"]),ae="fjall:",oe="Fjall",re=/^_[0-9a-f]{8,64}$/,se=".acm-validations.aws",ie=[/\.cloudfront\.net$/,/\.elb\.amazonaws\.com$/,/\.elb\.[a-z0-9-]+\.amazonaws\.com$/];function b(t){const e=t.replace(/\\(\d{3})/g,(a,r)=>String.fromCharCode(parseInt(r,8)));return(e.endsWith(".")?e.slice(0,-1):e).toLowerCase()}u(b,"normaliseRecordName");function C(t,e,n){return`${b(t)}|${e.toUpperCase()}|${n??""}`}u(C,"recordKey");async function ce(t,e,n){const a=[];let r,c,o;do{if(I(n))throw new Error("record listing aborted by shutdown signal");const i=await t.send(new U({HostedZoneId:e,StartRecordName:r,StartRecordType:c,StartRecordIdentifier:o}),{abortSignal:E(n)});for(const s of i.ResourceRecordSets??[]){if(!s.Name||!s.Type)continue;const f=s.AliasTarget?.DNSName!==void 0?b(s.AliasTarget.DNSName):void 0,l=s.AliasTarget?.DNSName!==void 0?[s.AliasTarget.DNSName]:(s.ResourceRecords??[]).flatMap(h=>h.Value!==void 0?[h.Value]:[]);a.push({name:b(s.Name),type:s.Type,...s.SetIdentifier!==void 0?{setIdentifier:s.SetIdentifier}:{},values:l,...f!==void 0?{aliasTargetDnsName:f}:{}})}i.IsTruncated?(r=i.NextRecordName,c=i.NextRecordType,o=i.NextRecordIdentifier):(r=void 0,c=void 0,o=void 0)}while(r);return a}u(ce,"listLiveRecords");function de(t){const e=new Set;let n;try{n=JSON.parse(t)}catch{return A(new Error("stack template is not JSON (likely a hand-written YAML template), so its Route53 record declarations cannot be indexed. Cure: convert the template to JSON or accept conservative classification."))}if(typeof n!="object"||n===null)return T(e);const a=n.Resources;if(typeof a!="object"||a===null)return T(e);const r=u(c=>{const o=c.Name,i=c.Type,s=c.SetIdentifier;typeof o!="string"||typeof i!="string"||s!==void 0&&typeof s!="string"||e.add(C(o,i,s))},"addKey");for(const[c,o]of Object.entries(a)){if(typeof o!="object"||o===null)continue;const{Type:i,Properties:s}=o,f=typeof s=="object"&&s!==null?s:void 0;if(i===V){const l=f?.DelegatedZoneName;if(typeof l!="string")return A(new Error(`${V} resource '${c}' has no literal DelegatedZoneName, so the parent-zone NS row it writes cannot be indexed. Cure: synthesise the delegation with a literal delegated zone name or accept conservative classification.`));e.add(C(l,"NS"));continue}if(f!==void 0){if(i==="AWS::Route53::RecordSet")r(f);else if(i==="AWS::Route53::RecordSetGroup"){const l=f.RecordSets;if(!Array.isArray(l))continue;for(const h of l)typeof h=="object"&&h!==null&&r(h)}}}return T(e)}u(de,"extractTemplateRecordKeys");async function _(t,e,n){try{const a=await t.send(new W({StackName:e,TemplateStage:"Processed"}),{abortSignal:E(n)});if(a.TemplateBody===void 0)return A(new Error(`zone classification: CloudFormation returned no template body for stack '${e}'`));const r=de(a.TemplateBody);return r.success?r:A(new Error(`stack '${e}': ${r.error.message}`))}catch(a){return A(a instanceof Error?a:new Error(String(a)))}}u(_,"readStackRecordKeys");function le(t){return t.Tags?.some(e=>e.Key?.startsWith(ae))===!0?!0:t.StackName?.startsWith(oe)===!0}u(le,"isFjallStack");async function ue(t,e={}){const n=new Map,a=[],r=new Set(e.excludeStackNames??[]),c=[];try{let o;do{if(I(e.abortSignal))throw new Error("stack enumeration aborted by shutdown signal");const i=await t.send(new P({...o!==void 0?{NextToken:o}:{}}),{abortSignal:E(e.abortSignal)});for(const s of i.Stacks??[])s.StackName===void 0||r.has(s.StackName)||s.StackStatus===void 0||!j.has(s.StackStatus)||!le(s)||c.push(s.StackName);o=i.NextToken}while(o!==void 0)}catch(o){return a.push(N(`zone classification: could not enumerate CloudFormation stacks (${y(o)}) \u2014 satellite ownership cannot be proven, so unmatched records classify 'unknown'. Cure: grant cloudformation:DescribeStacks and retry.`)),{byRecordKey:n,stackNames:[],warnings:a}}for(const o of c){const i=await _(t,o,e.abortSignal);if(!i.success){a.push(N(`zone classification: could not read template for stack '${o}' (${y(i.error)}) \u2014 its records classify 'unknown'. Cure: grant cloudformation:GetTemplate and retry.`));continue}for(const s of i.data)n.has(s)||n.set(s,o)}return{byRecordKey:n,stackNames:c,warnings:a}}u(ue,"buildSatelliteRecordIndex");function F(t){return new Set(t.map(e=>b(e.trim())).filter(e=>e!==""))}u(F,"normaliseNsValueSet");function fe(t,e){if(t.size!==e.size)return!1;for(const n of t)if(!e.has(n))return!1;return!0}u(fe,"nsValueSetsEqual");async function me(t){const{factory:e,accountId:n,zoneName:a,stackName:r,abortSignal:c}=t,o=u(p=>A(new Error(`zone classification: cross-account delegation for '${a}': ${p}`)),"fail");let i;try{const p=await e(n);if(!p.success)return o(`account ${n} is unreachable (${y(p.error)}), so the NS row classifies 'unknown'. Cure: restore access to account ${n} and retry.`);i=p.data}catch(p){return o(`account ${n} is unreachable (${y(p)}), so the NS row classifies 'unknown'. Cure: restore access to account ${n} and retry.`)}let s;try{s=await i.cloudFormation.send(new P({StackName:r}),{abortSignal:E(c)})}catch(p){return o(`could not describe stack '${r}' in account ${n} (${y(p)}), so the NS row classifies 'unknown'. Cure: deploy the child with 'fjall domain deploy ${a}' or grant cloudformation:DescribeStacks in that account.`)}const f=s.Stacks?.[0];if(f===void 0)return o(`stack '${r}' was not found in account ${n}, so the NS row classifies 'unknown'. Cure: deploy the child with 'fjall domain deploy ${a}'.`);if(f.StackStatus===void 0||!j.has(f.StackStatus))return o(`stack '${r}' in account ${n} has status ${f.StackStatus??"unknown"}, which does not describe live resources, so the NS row classifies 'unknown'. Cure: repair the child stack, then redeploy with 'fjall domain deploy ${a}'.`);const l=await _(i.cloudFormation,r,c);if(!l.success)return o(`template evidence failed in account ${n} (${y(l.error)}), so the NS row classifies 'unknown'.`);if(!l.data.has(C(a,"NS")))return o(`stack '${r}' in account ${n} does not claim the NS row for '${a}', so the row classifies 'unknown'. Cure: redeploy the child with 'fjall domain deploy ${a}' so its delegation resource is current.`);const h=X(a).nameservers,k=(f.Outputs??[]).find(p=>p.ExportName===h)?.OutputValue;if(k===void 0||k.trim()==="")return o(`stack '${r}' in account ${n} publishes no '${h}' output, so the delegated nameserver values cannot be verified and the NS row classifies 'unknown'. Cure: redeploy the child with 'fjall domain deploy ${a}' so the export contract is current.`);const R=F(k.split(",")),$=F(t.liveNsValues);if(!fe(R,$)){const p=u(v=>`[${[...v].sort().join(", ")}]`,"quote");return o(`stack '${r}' in account ${n} publishes nameservers ${p(R)} but the live NS row holds ${p($)}, so the row classifies 'unknown'. Cure: re-run 'fjall domain deploy ${a}' so parent and child agree.`)}return T({ownerStack:r,accountId:n})}u(me,"proveCrossAccountDelegation");async function we(t,e){const n=new Map;for(const[a,r]of t)try{const c=[];let o;do{if(I(e))throw new Error("certificate listing aborted by shutdown signal");const i=await r.send(new Z({...o!==void 0?{NextToken:o}:{}}),{abortSignal:E(e)});for(const s of i.CertificateSummaryList??[])s.CertificateArn!==void 0&&c.push(s.CertificateArn);o=i.NextToken}while(o!==void 0);for(const i of c){if(I(e))throw new Error("certificate description aborted by shutdown signal");const s=await r.send(new q({CertificateArn:i}),{abortSignal:E(e)});for(const f of s.Certificate?.DomainValidationOptions??[]){const l=f.ResourceRecord;if(l?.Type==="CNAME"&&l.Name!==void 0){const h=b(l.Name),k=n.get(h)??new Set;k.add(i),n.set(h,k)}}}}catch(c){return A(new Error(`zone classification: could not read ACM certificates in ${a} (${y(c)}). Cure: grant acm:ListCertificates and acm:DescribeCertificate in ${a}, then retry.`))}return T(n)}u(we,"collectAcmValidationReferences");async function pe(t,e,n,a){const r=new Map,c=[];for(const o of e){if(r.size===n.size)break;try{if(I(a))throw new Error("stack resource listing aborted by shutdown signal");const i=await t.send(new G({StackName:o}),{abortSignal:E(a)});for(const s of i.StackResources??[])s.ResourceType!=="AWS::CertificateManager::Certificate"||s.PhysicalResourceId===void 0||!n.has(s.PhysicalResourceId)||r.has(s.PhysicalResourceId)||r.set(s.PhysicalResourceId,o)}catch(i){c.push(N(`zone classification: could not list resources for stack '${o}' (${y(i)}), so certificate ownership cannot be proven there and its validation CNAMEs stay 'unknown'. Cure: grant cloudformation:DescribeStackResources and retry.`))}}return{ownerByArn:r,warnings:c}}u(pe,"resolveCertificateOwnership");function D(t){if(t.type!=="CNAME")return!1;const e=t.name.split(".")[0]??"";return re.test(e)?t.values.some(n=>b(n).endsWith(se)):!1}u(D,"isAcmValidationShaped");function K(t){if(t.type!=="A"&&t.type!=="AAAA")return!1;const e=t.aliasTargetDnsName;return e===void 0?!1:ie.some(n=>n.test(e))}u(K,"isAwsManagedAliasCandidate");async function Te(t){const{zone:e,domainStackName:n,clients:a,abortSignal:r,onPhase:c}=t,o=[];c?.("read-records");let i;try{i=await ce(a.route53,e.hostedZoneId,r)}catch(d){return A(new Error(`zone classification: could not list record sets for zone ${e.hostedZoneId} (${e.zoneName}): ${y(d)}. Cure: grant route53:ListResourceRecordSets on the zone and retry.`))}let s=new Set,f=!0;if(n!==void 0){const d=await _(a.cloudFormation,n,r);d.success?s=d.data:(f=!1,o.push(N(`zone classification: could not read the domain stack template for '${n}' (${y(d.error)}) \u2014 only zone-owned SOA/NS classify 'declared'. Cure: deploy the domain stack with 'fjall domain deploy ${e.zoneName}' or grant cloudformation:GetTemplate.`)))}c?.("index-stacks");const l=t.satelliteIndex??await ue(a.cloudFormation,{...n!==void 0?{excludeStackNames:[n]}:{},...r!==void 0?{abortSignal:r}:{}});o.push(...l.warnings);const h=new Map;if(t.delegatedChildren!==void 0){const d=new Map;for(const m of i)m.type==="NS"&&d.set(C(m.name,m.type,m.setIdentifier),m.values);const w=t.delegatedChildren.filter(m=>{const g=C(m.zoneName,"NS");return d.has(g)&&!s.has(g)&&!l.byRecordKey.has(g)}),S=t.childAccountClientFactory;if(w.length>0&&S===void 0)o.push(N("zone classification: delegated child domains are configured but no child-account client factory was wired, so cross-account delegations cannot be verified and their NS rows classify 'unknown'. Cure: supply childAccountClientFactory in ClassifyZoneOptions."));else if(S!==void 0)for(const m of w){if(m.accountId===void 0){o.push(N(`zone classification: delegated child '${m.zoneName}' has no AWS account id, so its cross-account delegation cannot be verified and its NS row classifies 'unknown'. Cure: set "account" on the delegated domain entry in fjall-config.json.`));continue}const g=C(m.zoneName,"NS"),O=await me({factory:S,accountId:m.accountId,zoneName:m.zoneName,stackName:m.stackName??Y(m.zoneName),liveNsValues:d.get(g)??[],...r!==void 0?{abortSignal:r}:{}});if(!O.success){o.push(N(y(O.error)));continue}h.set(g,O.data)}}c?.("check-acm");const k=i.some(D);let R;if(k){const d=await we(a.acmByRegion,r);d.success?R=d.data:o.push(N(d.error.message))}let $;if(R!==void 0){const d=new Set;for(const w of i)if(D(w))for(const S of R.get(w.name)??[])d.add(S);if(d.size>0){const w=await pe(a.cloudFormation,[...n!==void 0?[n]:[],...l.stackNames],d,r);$=w.ownerByArn,o.push(...w.warnings)}}const p=f&&l.warnings.length===0,v=new Set;if(p)for(const d of i){if(!K(d))continue;const w=C(d.name,d.type,d.setIdentifier);s.has(w)||l.byRecordKey.has(w)||v.add(d.aliasTargetDnsName)}let x;if(v.size>0){const d=t.dnsProbe??ne,w=new Map;for(const S of v){let m;try{m=await d(S,r)}catch(g){m="indeterminate",o.push(N(`zone classification: DNS probe for alias target '${S}' failed (${y(g)}) \u2014 dependent alias records classify 'unknown', never 'residue'. Cure: retry with a working resolver.`)),w.set(S,m);continue}m==="indeterminate"&&o.push(N(`zone classification: DNS probe for alias target '${S}' was inconclusive \u2014 dependent alias records classify 'unknown', never 'residue'. Cure: retry once the resolver answers definitively.`)),w.set(S,m)}x=w}c?.("classify");const z=b(e.zoneName),M=i.map(d=>ye(d,{zoneApex:z,domainStackName:n,declaredKeys:s,satelliteIndex:l,crossAccountDelegations:h,acmValidationReferences:R,certificateOwnerByArn:$,templateEvidenceComplete:p,aliasProbeVerdicts:x})).sort((d,w)=>d.name.localeCompare(w.name)||d.type.localeCompare(w.type)||(d.setIdentifier??"").localeCompare(w.setIdentifier??"")),L={declared:0,satellite:0,residue:0,unknown:0};for(const d of M)L[d.classification]+=1;return T({hostedZoneId:e.hostedZoneId,zoneName:z,records:M,counts:L,warnings:o})}u(Te,"classifyZoneRecords");function he(t,e){const n=new Set;for(const a of t){const r=e?.get(a);if(r===void 0)return;n.add(r)}return n}u(he,"provenCertificateOwners");function ye(t,e){const n=C(t.name,t.type,t.setIdentifier),a={name:t.name,type:t.type,...t.setIdentifier!==void 0?{setIdentifier:t.setIdentifier}:{},values:t.values};if(t.name===e.zoneApex&&(t.type==="SOA"||t.type==="NS"))return{...a,classification:"declared",...e.domainStackName!==void 0?{ownerStack:e.domainStackName}:{},detail:"zone-owned record, created with the hosted zone"};if(e.declaredKeys.has(n))return{...a,classification:"declared",...e.domainStackName!==void 0?{ownerStack:e.domainStackName}:{}};const r=e.satelliteIndex.byRecordKey.get(n);if(r!==void 0)return{...a,classification:"satellite",ownerStack:r};const c=e.crossAccountDelegations.get(n);if(c!==void 0)return{...a,classification:"satellite",ownerStack:c.ownerStack,detail:`cross-account delegation: stack ${c.ownerStack} in account ${c.accountId}`};if(D(t)){if(e.acmValidationReferences===void 0)return{...a,classification:"unknown",detail:"ACM validation CNAME shape, but live-certificate evidence was unavailable \u2014 fail-closed as unknown rather than residue"};const o=e.acmValidationReferences.get(t.name);if(o!==void 0&&o.size>0){const i=he(o,e.certificateOwnerByArn);if(i!==void 0){const s=[...i].sort();if(e.domainStackName!==void 0&&s.every(l=>l===e.domainStackName))return{...a,classification:"declared",ownerStack:e.domainStackName,detail:"ACM validation CNAME for the domain stack's live certificates; required for issuance and auto-renewal"};const f=s.find(l=>l!==e.domainStackName);if(f!==void 0)return{...a,classification:"satellite",ownerStack:f,detail:`ACM validation CNAME for live certificates owned by ${s.map(l=>`'${l}'`).join(", ")}; required for issuance and auto-renewal`}}return{...a,classification:"unknown",detail:"ACM validation CNAME still referenced by a live certificate \u2014 keep it; it is not residue"}}return{...a,classification:"residue",detail:"ACM validation CNAME not referenced by any live certificate in the scanned regions"}}if(K(t)){if(!e.templateEvidenceComplete)return{...a,classification:"unknown",detail:"alias to an AWS-managed target, but template evidence was incomplete \u2014 fail-closed as unknown rather than residue"};const o=e.aliasProbeVerdicts?.get(t.aliasTargetDnsName);return o==="nxdomain"?{...a,classification:"residue",detail:`dangling alias: target ${t.aliasTargetDnsName} no longer exists (NXDOMAIN) and no live template references this record`}:o==="resolves"?{...a,classification:"unknown",detail:"alias target still exists in DNS, so the record cannot be dangling; not residue"}:{...a,classification:"unknown",detail:"alias target probe was inconclusive \u2014 fail-closed as unknown rather than residue"}}return{...a,classification:"unknown"}}u(ye,"classifyRecord");export{ue as buildSatelliteRecordIndex,Te as classifyZoneRecords,te as createSystemDnsNameProbe,de as extractTemplateRecordKeys,b as normaliseRecordName,ne as systemDnsNameProbe};
|
|
1
|
+
var U=Object.defineProperty;var u=(t,e)=>U(t,"name",{value:e,configurable:!0});import{ListResourceRecordSetsCommand as W}from"@aws-sdk/client-route-53";import{DescribeStacksCommand as V,DescribeStackResourcesCommand as G,GetTemplateCommand as J,GetTemplateSummaryCommand as Z}from"@aws-sdk/client-cloudformation";import{ListCertificatesCommand as q,DescribeCertificateCommand as X}from"@aws-sdk/client-acm";import{Resolver as Y}from"node:dns/promises";import{success as v,failure as A}from"@fjall/generator";import{maskSensitiveOutput as N,getErrorMessage as h,getDomainExportNames as H,getDomainStackName as Q}from"@fjall/util";import{composeSdkAbortSignal as C,isAborted as b}from"../../aws/organisations/types.js";import{CROSS_ACCOUNT_DELEGATION_RESOURCE_TYPE as j}from"./delegationDestroyMirror.js";const ee=5e3,te=2;async function ne(t,e,n){const r=new Y({timeout:ee,tries:te}),a=u(()=>{r.cancel()},"cancel");n?.addEventListener("abort",a,{once:!0});try{return e==="A"?await r.resolve4(t):await r.resolve6(t)}finally{n?.removeEventListener("abort",a)}}u(ne,"resolveWithSystemResolver");function ae(t=ne){const e=u(async(n,r,a)=>{try{return(await t(n,r,a)).length>0?"answers":"indeterminate"}catch(s){const o=s.code;return o==="ENOTFOUND"?"nxdomain":o==="ENODATA"?"nodata":"indeterminate"}},"queryFamily");return async(n,r)=>{const a=await e(n,"A",r);if(a==="answers")return"resolves";if(a==="nxdomain")return"nxdomain";if(a==="indeterminate")return"indeterminate";const s=await e(n,"AAAA",r);return s==="answers"?"resolves":s==="nxdomain"?"nxdomain":"indeterminate"}}u(ae,"createSystemDnsNameProbe");const re=ae(),F=new Set(["CREATE_COMPLETE","UPDATE_COMPLETE","UPDATE_ROLLBACK_COMPLETE","IMPORT_COMPLETE","IMPORT_ROLLBACK_COMPLETE","UPDATE_IN_PROGRESS","UPDATE_COMPLETE_CLEANUP_IN_PROGRESS","UPDATE_ROLLBACK_IN_PROGRESS","UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS"]),oe="fjall:",se="Fjall",ie=/^_[0-9a-f]{8,64}$/,ce=".acm-validations.aws",de=[/\.cloudfront\.net$/,/\.elb\.amazonaws\.com$/,/\.elb\.[a-z0-9-]+\.amazonaws\.com$/];function T(t){const e=t.replace(/\\(\d{3})/g,(r,a)=>String.fromCharCode(parseInt(a,8)));return(e.endsWith(".")?e.slice(0,-1):e).toLowerCase()}u(T,"normaliseRecordName");function R(t,e,n){return`${T(t)}|${e.toUpperCase()}|${n??""}`}u(R,"recordKey");async function le(t,e,n){const r=[];let a,s,o;do{if(b(n))throw new Error("record listing aborted by shutdown signal");const c=await t.send(new W({HostedZoneId:e,StartRecordName:a,StartRecordType:s,StartRecordIdentifier:o}),{abortSignal:C(n)});for(const i of c.ResourceRecordSets??[]){if(!i.Name||!i.Type)continue;const p=i.AliasTarget?.DNSName!==void 0?T(i.AliasTarget.DNSName):void 0,l=i.AliasTarget?.DNSName!==void 0?[i.AliasTarget.DNSName]:(i.ResourceRecords??[]).flatMap(w=>w.Value!==void 0?[w.Value]:[]);r.push({name:T(i.Name),type:i.Type,...i.SetIdentifier!==void 0?{setIdentifier:i.SetIdentifier}:{},values:l,...p!==void 0?{aliasTargetDnsName:p}:{}})}c.IsTruncated?(a=c.NextRecordName,s=c.NextRecordType,o=c.NextRecordIdentifier):(a=void 0,s=void 0,o=void 0)}while(a);return r}u(le,"listLiveRecords");class $ extends Error{static{u(this,"TemplateEvidenceError")}reason;constructor(e,n){super(n),this.reason=e,this.name="TemplateEvidenceError"}}function ue(t){const e=new Set;let n;try{n=JSON.parse(t)}catch{return A(new $("non-json-template","stack template is not JSON (likely a hand-written YAML template), so its Route53 record declarations cannot be indexed. Cure: convert the template to JSON or accept conservative classification."))}if(typeof n!="object"||n===null)return v(e);const r=n.Resources;if(typeof r!="object"||r===null)return v(e);const a=u(s=>{const o=s.Name,c=s.Type,i=s.SetIdentifier;typeof o!="string"||typeof c!="string"||i!==void 0&&typeof i!="string"||e.add(R(o,c,i))},"addKey");for(const[s,o]of Object.entries(r)){if(typeof o!="object"||o===null)continue;const{Type:c,Properties:i}=o,p=typeof i=="object"&&i!==null?i:void 0;if(c===j){const l=p?.DelegatedZoneName;if(typeof l!="string")return A(new $("non-literal-delegation",`${j} resource '${s}' has no literal DelegatedZoneName, so the parent-zone NS row it writes cannot be indexed. Cure: synthesise the delegation with a literal delegated zone name or accept conservative classification.`));e.add(R(l,"NS"));continue}if(p!==void 0){if(c==="AWS::Route53::RecordSet")a(p);else if(c==="AWS::Route53::RecordSetGroup"){const l=p.RecordSets;if(!Array.isArray(l))continue;for(const w of l)typeof w=="object"&&w!==null&&a(w)}}}return v(e)}u(ue,"extractTemplateRecordKeys");function fe(t){return t.startsWith("AWS::Route53::")||t.startsWith("Custom::")||t==="AWS::CloudFormation::CustomResource"}u(fe,"isRecordClaimingResourceType");async function me(t,e,n){const r=u((o,c)=>A(new $("non-json-template",`template is not JSON (likely a hand-written YAML template) and ${o}, so its record declarations cannot be indexed. Cure: ${c}`)),"failNonJson");if(b(n))return r("the resource-type summary check was aborted by the shutdown signal","retry, convert the template to JSON, or accept conservative classification.");let a;try{a=(await t.send(new Z({StackName:e}),{abortSignal:C(n)})).ResourceTypes}catch(o){return b(n)?r("the resource-type summary read was aborted by the shutdown signal","retry, convert the template to JSON, or accept conservative classification."):r(`its resource-type summary could not be read (${h(o)})`,"grant cloudformation:GetTemplateSummary and retry, convert the template to JSON, or accept conservative classification.")}if(a===void 0)return r("its resource-type summary carried no resource types","convert the template to JSON or accept conservative classification.");const s=[...new Set(a.filter(fe))].sort();return s.length>0?r(`its resource-type summary lists record-claiming types (${s.join(", ")}) whose keys a summary cannot name`,"convert the template to JSON or accept conservative classification."):v(new Set)}u(me,"proveRecordFreeViaResourceTypeSummary");async function x(t,e,n){let r;try{const s=await t.send(new J({StackName:e,TemplateStage:"Processed"}),{abortSignal:C(n)});if(s.TemplateBody===void 0)return A(new $("missing-template-body","CloudFormation returned no template body. Cure: retry, or accept conservative classification."));r=s.TemplateBody}catch(s){return A(s instanceof Error?s:new Error(String(s)))}const a=ue(r);return a.success?a:a.error instanceof $&&a.error.reason==="non-json-template"?me(t,e,n):a}u(x,"readStackRecordKeys");function pe(t){return t.Tags?.some(e=>e.Key?.startsWith(oe))===!0?!0:t.StackName?.startsWith(se)===!0}u(pe,"isFjallStack");async function ye(t,e={}){const n=new Map,r=[],a=new Set(e.excludeStackNames??[]),s=[];try{let o;do{if(b(e.abortSignal))throw new Error("stack enumeration aborted by shutdown signal");const c=await t.send(new V({...o!==void 0?{NextToken:o}:{}}),{abortSignal:C(e.abortSignal)});for(const i of c.Stacks??[])i.StackName===void 0||a.has(i.StackName)||i.StackStatus===void 0||!F.has(i.StackStatus)||!pe(i)||s.push(i.StackName);o=c.NextToken}while(o!==void 0)}catch(o){return r.push(N(`zone classification: could not enumerate CloudFormation stacks (${h(o)}) \u2014 satellite ownership cannot be proven, so unmatched records classify 'unknown'. Cure: grant cloudformation:DescribeStacks and retry.`)),{byRecordKey:n,stackNames:[],warnings:r}}for(const o of s){const c=await x(t,o,e.abortSignal);if(!c.success){const i=c.error instanceof $?"":" Cure: grant cloudformation:GetTemplate and retry.";r.push(N(`zone classification: could not read template evidence for stack '${o}' (${h(c.error)}) \u2014 its records classify 'unknown'.${i}`));continue}for(const i of c.data)n.has(i)||n.set(i,o)}return{byRecordKey:n,stackNames:s,warnings:r}}u(ye,"buildSatelliteRecordIndex");function K(t){return new Set(t.map(e=>T(e.trim())).filter(e=>e!==""))}u(K,"normaliseNsValueSet");function we(t,e){if(t.size!==e.size)return!1;for(const n of t)if(!e.has(n))return!1;return!0}u(we,"nsValueSetsEqual");async function he(t){const{factory:e,accountId:n,region:r,zoneName:a,stackName:s,abortSignal:o}=t,c=u(y=>A(new Error(`zone classification: cross-account delegation for '${a}': ${y}`)),"fail");let i;try{const y=await e(n,r);if(!y.success)return c(`account ${n} is unreachable (${h(y.error)}), so the NS row classifies 'unknown'. Cure: restore access to account ${n} and retry.`);i=y.data}catch(y){return c(`account ${n} is unreachable (${h(y)}), so the NS row classifies 'unknown'. Cure: restore access to account ${n} and retry.`)}let p;try{p=await i.cloudFormation.send(new V({StackName:s}),{abortSignal:C(o)})}catch(y){return c(`could not describe stack '${s}' in account ${n} (${h(y)}), so the NS row classifies 'unknown'. Cure: deploy the child with 'fjall domain deploy ${a}' or grant cloudformation:DescribeStacks in that account.`)}const l=p.Stacks?.[0];if(l===void 0)return c(`stack '${s}' was not found in account ${n}, so the NS row classifies 'unknown'. Cure: deploy the child with 'fjall domain deploy ${a}'.`);if(l.StackStatus===void 0||!F.has(l.StackStatus))return c(`stack '${s}' in account ${n} has status ${l.StackStatus??"unknown"}, which does not describe live resources, so the NS row classifies 'unknown'. Cure: repair the child stack, then redeploy with 'fjall domain deploy ${a}'.`);const w=await x(i.cloudFormation,s,o);if(!w.success)return c(`template evidence for stack '${s}' failed in account ${n} (${h(w.error)}), so the NS row classifies 'unknown'.`);if(!w.data.has(R(a,"NS")))return c(`stack '${s}' in account ${n} does not claim the NS row for '${a}', so the row classifies 'unknown'. Cure: redeploy the child with 'fjall domain deploy ${a}' so its delegation resource is current.`);const E=H(a).nameservers,k=(l.Outputs??[]).find(y=>y.ExportName===E)?.OutputValue;if(k===void 0||k.trim()==="")return c(`stack '${s}' in account ${n} publishes no '${E}' output, so the delegated nameserver values cannot be verified and the NS row classifies 'unknown'. Cure: redeploy the child with 'fjall domain deploy ${a}' so the export contract is current.`);const I=K(k.split(",")),O=K(t.liveNsValues);if(!we(I,O)){const y=u(_=>`[${[..._].sort().join(", ")}]`,"quote");return c(`stack '${s}' in account ${n} publishes nameservers ${y(I)} but the live NS row holds ${y(O)}, so the row classifies 'unknown'. Cure: re-run 'fjall domain deploy ${a}' so parent and child agree.`)}return v({ownerStack:s,accountId:n})}u(he,"proveCrossAccountDelegation");async function Se(t,e){const n=new Map;for(const[r,a]of t)try{const s=[];let o;do{if(b(e))throw new Error("certificate listing aborted by shutdown signal");const c=await a.send(new q({...o!==void 0?{NextToken:o}:{}}),{abortSignal:C(e)});for(const i of c.CertificateSummaryList??[])i.CertificateArn!==void 0&&s.push(i.CertificateArn);o=c.NextToken}while(o!==void 0);for(const c of s){if(b(e))throw new Error("certificate description aborted by shutdown signal");const i=await a.send(new X({CertificateArn:c}),{abortSignal:C(e)});for(const p of i.Certificate?.DomainValidationOptions??[]){const l=p.ResourceRecord;if(l?.Type==="CNAME"&&l.Name!==void 0){const w=T(l.Name),E=n.get(w)??new Set;E.add(c),n.set(w,E)}}}}catch(s){return A(new Error(`zone classification: could not read ACM certificates in ${r} (${h(s)}). Cure: grant acm:ListCertificates and acm:DescribeCertificate in ${r}, then retry.`))}return v(n)}u(Se,"collectAcmValidationReferences");async function Ne(t,e,n,r){const a=new Map,s=[];for(const o of e){if(a.size===n.size)break;try{if(b(r))throw new Error("stack resource listing aborted by shutdown signal");const c=await t.send(new G({StackName:o}),{abortSignal:C(r)});for(const i of c.StackResources??[])i.ResourceType!=="AWS::CertificateManager::Certificate"||i.PhysicalResourceId===void 0||!n.has(i.PhysicalResourceId)||a.has(i.PhysicalResourceId)||a.set(i.PhysicalResourceId,o)}catch(c){s.push(N(`zone classification: could not list resources for stack '${o}' (${h(c)}), so certificate ownership cannot be proven there and its validation CNAMEs stay 'unknown'. Cure: grant cloudformation:DescribeStackResources and retry.`))}}return{ownerByArn:a,warnings:s}}u(Ne,"resolveCertificateOwnership");function z(t){if(t.type!=="CNAME")return!1;const e=t.name.split(".")[0]??"";return ie.test(e)?t.values.some(n=>T(n).endsWith(ce)):!1}u(z,"isAcmValidationShaped");function B(t){if(t.type!=="A"&&t.type!=="AAAA")return!1;const e=t.aliasTargetDnsName;return e===void 0?!1:de.some(n=>n.test(e))}u(B,"isAwsManagedAliasCandidate");async function Oe(t){const{zone:e,domainStackName:n,clients:r,abortSignal:a,onPhase:s}=t,o=[];s?.("read-records");let c;try{c=await le(r.route53,e.hostedZoneId,a)}catch(d){return A(new Error(`zone classification: could not list record sets for zone ${e.hostedZoneId} (${e.zoneName}): ${h(d)}. Cure: grant route53:ListResourceRecordSets on the zone and retry.`))}let i=new Set,p=!0;if(n!==void 0){const d=await x(r.cloudFormation,n,a);if(d.success)i=d.data;else{p=!1;const m=d.error instanceof $?"":" or grant cloudformation:GetTemplate";o.push(N(`zone classification: could not read the domain stack template for '${n}' (${h(d.error)}) \u2014 only zone-owned SOA/NS classify 'declared'. Cure: deploy the domain stack with 'fjall domain deploy ${e.zoneName}'${m}.`))}}s?.("index-stacks");const l=t.satelliteIndex??await ye(r.cloudFormation,{...n!==void 0?{excludeStackNames:[n]}:{},...a!==void 0?{abortSignal:a}:{}});o.push(...l.warnings);const w=new Map;if(t.delegatedChildren!==void 0){const d=new Map;for(const f of c)f.type==="NS"&&d.set(R(f.name,f.type,f.setIdentifier),f.values);const m=t.delegatedChildren.filter(f=>{const g=R(f.zoneName,"NS");return d.has(g)&&!i.has(g)&&!l.byRecordKey.has(g)}),S=t.childAccountClientFactory;if(m.length>0&&S===void 0)o.push(N("zone classification: delegated child domains are configured but no child-account client factory was wired, so cross-account delegations cannot be verified and their NS rows classify 'unknown'. Cure: supply childAccountClientFactory in ClassifyZoneOptions."));else if(S!==void 0)for(const f of m){if(f.accountId===void 0){o.push(N(`zone classification: delegated child '${f.zoneName}' has no AWS account id, so its cross-account delegation cannot be verified and its NS row classifies 'unknown'. Cure: set "account" on the delegated domain entry in fjall-config.json.`));continue}const g=R(f.zoneName,"NS"),D=await he({factory:S,accountId:f.accountId,...f.region!==void 0?{region:f.region}:{},zoneName:f.zoneName,stackName:f.stackName??Q(f.zoneName),liveNsValues:d.get(g)??[],...a!==void 0?{abortSignal:a}:{}});if(!D.success){o.push(N(h(D.error)));continue}w.set(g,D.data)}}s?.("check-acm");const E=c.some(z);let k;if(E){const d=await Se(r.acmByRegion,a);d.success?k=d.data:o.push(N(d.error.message))}let I;if(k!==void 0){const d=new Set;for(const m of c)if(z(m))for(const S of k.get(m.name)??[])d.add(S);if(d.size>0){const m=await Ne(r.cloudFormation,[...n!==void 0?[n]:[],...l.stackNames],d,a);I=m.ownerByArn,o.push(...m.warnings)}}const O=p&&l.warnings.length===0,y=new Set;if(O)for(const d of c){if(!B(d))continue;const m=R(d.name,d.type,d.setIdentifier);i.has(m)||l.byRecordKey.has(m)||y.add(d.aliasTargetDnsName)}let _;if(y.size>0){const d=t.dnsProbe??re,m=new Map;for(const S of y){let f;try{f=await d(S,a)}catch(g){f="indeterminate",o.push(N(`zone classification: DNS probe for alias target '${S}' failed (${h(g)}) \u2014 dependent alias records classify 'unknown', never 'residue'. Cure: retry with a working resolver.`)),m.set(S,f);continue}f==="indeterminate"&&o.push(N(`zone classification: DNS probe for alias target '${S}' was inconclusive \u2014 dependent alias records classify 'unknown', never 'residue'. Cure: retry once the resolver answers definitively.`)),m.set(S,f)}_=m}s?.("classify");const M=T(e.zoneName),L=c.map(d=>ge(d,{zoneApex:M,domainStackName:n,declaredKeys:i,satelliteIndex:l,crossAccountDelegations:w,acmValidationReferences:k,certificateOwnerByArn:I,templateEvidenceComplete:O,aliasProbeVerdicts:_})).sort((d,m)=>d.name.localeCompare(m.name)||d.type.localeCompare(m.type)||(d.setIdentifier??"").localeCompare(m.setIdentifier??"")),P={declared:0,satellite:0,residue:0,unknown:0};for(const d of L)P[d.classification]+=1;return v({hostedZoneId:e.hostedZoneId,zoneName:M,records:L,counts:P,warnings:o})}u(Oe,"classifyZoneRecords");function ke(t,e){const n=new Set;for(const r of t){const a=e?.get(r);if(a===void 0)return;n.add(a)}return n}u(ke,"provenCertificateOwners");function ge(t,e){const n=R(t.name,t.type,t.setIdentifier),r={name:t.name,type:t.type,...t.setIdentifier!==void 0?{setIdentifier:t.setIdentifier}:{},values:t.values};if(t.name===e.zoneApex&&(t.type==="SOA"||t.type==="NS"))return{...r,classification:"declared",...e.domainStackName!==void 0?{ownerStack:e.domainStackName}:{},detail:"zone-owned record, created with the hosted zone"};if(e.declaredKeys.has(n))return{...r,classification:"declared",...e.domainStackName!==void 0?{ownerStack:e.domainStackName}:{}};const a=e.satelliteIndex.byRecordKey.get(n);if(a!==void 0)return{...r,classification:"satellite",ownerStack:a};const s=e.crossAccountDelegations.get(n);if(s!==void 0)return{...r,classification:"satellite",ownerStack:s.ownerStack,detail:`cross-account delegation: stack ${s.ownerStack} in account ${s.accountId}`};if(z(t)){if(e.acmValidationReferences===void 0)return{...r,classification:"unknown",detail:"ACM validation CNAME shape, but live-certificate evidence was unavailable \u2014 fail-closed as unknown rather than residue"};const o=e.acmValidationReferences.get(t.name);if(o!==void 0&&o.size>0){const c=ke(o,e.certificateOwnerByArn);if(c!==void 0){const i=[...c].sort();if(e.domainStackName!==void 0&&i.every(l=>l===e.domainStackName))return{...r,classification:"declared",ownerStack:e.domainStackName,detail:"ACM validation CNAME for the domain stack's live certificates; required for issuance and auto-renewal"};const p=i.find(l=>l!==e.domainStackName);if(p!==void 0)return{...r,classification:"satellite",ownerStack:p,detail:`ACM validation CNAME for live certificates owned by ${i.map(l=>`'${l}'`).join(", ")}; required for issuance and auto-renewal`}}return{...r,classification:"unknown",detail:"ACM validation CNAME still referenced by a live certificate \u2014 keep it; it is not residue"}}return{...r,classification:"residue",detail:"ACM validation CNAME not referenced by any live certificate in the scanned regions"}}if(B(t)){if(!e.templateEvidenceComplete)return{...r,classification:"unknown",detail:"alias to an AWS-managed target, but template evidence was incomplete \u2014 fail-closed as unknown rather than residue"};const o=e.aliasProbeVerdicts?.get(t.aliasTargetDnsName);return o==="nxdomain"?{...r,classification:"residue",detail:`dangling alias: target ${t.aliasTargetDnsName} no longer exists (NXDOMAIN) and no live template references this record`}:o==="resolves"?{...r,classification:"unknown",detail:"alias target still exists in DNS, so the record cannot be dangling; not residue"}:{...r,classification:"unknown",detail:"alias target probe was inconclusive \u2014 fail-closed as unknown rather than residue"}}return{...r,classification:"unknown"}}u(ge,"classifyRecord");export{$ as TemplateEvidenceError,ye as buildSatelliteRecordIndex,Oe as classifyZoneRecords,ae as createSystemDnsNameProbe,ue as extractTemplateRecordKeys,T as normaliseRecordName,re as systemDnsNameProbe};
|
|
@@ -7,12 +7,25 @@ import type { DelegatedDomainDeployer } from "./domain/delegatedDomainDeploy.js"
|
|
|
7
7
|
export interface DomainConfig {
|
|
8
8
|
name: string;
|
|
9
9
|
type: "apex" | "delegated";
|
|
10
|
+
/**
|
|
11
|
+
* Self-recorded home: the 12-digit AWS account id (and region) the last
|
|
12
|
+
* successful deploy of this domain ran in, from the provider's config
|
|
13
|
+
* entry. The cascade runs under ONE ambient identity, so `deployDomains`
|
|
14
|
+
* SKIPS any domain whose pin contradicts the executing identity rather
|
|
15
|
+
* than deploying it into the wrong account (the development.fjall.io
|
|
16
|
+
* incident). Absent on never-deployed / pre-3.9.0 entries: fail-open.
|
|
17
|
+
*/
|
|
18
|
+
account?: string;
|
|
19
|
+
region?: string;
|
|
10
20
|
}
|
|
11
21
|
/**
|
|
12
22
|
* Result of a domain deployment phase.
|
|
13
23
|
*/
|
|
14
24
|
export interface DomainDeployResult {
|
|
15
25
|
domainsDeployed: number;
|
|
26
|
+
/** Domains left untouched because their account/region pin names another
|
|
27
|
+
* home. A skip is expected topology, never an entry in `errors`. */
|
|
28
|
+
domainsSkipped: number;
|
|
16
29
|
errors: string[];
|
|
17
30
|
}
|
|
18
31
|
/**
|
|
@@ -137,6 +137,16 @@ export interface DeployDomainsOptions {
|
|
|
137
137
|
* `deployDelegatedDomain`, where the skip is loud, never silent.
|
|
138
138
|
*/
|
|
139
139
|
skipCaaPreflight?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* The ambient identity this cascade executes under. When present, any
|
|
142
|
+
* domain whose self-recorded `account`/`region` pin (DomainConfig) names a
|
|
143
|
+
* different home is SKIPPED with a warning instead of deployed — the
|
|
144
|
+
* account-blind domains phase is how development.fjall.io was deployed
|
|
145
|
+
* into production. Absent (legacy callers): no partition, today's
|
|
146
|
+
* behaviour.
|
|
147
|
+
*/
|
|
148
|
+
executingAccountId?: string;
|
|
149
|
+
executingRegion?: string;
|
|
140
150
|
}
|
|
141
151
|
/**
|
|
142
152
|
* Deploy configured domains: apex domains sequentially, then delegated
|
|
@@ -144,5 +154,6 @@ export interface DeployDomainsOptions {
|
|
|
144
154
|
*/
|
|
145
155
|
export declare function deployDomains(provider: DomainDeployProvider, callbacks: DeployCallbacks, options?: DeployDomainsOptions): Promise<{
|
|
146
156
|
domainsDeployed: number;
|
|
157
|
+
domainsSkipped: number;
|
|
147
158
|
errors: string[];
|
|
148
159
|
}>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var z=Object.defineProperty;var
|
|
1
|
+
var z=Object.defineProperty;var p=(e,t)=>z(e,"name",{value:t,configurable:!0});import{join as B}from"path";import{success as M,failure as y}from"@fjall/generator";import{logger as $}from"@fjall/util/logger";import{maskSensitiveOutput as C,mapSettledWithConcurrency as Y}from"@fjall/util";import{ORGANISATION_TYPES as w,getOrganisationStackName as T}from"../../types/operations.js";import{CdkContextBuilder as W}from"../../services/supporting/CdkContextBuilder.js";import{stubCallerIdentity as X}from"../../types/deployment/index.js";import{CloudFormationService as H}from"../../services/infrastructure/CloudFormationService.js";import{getCascadeStateFilePath as q,regionSuffix as J}from"../../types/config/FjallState.js";import{BackupClient as Q}from"@aws-sdk/client-backup";import{accountHasDisasterRecovery as Z,describeBackupVaultExists as ee}from"../../aws/organisations/backup.js";import{buildParamsContext as te,collectStackOutputs as V,assumeCascadeRole as I,forwardOutput as E,reconcileOidcProviderIfCarried as G}from"../contextHelpers.js";import{assertLeaseBeforeStack as oe}from"../application/leaseGate.js";import{deployDelegatedDomain as ne}from"../domain/delegatedDomainDeploy.js";import{runDriftPreFlightGate as re}from"../drift/preFlightGate.js";import{accountTier as U}from"@fjall/util";import{buildBootstrapTagsIfResolved as ae}from"../bootstrapTags.js";import{hasOrganisationTierAccount as ie}from"../../aws/organisations/accountGlobals.js";import{DEFAULT_REGION as _}from"../../aws/utils/regions.js";const se=4;function Ee(e){const t=e.find(o=>U(o)==="platform"),u=e.filter(o=>U(o)==="account");return{platformAccount:t,memberAccounts:u}}p(Ee,"partitionAccounts");function ce(e){const t=e?.primaryRegion??_,u=e?.secondaryRegions??[],o=new Set([t,...u]),s=e?.disasterRecoveryRegion;return s&&o.add(s),[...o]}p(ce,"buildRegionList");function be(e){return ce(e)[0]??_}p(be,"cascadeHomeRegion");function ue(e,t){return`${e} (${t})`}p(ue,"cascadeOperationKey");function Fe(e,t){const u=[];for(const o of t)for(const s of e)u.push({account:s,region:o});return u}p(Fe,"buildAccountRegionPairs");import{buildCascadeRoleArn as He}from"../contextHelpers.js";async function Ne(e,t,u,o){u.onLog?.(`Verifying cascade role access for ${t.length} account(s)\u2026`,"info");const s=await Y(t,se,async i=>I(e.awsProvider,i.id,i.region??e.awsProvider.getRegion(),`fjall-preflight-${i.name}`,o)),n=[];return s.forEach((i,r)=>{const a=t[r];if(a){if(i.status==="rejected"){n.push({accountId:a.id,accountName:a.name,error:i.reason instanceof Error?i.reason.message:String(i.reason)});return}i.value.success||n.push({accountId:a.id,accountName:a.name,error:i.value.error.message})}}),n.length===0&&u.onLog?.(`Cascade role access verified for ${t.length} account(s)`,"info"),n}p(Ne,"probeCascadeRoles");async function je(e,t,u,o,s,n,i){const r=i?.region??o.region??t.awsProvider.getRegion(),a=ue(o.name,r),l=i?.orgConfig??e.orgConfig,f=i?.ipamPoolId;n.onCascadeAccountStart?.(a,o.id,r,s);const c=await I(t.awsProvider,o.id,r,`fjall-cascade-${o.name}`,e.abortSignal);if(!c.success)return n.onCascadeAccountComplete?.(a,!1,C(c.error.message),r),y(new Error(`Failed to assume role for ${o.name}: ${C(c.error.message)}`));const{provider:d,credentials:h}=c.data,R=B(e.workingDirectory,"fjall",s==="platform"?w.PLATFORM:w.ACCOUNT),A=B(R,`cdk.out.${o.id}.${J(r)}`),S=W.buildDeploymentContext({deployType:s,target:u.target,path:R,assemblyDir:A,environment:o.environment??void 0,region:r,accountName:o.name,callerIdentity:X(o.id),ipamPoolId:f,...te({orgConfig:l,identity:e.identity,skipOidc:e.options?.skipOidc,region:r,primaryRegion:i?.primaryRegion,trailLifecycle:o.trailLifecycle})},{verbose:e.options?.verbose},l);if(Z(o.environment,l?.disasterRecoveryRegion)){const m=await ee(d.getClient(Q),e.abortSignal);if(!m.success)return n.onCascadeAccountComplete?.(a,!1,C(m.error.message),r),y(new Error(`Backup vault probe failed for ${o.name}: ${C(m.error.message)}`));S.fjallAdoptBackupVault=m.data}n.onCascadeAccountPhaseChange?.(a,"synth",r);const O=await t.cdkService.runCdkSynth(S,E(n),h);if(!O.success)return n.onCascadeAccountComplete?.(a,!1,C(`Synth failed: ${O.error}`),r),y(new Error(`Synth failed for ${o.name}: ${C(O.error)}`));const g=T(s==="platform"?w.PLATFORM:w.ACCOUNT),b=q(R,o.id,r),P=new H(d),{changed:K,currentHash:F}=await t.hashService.compareCascadeStack(A,g,b);if(!K&&e.options?.force!==!0&&await P.stackExists(g,void 0,e.abortSignal)){const m=await P.getStackOutputs(g,void 0,e.abortSignal);m.success||$.debug("cascadeHelpers","Failed to read outputs for skipped cascade account (non-critical)",{stackName:g,account:o.name});const L=V(m);return await G(S,d,n,e.abortSignal),n.onLog?.(`${o.name}: no infrastructure changes \u2014 skipping deploy`,"info"),n.onCascadeAccountComplete?.(a,!0,void 0,r,L,!0),M({outputs:L,skipped:!0})}const k=await re(d,{stackNames:[g],cdkOutPath:A,cfnService:P,callbacks:n,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}});if(k.kind==="blocked")return n.onCascadeAccountComplete?.(a,!1,k.message,r),y(new Error(k.message));const x=await oe(e.assertLeaseForStack,g,e.abortSignal);if(x!==void 0)return n.onCascadeAccountComplete?.(a,!1,x.message,r),y(x);n.onCascadeAccountPhaseChange?.(a,"bootstrap",r);const v=await t.cdkService.runCdkBootstrap(S,E(n),h,void 0,ae({account:o,estate:ie(l?.providerAccounts)?"org":"solo",...i?.orgId!==void 0?{orgId:i.orgId}:{}}));if(!v.success)return n.onCascadeAccountComplete?.(a,!1,C(`Bootstrap failed: ${v.error}`),r),y(new Error(`Bootstrap failed for ${o.name}: ${C(v.error)}`));n.onCascadeAccountPhaseChange?.(a,"deploy",r);const D=await t.cdkService.runCdkDeploy(S,g,E(n),m=>n.onCascadeAccountResourceProgress?.(a,m,r),d,h);if(!D.success)return n.onCascadeAccountComplete?.(a,!1,C(D.error),r),y(new Error(C(D.error)));const N=await P.getStackOutputs(g,void 0,e.abortSignal);N.success||$.debug("cascadeHelpers","Failed to read cascade account stack outputs (non-critical)",{stackName:g,account:o.name});const j=V(N);return await G(S,d,n,e.abortSignal),F!==void 0&&((await t.hashService.persistCascadeStack(b,g,F)).success||$.debug("cascadeHelpers","Failed to persist cascade hash state (non-critical)",{stackName:g,account:o.name})),n.onCascadeAccountComplete?.(a,!0,void 0,r,j,!1),M({outputs:j,skipped:!1})}p(je,"deployCascadeAccount");async function Le(e,t,u,o){const s=new Map,n=e.awsProvider.getRegion(),i=await I(e.awsProvider,t.id,n,`fjall-ipam-read-${t.name}`,o);if(!i.success)return $.debug("organisationDeploy",`Cannot read Platform outputs: ${i.error.message}`),s;const r=new H(i.data.provider),a=T(w.PLATFORM),l=await r.getStackOutputs(a);if(!l.success)return $.debug("organisationDeploy",`Failed to read Platform stack outputs: ${l.error.message}`),s;const f=/^IpamPoolId(\d{12})(\w+)$/;for(const c of l.data){const d=c.OutputKey?.match(f);if(d&&c.OutputValue){const h=`${d[1]}-${d[2]}`;s.set(h,c.OutputValue)}}return s.size>0&&u.onLog?.(`Read ${s.size} IPAM pool ID(s) from Platform stack`,"info"),s}p(Le,"readPlatformIpamPoolIds");function de(e,t){const u=e.account!==void 0&&t?.executingAccountId!==void 0&&e.account!==t.executingAccountId,o=e.region!==void 0&&t?.executingRegion!==void 0&&e.region!==t.executingRegion;if(u)return`[${e.name}] skipped: its fjall-config.json entry records account ${e.account} as its home, but this cascade runs in account ${t?.executingAccountId} - deploy it separately with 'fjall domain deploy ${e.name} -t <target in account ${e.account}>' or run the cascade under that account.`;if(o)return`[${e.name}] skipped: its fjall-config.json entry records region ${e.region} as its home, but this cascade runs in region ${t?.executingRegion} - deploy it separately with 'fjall domain deploy ${e.name} --region ${e.region}', or update the entry's "region" key if this move is intentional.`}p(de,"foreignDomainSkipReason");async function Be(e,t,u){const o=e.getDomains();if(o.length===0)return{domainsDeployed:0,domainsSkipped:0,errors:[]};t.onCascadePhaseStart?.("domains");const s=[];let n=0;for(const f of o){const c=de(f,u);if(c!==void 0){n++,t.onProgress?.({type:"warning",message:c});continue}s.push(f)}const i=s.filter(f=>f.type==="apex"),r=s.filter(f=>f.type==="delegated");let a=0;const l=[];for(const f of i){const c=await e.deployDomain(f.name,t);c.success?a++:l.push(`${f.name}: ${c.error.message}`)}if(r.length>0){const f=await Promise.allSettled(r.map(c=>{const d=e.delegatedDeployer?.(c.name);return d!==void 0?ne({deployer:d,callbacks:t,...u?.abortSignal!==void 0?{abortSignal:u.abortSignal}:{},...u?.skipCaaPreflight!==void 0?{skipCaaPreflight:u.skipCaaPreflight}:{}}):e.deployDomain(c.name,t)}));for(let c=0;c<f.length;c++){const d=f[c],h=r[c];if(!(!d||!h))if(d.status==="fulfilled")d.value.success?a++:l.push(`${h.name}: ${d.value.error.message}`);else{const R=d.reason instanceof Error?d.reason.message:String(d.reason);l.push(`${h.name}: ${R}`)}}}return t.onCascadePhaseComplete?.("domains"),{domainsDeployed:a,domainsSkipped:n,errors:l}}p(Be,"deployDomains");export{se as CASCADE_MAX_CONCURRENCY,Fe as buildAccountRegionPairs,He as buildCascadeRoleArn,ce as buildRegionList,be as cascadeHomeRegion,ue as cascadeOperationKey,je as deployCascadeAccount,Be as deployDomains,Ee as partitionAccounts,Ne as probeCascadeRoles,Le as readPlatformIpamPoolIds};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var W=Object.defineProperty;var b=(o,
|
|
1
|
+
var W=Object.defineProperty;var b=(o,l)=>W(o,"name",{value:l,configurable:!0});import{failure as $}from"@fjall/generator";import{regionSuffix as X}from"../../types/config/FjallState.js";import{partitionAccounts as Y,deployCascadeAccount as T,readPlatformIpamPoolIds as z,deployDomains as B,buildRegionList as G,buildAccountRegionPairs as J,cascadeHomeRegion as K,CASCADE_MAX_CONCURRENCY as Q}from"../organisation/cascadeHelpers.js";import{projectScalarSummary as q}from"../organisation/cascadeSummary.js";import{getErrorMessage as V,maskSensitiveOutput as C,mapSettledWithConcurrency as Z}from"@fjall/util";import{INFRA_STEPS as R}from"./infraSteps.js";async function ie(o,l,E,H){const{callbacks:e}=o,{providerAccounts:U,effectiveOrgConfig:N,totalSteps:u,cascadeErrors:m,allCascadeOutputs:M,orgId:O}=H;let S=!1;e.onCascadeStart?.();const j=Date.now();let d=2,w=!1,h,y=!1;const I=[],x=b(t=>({members:I,...h!==void 0?{platform:h}:{},domainsDeployed:y,errors:m,totalDurationMs:t}),"buildLedger"),{platformAccount:r,memberAccounts:A}=Y(U),_=K(o.orgConfig);if(r){const{id:t,name:a}=R.CASCADE_PLATFORM;e.onStepStart?.(t,a,d,u),e.onCascadePhaseStart?.("platform");let c;const P=Date.now();try{c=await T(o,l,E,r,"platform",e,{orgConfig:N,primaryRegion:_,orgId:O})}catch(i){const n=C(V(i));m.push({accountId:r.id,error:n}),e.onCascadePhaseComplete?.("platform"),e.onStepComplete?.(t,a,"error",d,u),c=$(new Error(n))}const D=Date.now()-P;if(c.success){w=!0;const i=c.data.skipped===!0;i||(S=!0),c.data.outputs&&M.push({accountId:r.id,outputs:c.data.outputs}),e.onCascadePhaseComplete?.("platform"),e.onStepComplete?.(t,a,i?"skipped":"completed",d,u),h={accountId:r.id,result:i?"skipped":"succeeded",durationMs:D}}else m.some(i=>i.accountId===r.id)||(m.push({accountId:r.id,error:C(c.error.message)}),e.onCascadePhaseComplete?.("platform"),e.onStepComplete?.(t,a,"error",d,u)),h={accountId:r.id,result:"failed",durationMs:D,error:C(c.error.message)};d++}let F=new Map;if(w&&r&&(F=await z(l,r,e,o.abortSignal)),o.domainProvider){const t=await B(o.domainProvider,e,{executingAccountId:l.awsProvider.getAccountId(),executingRegion:l.awsProvider.getRegion(),...o.abortSignal!==void 0?{abortSignal:o.abortSignal}:{},...o.options?.skipCaaPreflight!==void 0?{skipCaaPreflight:o.options.skipCaaPreflight}:{}});y=t.domainsDeployed>0,y&&(S=!0);for(const a of t.errors)m.push({accountId:"domains",error:C(a)})}if(r!==void 0&&!w&&A.length>0){const{id:t,name:a}=R.CASCADE_ACCOUNTS;e.onStepStart?.(t,a,d,u),e.onStepComplete?.(t,a,"skipped",d,u),e.onLog?.("Skipping account cascade \u2014 platform deployment failed; platform is a prerequisite for member accounts.","warn")}else if(A.length>0){const{id:t,name:a}=R.CASCADE_ACCOUNTS;e.onStepStart?.(t,a,d,u),e.onCascadePhaseStart?.("accounts");const c=G(o.orgConfig),P=J(A,c);(await Z(P,Q,async({account:n,region:k})=>{const f=F.get(`${n.id}-${X(k)}`),s=Date.now();return{result:await T(o,l,E,n,"account",e,{ipamPoolId:f,orgConfig:N,region:k,primaryRegion:_,orgId:O}),durationMs:Date.now()-s}})).forEach((n,k)=>{const f=P[k];if(!f)return;const s=f.account;if(n.status==="rejected"){const p=C(n.reason instanceof Error?n.reason.message:String(n.reason));I.push({accountId:s.id,accountName:s.name,region:f.region,result:"failed",durationMs:0,error:p}),m.push({accountId:s.id,error:p});return}const{result:g,durationMs:v}=n.value;if(g.success){const p=g.data.skipped===!0;p||(S=!0),g.data.outputs&&M.push({accountId:s.id,outputs:g.data.outputs}),I.push({accountId:s.id,accountName:s.name,region:f.region,result:p?"skipped":"succeeded",durationMs:v})}else{const p=C(g.error.message);I.push({accountId:s.id,accountName:s.name,region:f.region,result:"failed",durationMs:v,error:p}),m.push({accountId:s.id,error:p})}});const i=q(x(0));e.onCascadePhaseComplete?.("accounts"),e.onStepComplete?.(t,a,i.accountsFailed>0?"error":i.accountsSkipped===A.length?"skipped":"completed",d,u)}const L=x(Date.now()-j);return e.onCascadeComplete?.(q(L)),e.onCascadeLedger?.(L),{anyCascadeDeployHappened:S}}b(ie,"executeCascade");export{ie as executeCascade};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
var j=Object.defineProperty;var F=(
|
|
1
|
+
var j=Object.defineProperty;var F=(E,e)=>j(E,"name",{value:e,configurable:!0});import{existsSync as U}from"fs";import{join as P}from"path";import{logger as T}from"@fjall/util/logger";import{success as v,failure as o}from"@fjall/generator";import{DEFAULT_REGION as S}from"../../aws/utils/regions.js";import{getErrorMessage as k,maskSensitiveOutput as g}from"@fjall/util";import{CdkEventMonitor as G,DEFAULT_DEPLOY_TIMEOUT_MS as B,settleMonitoringAnalysis as Y,startStackMonitoring as q}from"./CdkEventMonitoring.js";import{analyseDeployOutput as X,analyseDestroyResult as z,createEnhancedOutputCallback as H,formatAnalysedDeployFailure as _}from"./CdkOutputAnalyser.js";import{recordDriftSuspects as J}from"../../orchestration/drift/driftSuspectJournal.js";import{CdkCommandRunner as Q}from"./CdkCommandRunner.js";import{CdkArgumentBuilder as V}from"./CdkArgumentBuilder.js";import{CdkProcessManager as Z}from"./CdkProcessManager.js";import{cancelInFlightStack as O}from"./cancelInFlightStack.js";import{isAborted as w}from"../../aws/organisations/types.js";import{wrapWithConstructMapEnrichment as x}from"./constructMapEnrichment.js";import{STACK_DETECTION_FALLBACK_MS as ee,resolveStackName as K,getFallbackStackName as te,buildDeploymentCdkContext as I}from"./cdkServiceHelpers.js";import{ensureSynthDependencies as re}from"./synthDependencies.js";import{runInfraTypecheckPreflight as ne}from"./infraTypecheckPreflight.js";import{isTransientNetworkFailure as ae,isReattachableInProgressStatus as oe,MAX_TRANSIENT_NETWORK_RETRIES as N,TRANSIENT_RETRY_BACKOFF_MS as ie}from"./transientNetworkRecovery.js";import{sleepAbortable as se}from"../../util/sleepAbortable.js";class Fe{static{F(this,"CdkService")}commandRunner;eventMonitor;abortSignal;constructor(e){const t=e?.processManager??new Z(new V,e?.abortSignal);this.commandRunner=new Q(t),this.eventMonitor=new G({eventLogWriterFactory:e?.eventLogWriterFactory,...e?.onFailureAnalysis!==void 0?{onFailureAnalysis:e.onFailureAnalysis}:{}}),this.abortSignal=e?.abortSignal}dispose(){this.commandRunner.dispose()}async checkDifferences(e,t,r){return this.commandRunner.checkDifferences(e,t,r)}async deploy(e,t,r){return this.commandRunner.deploy(e,t,r)}async destroy(e,t,r){return this.commandRunner.destroy(e,t,r)}async runImport(e,t,r){return this.commandRunner.runImport(e,t,r)}async synth(e,t){return this.commandRunner.synth(e,t)}async bootstrap(e,t,r){return this.commandRunner.bootstrap(e,t,r)}async runCdkSynth(e,t,r){const a=e.callerIdentity?.Account;try{const n=await re(e.path,{...t!==void 0?{onOutput:t}:{},...this.abortSignal!==void 0?{abortSignal:this.abortSignal}:{}});if(!n.success)return o(n.error);const l=await ne(e.path,{...this.abortSignal!==void 0?{abortSignal:this.abortSignal}:{}});l.status==="failed"&&(t?.(`WARNING: TypeScript errors in ${e.path} \u2014 the synthesised template may be wrong. This check will block deploys in a future release.
|
|
2
|
+
${l.details}
|
|
3
|
+
`),T.warn("CdkService","Infrastructure typecheck failed",{path:e.path}));const i=await this.synth(e.path,{outputCallback:t,context:I(e,a,e.region||S),...e.assemblyDir!==void 0?{outputDir:e.assemblyDir}:{},...r!==void 0?{credentials:r}:{}});return i.success?v({message:"CloudFormation template synthesised",details:i.data.output?{synthesisTime:i.data.output}:void 0}):o(i.error||"Failed to synthesise CloudFormation template")}catch(n){return o(`CDK synth failed: ${g(k(n))}`)}}async runCdkBootstrap(e,t,r,a,n){const l=e.callerIdentity?.Account,i=e.region||S;try{if(!l)return o("No AWS account ID available");const u=P(e.path,"node_modules");if(!U(u))return o(`Dependencies not installed. Please run 'npm install' in ${e.path} before deploying.`);const d=await this.bootstrap(l,i,{outputCallback:t,credentials:r,...a!==void 0&&a!==""?{permissionsBoundary:a}:{},...n!==void 0&&Object.keys(n).length>0?{bootstrapTags:n}:{}});return d.success?v({message:"AWS environment bootstrapped"}):o(d.error||"Failed to bootstrap AWS environment")}catch(u){return o(`CDK bootstrap failed: ${g(k(u))}`)}}async runCdkDiff(e,t){const r=e.callerIdentity?.Account;try{const a=await this.checkDifferences(e.path,void 0,{verbose:e.options?.verbose,outputCallback:t,context:I(e,r,e.region||S)});return a.success?v({message:"Diff check complete",details:{hasDifferences:a.data.hasDifferences,details:a.data.details}}):o(`CDK diff failed: ${a.error.message}`)}catch(a){return o(`CDK diff failed: ${g(k(a))}`)}}async runCdkDeploy(e,t,r,a,n,l,i,u){const d=e.callerIdentity?.Account,s=e.region||S;if(!d)return o("AWS account ID not available. Please ensure AWS credentials are properly configured.");if(!n)return o("AwsProvider is required for deployment monitoring.");const p=e.assemblyDir??P(e.path,"cdk.out"),y=x(p,a);let h=null,b;try{const f=K(t,e)??te(e),m=await this.eventMonitor.createEventMonitor("deploy",f,s,e,n);h=m,r&&(r(g(`Starting CloudFormation deployment of ${f}...
|
|
2
4
|
`)),r(`Monitoring CloudFormation events (CDK process running in background)...
|
|
3
|
-
`));const c={cdkOutput:"",actualStackName:f,stackDetected:!1,monitoringPromise:null},L=H(c,r,
|
|
4
|
-
`);if(
|
|
5
|
-
`);const M=await m.getStackStatus(c.actualStackName,this.abortSignal);if(M!==null&&oe(M.status))return clearTimeout(b),await this.reattachToInFlightOperation({stackName:c.actualStackName,currentStatus:M.status,region:s,accountId:d,monitor:m,state:c,credentials:
|
|
6
|
-
`),await
|
|
7
|
-
`)}const
|
|
8
|
-
`),r.stopMonitoring(),
|
|
9
|
-
`),v({message:`Re-attached to the in-flight CloudFormation operation after a transient network failure; ${t} reached ${s}`,details:{reattached:!0,status:s}})}const
|
|
5
|
+
`));const c={cdkOutput:"",actualStackName:f,stackDetected:!1,monitoringPromise:null},L=H(c,r,h,y);b=setTimeout(()=>{!c.stackDetected&&!c.monitoringPromise&&(T.debug("CdkService","Fallback monitoring STARTING",{targetStackName:f,stackDetected:c.stackDetected,hasOnResourceProgress:!!a}),c.monitoringPromise=q(m,f,y))},ee);for(let D=0;;D++){const C=await this.deploy(e.path,f,{verbose:e.options?.verbose,outputCallback:L,...e.assemblyDir!==void 0?{appDir:e.assemblyDir}:{useCdkOut:!0},cdkOutputLogger:m.getEventLogger()??void 0,context:I(e,d,s),credentials:l,...i!==void 0&&Object.keys(i).length>0&&{parameters:i},...u===!0&&{exclusively:!0}});if(w(this.abortSignal))return await O(f,s,l,r),o("Deployment cancelled");const R=X(c.cdkOutput,C,c.actualStackName);if(R.success)return R;const W=[c.cdkOutput,C.success?C.data.output??"":C.error,R.error].join(`
|
|
6
|
+
`);if(ae(W)){r?.(`Transient network failure while running CDK for ${c.actualStackName} \u2014 probing CloudFormation for an in-flight operation...
|
|
7
|
+
`);const M=await m.getStackStatus(c.actualStackName,this.abortSignal);if(M!==null&&oe(M.status))return clearTimeout(b),await this.reattachToInFlightOperation({stackName:c.actualStackName,currentStatus:M.status,region:s,accountId:d,monitor:m,state:c,credentials:l,onOutput:r,onResourceProgress:y});if(D<N){const $=ie[D]??15e3;if(r?.(`No CloudFormation operation in progress \u2014 nothing was mutated. Retrying the CDK deploy in ${$/1e3}s (retry ${D+1} of ${N})...
|
|
8
|
+
`),await se($,this.abortSignal),w(this.abortSignal))return o("Deployment cancelled");c.cdkOutput="";continue}r?.(`No CloudFormation operation in progress and the retry budget (${N}) is exhausted \u2014 giving up.
|
|
9
|
+
`)}const A=await Y(m,c.monitoringPromise);return A===null?R:(await this.journalDriftSuspects(d,s,c.actualStackName,A),o(_(A)))}}catch(f){const m=`CDK deploy failed: ${g(k(f))}`;return T.error("CdkService","CDK deployment exception",{error:m}),o(m)}finally{clearTimeout(b),h&&h.stopMonitoring()}}async reattachToInFlightOperation(e){const{stackName:t,monitor:r,state:a,onOutput:n}=e;n?.(`CloudFormation reports ${e.currentStatus} for ${t} \u2014 the operation survived the network failure. Re-attaching and monitoring it to completion...
|
|
10
|
+
`),r.stopMonitoring(),a.monitoringPromise=null;const l=F(()=>{r.stopMonitoring()},"onAbort");this.abortSignal?.addEventListener("abort",l,{once:!0});const i=await r.waitForStackComplete(t,{timeout:B,...e.onResourceProgress!==void 0?{onResourceUpdate:e.onResourceProgress}:{}}).finally(()=>{this.abortSignal?.removeEventListener("abort",l)});if(w(this.abortSignal))return await O(t,e.region,e.credentials,n),o("Deployment cancelled");if(i.success){const s=i.status??"COMPLETE";return n?.(`${t} reached ${s} after re-attach.
|
|
11
|
+
`),v({message:`Re-attached to the in-flight CloudFormation operation after a transient network failure; ${t} reached ${s}`,details:{reattached:!0,status:s}})}const u=r.getFailureAnalysis();if(u!==null)return await this.journalDriftSuspects(e.accountId,e.region,t,u),o(_(u));const d=i.failureReason!==void 0&&i.failureReason!==""?g(i.failureReason):"no failure reason reported";return o(`Re-attached to the in-flight CloudFormation operation after a transient network failure; ${t} reached ${i.status??"UNKNOWN"}: ${d}`)}async journalDriftSuspects(e,t,r,a){a.driftSuspects===void 0||a.driftSuspects.length===0||await J({accountId:e,region:t,stackName:r,recordedAt:new Date().toISOString(),suspects:a.driftSuspects.map(n=>({stackName:r,logicalId:n.logicalId,resourceType:n.resourceType,...n.physicalId!==void 0?{physicalId:n.physicalId}:{},failedStatus:n.failedStatus,statusReason:n.statusReason}))})}async runCdkDestroy(e,t,r,a,n,l,i){const u=e.callerIdentity?.Account,d=e.region||S;let s=null;try{const p=K(t,e);u&&p&&n&&(s=await this.eventMonitor.createEventMonitor("destroy",p,d,e,n),s.startMonitoring(p,h=>{a?.(h)},(h,b)=>{}));const y=await this.destroy(e.path,t,{verbose:e.options?.verbose,outputCallback:r,useCdkOut:l,cdkOutputLogger:s?.getEventLogger()??void 0,context:I(e,u,d),credentials:i});return w(this.abortSignal)?o("Destroy cancelled"):z(y)}catch(p){return o(`CDK destroy failed: ${g(k(p))}`)}finally{s&&s.stopMonitoring()}}}export{Fe as CdkService};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typechecks the customer's CDK app before synthesis.
|
|
3
|
+
*
|
|
4
|
+
* `cdk synth` executes infrastructure.ts via tsx, which strips types without
|
|
5
|
+
* checking them — a wrong construct prop synths silently wrong or dies at
|
|
6
|
+
* deploy time in CloudFormation language. Running the app dir's own tsc
|
|
7
|
+
* (`--noEmit -p <dir>`) surfaces the error as a readable compiler diagnostic
|
|
8
|
+
* before any AWS work starts.
|
|
9
|
+
*
|
|
10
|
+
* Warn-first rollout: a failed typecheck never blocks the deploy in this
|
|
11
|
+
* release — the caller reports it as a warning. Environmental problems
|
|
12
|
+
* (no tsconfig, no typescript install, tsc timeout) skip rather than fail,
|
|
13
|
+
* because the deploy has synthesised successfully without this gate for
|
|
14
|
+
* every release before it.
|
|
15
|
+
*/
|
|
16
|
+
/** Timeout for the whole-project tsc run (2 minutes). */
|
|
17
|
+
export declare const INFRA_TYPECHECK_TIMEOUT_MS: number;
|
|
18
|
+
export type InfraTypecheckOutcome = {
|
|
19
|
+
status: "passed";
|
|
20
|
+
} | {
|
|
21
|
+
status: "skipped";
|
|
22
|
+
reason: string;
|
|
23
|
+
} | {
|
|
24
|
+
status: "failed";
|
|
25
|
+
details: string;
|
|
26
|
+
};
|
|
27
|
+
export interface InfraTypecheckOptions {
|
|
28
|
+
abortSignal?: AbortSignal;
|
|
29
|
+
}
|
|
30
|
+
export declare function clearInfraTypecheckCache(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Typecheck the CDK app dir with its own tsc. Never throws; environmental
|
|
33
|
+
* failures return `skipped` so the deploy proceeds exactly as it did before
|
|
34
|
+
* this preflight existed.
|
|
35
|
+
*
|
|
36
|
+
* Messages are pre-masked at construction: `details` and every
|
|
37
|
+
* error-derived `reason` route through maskSensitiveOutput before return,
|
|
38
|
+
* so callers may surface them to callback/log sinks directly.
|
|
39
|
+
*/
|
|
40
|
+
export declare function runInfraTypecheckPreflight(path: string, options?: InfraTypecheckOptions): Promise<InfraTypecheckOutcome>;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var b=Object.defineProperty;var s=(t,e)=>b(t,"name",{value:e,configurable:!0});import{existsSync as v}from"fs";import{createRequire as S}from"module";import{dirname as T,join as p,resolve as w}from"path";import{filterDangerousEnvVars as C,getErrorMessage as h,maskSensitiveOutput as f}from"@fjall/util";import{logger as m}from"@fjall/util/logger";import{spawnWithTimeout as I}from"../../orchestration/spawnHelpers.js";const k=120*1e3,y=1500,a=new Map,d=new Map;function O(){a.clear(),d.clear()}s(O,"clearInfraTypecheckCache");function A(t){const e=f(t.trim());return e.length>y?`\u2026${e.slice(-y)}`:e}s(A,"maskedTail");function o(t,e){return m.debug("CdkService","Infrastructure typecheck skipped",{path:t,reason:e}),{status:"skipped",reason:e}}s(o,"skipped");function E(t){try{const r=S(p(t,"package.json")).resolve("typescript/package.json");return p(T(r),"bin","tsc")}catch(e){m.debug("CdkService","Failed to resolve the app dir's typescript",{path:t,error:f(h(e))});return}}s(E,"resolveCustomerTsc");async function R(t,e={}){const r=w(t),c=a.get(r);if(c!==void 0)return c;const u=d.get(r);if(u!==void 0)return u;const n=_(r,t,e).finally(()=>{d.delete(r)});return d.set(r,n),n}s(R,"runInfraTypecheckPreflight");async function _(t,e,r){if(!v(p(e,"tsconfig.json")))return o(e,"no tsconfig.json in the app dir");const c=E(e);if(c===void 0)return o(e,"typescript is not installed in the app dir");const u=Date.now(),n=await I({command:process.execPath,args:[c,"--noEmit","-p",e],cwd:e,env:C(process.env),timeout:k,...r.abortSignal!==void 0?{abortSignal:r.abortSignal}:{}});switch(n.type){case"success":{if(n.code===0){m.debug("CdkService","Infrastructure typecheck passed",{path:e,durationMs:Date.now()-u});const g={status:"passed"};return a.set(t,g),g}const i=`${n.stdout}
|
|
2
|
+
${n.stderr}`.trim();if(i==="")return o(e,`tsc exited with code ${n.code} and no diagnostics \u2014 possibly killed by the OS`);const l={status:"failed",details:A(i)};return a.set(t,l),l}case"timeout":{const i=o(e,`tsc timed out after ${k}ms`);return a.set(t,i),i}case"aborted":return o(e,"cancelled");case"enoent":case"spawn_error":return o(e,`tsc failed to start: ${f(n.error.message)}`)}}s(_,"typecheckUncached");export{k as INFRA_TYPECHECK_TIMEOUT_MS,O as clearInfraTypecheckCache,R as runInfraTypecheckPreflight};
|
|
@@ -106,8 +106,9 @@ export interface DeployParams {
|
|
|
106
106
|
* as one `-c fjall:managedDomain:<zone>=<json>` context arg per entry
|
|
107
107
|
* (CdkArgumentBuilder); constructs read it ahead of BYO zone props, with
|
|
108
108
|
* explicit `managedDomain` props still winning. The CLI resolves these via
|
|
109
|
-
* `DomainService.resolveDomainForApp
|
|
110
|
-
*
|
|
109
|
+
* `DomainService.resolveDomainForApp` (matching local `fjall/domains/`
|
|
110
|
+
* declarations plus fjall-config.json `domains` entries); surfaces with
|
|
111
|
+
* neither omit the field and constructs fall back to explicit props.
|
|
111
112
|
*/
|
|
112
113
|
managedDomainBindings?: Record<string, string>;
|
|
113
114
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fjall/deploy-core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.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",
|
|
@@ -88,8 +88,8 @@
|
|
|
88
88
|
"@aws-sdk/client-ssm": "^3.1038.0",
|
|
89
89
|
"@aws-sdk/client-sso-admin": "^3.1038.0",
|
|
90
90
|
"@aws-sdk/client-sts": "^3.1038.0",
|
|
91
|
-
"@fjall/generator": "^3.
|
|
92
|
-
"@fjall/util": "^3.
|
|
91
|
+
"@fjall/generator": "^3.10.0",
|
|
92
|
+
"@fjall/util": "^3.10.0",
|
|
93
93
|
"@smithy/node-http-handler": "^4.6.1",
|
|
94
94
|
"aws-cdk": "^2.1128.1",
|
|
95
95
|
"tsx": "^4.21.0",
|
|
@@ -100,5 +100,5 @@
|
|
|
100
100
|
"typescript": "^6.0.3",
|
|
101
101
|
"vitest": "^4.1.5"
|
|
102
102
|
},
|
|
103
|
-
"gitHead": "
|
|
103
|
+
"gitHead": "ce4e8471194f7e3e5e7df66844bf9387503983f6"
|
|
104
104
|
}
|