@fjall/deploy-core 2.27.0 → 2.29.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/organisations/backup.d.ts +7 -2
- package/dist/src/aws/organisations/backup.js +2 -2
- package/dist/src/aws/organisations/index.d.ts +1 -1
- package/dist/src/aws/organisations/index.js +1 -1
- package/dist/src/aws/organisations/oidcProvider.d.ts +37 -0
- package/dist/src/aws/organisations/oidcProvider.js +1 -1
- package/dist/src/aws/organisations/organisation.d.ts +21 -0
- package/dist/src/aws/organisations/organisation.js +1 -1
- package/dist/src/aws/utils/cloudformationEventHelpers.d.ts +45 -0
- package/dist/src/aws/utils/cloudformationEventHelpers.js +1 -1
- package/dist/src/aws/utils/cloudformationEventTypes.d.ts +1 -1
- package/dist/src/aws/utils/cloudformationEvents.d.ts +2 -1
- package/dist/src/aws/utils/cloudformationEvents.js +1 -1
- package/dist/src/aws/utils/stackStatus.js +1 -1
- package/dist/src/index.d.ts +12 -1
- package/dist/src/index.js +1 -1
- package/dist/src/orchestration/activeDeploymentGuard.d.ts +41 -0
- package/dist/src/orchestration/activeDeploymentGuard.js +8 -4
- package/dist/src/orchestration/application/applicationDeploy.d.ts +9 -0
- package/dist/src/orchestration/application/applicationDeploy.js +1 -1
- package/dist/src/orchestration/application/applicationDeployHelpers.d.ts +20 -3
- package/dist/src/orchestration/application/applicationDeployHelpers.js +3 -3
- package/dist/src/orchestration/application/applicationDestroy.js +1 -1
- package/dist/src/orchestration/application/approvalGate.d.ts +15 -1
- package/dist/src/orchestration/application/approvalGate.js +1 -1
- package/dist/src/orchestration/application/destructionGate.d.ts +164 -0
- package/dist/src/orchestration/application/destructionGate.js +5 -0
- package/dist/src/orchestration/application/leaseGate.d.ts +30 -0
- package/dist/src/orchestration/application/leaseGate.js +1 -0
- package/dist/src/orchestration/application/plan/approvalToken.d.ts +15 -0
- package/dist/src/orchestration/application/plan/approvalToken.js +1 -1
- package/dist/src/orchestration/application/plan/changeSetEscalation.d.ts +46 -0
- package/dist/src/orchestration/application/plan/changeSetEscalation.js +1 -0
- package/dist/src/orchestration/application/plan/computeDeployPlan.d.ts +13 -0
- package/dist/src/orchestration/application/plan/computeDeployPlan.js +1 -1
- package/dist/src/orchestration/application/plan/index.d.ts +4 -1
- package/dist/src/orchestration/application/plan/index.js +1 -1
- package/dist/src/orchestration/application/plan/loadDeployPlan.d.ts +32 -0
- package/dist/src/orchestration/application/plan/loadDeployPlan.js +1 -0
- package/dist/src/orchestration/application/plan/pinnedNames.d.ts +31 -0
- package/dist/src/orchestration/application/plan/pinnedNames.js +1 -0
- package/dist/src/orchestration/application/plan/registryOverlay.d.ts +83 -0
- package/dist/src/orchestration/application/plan/registryOverlay.js +1 -0
- package/dist/src/orchestration/application/plan/renderDeployPlan.d.ts +4 -1
- package/dist/src/orchestration/application/plan/renderDeployPlan.js +1 -1
- package/dist/src/orchestration/application/plan/types.d.ts +31 -0
- package/dist/src/orchestration/application/plan/types.js +1 -0
- package/dist/src/orchestration/contextHelpers.d.ts +37 -1
- package/dist/src/orchestration/contextHelpers.js +1 -1
- package/dist/src/orchestration/drift/classifyDriftFailure.d.ts +31 -0
- package/dist/src/orchestration/drift/classifyDriftFailure.js +1 -0
- package/dist/src/orchestration/drift/detectStackDrift.d.ts +37 -0
- package/dist/src/orchestration/drift/detectStackDrift.js +1 -0
- package/dist/src/orchestration/drift/driftPreFlight.d.ts +73 -0
- package/dist/src/orchestration/drift/driftPreFlight.js +7 -0
- package/dist/src/orchestration/drift/driftProbe.d.ts +113 -0
- package/dist/src/orchestration/drift/driftProbe.js +1 -0
- package/dist/src/orchestration/drift/driftSuspectJournal.d.ts +40 -0
- package/dist/src/orchestration/drift/driftSuspectJournal.js +1 -0
- package/dist/src/orchestration/drift/preFlightGate.d.ts +44 -0
- package/dist/src/orchestration/drift/preFlightGate.js +2 -0
- package/dist/src/orchestration/index.d.ts +19 -0
- package/dist/src/orchestration/index.js +1 -1
- package/dist/src/orchestration/organisation/cascadeDestroyHelpers.d.ts +7 -0
- package/dist/src/orchestration/organisation/cascadeDestroyHelpers.js +1 -1
- package/dist/src/orchestration/organisation/cascadeHelpers.js +1 -1
- package/dist/src/orchestration/organisation/organisationDestroy.js +3 -3
- package/dist/src/orchestration/organisationDeploy/infraSteps.d.ts +8 -1
- package/dist/src/orchestration/organisationDeploy/infraSteps.js +1 -1
- package/dist/src/orchestration/organisationDeploy/orgCascadeDeploy.js +5 -5
- package/dist/src/orchestration/organisationDeploy/orgContext.d.ts +21 -2
- package/dist/src/orchestration/organisationDeploy/orgContext.js +1 -1
- package/dist/src/orchestration/organisationDeploy/singleComponentDeploy.js +1 -1
- package/dist/src/orchestration/remediation/driftRepairGate.d.ts +66 -0
- package/dist/src/orchestration/remediation/driftRepairGate.js +1 -0
- package/dist/src/orchestration/remediation/forensicsCapture.d.ts +49 -0
- package/dist/src/orchestration/remediation/forensicsCapture.js +1 -0
- package/dist/src/orchestration/remediation/forgetResource.d.ts +423 -0
- package/dist/src/orchestration/remediation/forgetResource.js +1 -0
- package/dist/src/orchestration/remediation/pinRemediation.d.ts +121 -0
- package/dist/src/orchestration/remediation/pinRemediation.js +1 -0
- package/dist/src/orchestration/remediation/postFailureRepairOffer.d.ts +77 -0
- package/dist/src/orchestration/remediation/postFailureRepairOffer.js +14 -0
- package/dist/src/orchestration/remediation/recreatePreFlight.d.ts +47 -0
- package/dist/src/orchestration/remediation/recreatePreFlight.js +1 -0
- package/dist/src/orchestration/remediation/recreateResource.d.ts +110 -0
- package/dist/src/orchestration/remediation/recreateResource.js +1 -0
- package/dist/src/orchestration/remediation/remediationJournal.d.ts +88 -0
- package/dist/src/orchestration/remediation/remediationJournal.js +2 -0
- package/dist/src/orchestration/remediation/removalUpdate.d.ts +135 -0
- package/dist/src/orchestration/remediation/removalUpdate.js +1 -0
- package/dist/src/orchestration/remediation/retainFlip.d.ts +294 -0
- package/dist/src/orchestration/remediation/retainFlip.js +1 -0
- package/dist/src/services/application/ApplicationStackService.d.ts +5 -3
- package/dist/src/services/application/ApplicationStackService.js +1 -1
- package/dist/src/services/application/applicationStackHelpers.d.ts +8 -6
- package/dist/src/services/application/applicationStackHelpers.js +3 -3
- package/dist/src/services/index.d.ts +2 -2
- package/dist/src/services/index.js +1 -1
- package/dist/src/services/infrastructure/CdkEventMonitoring.d.ts +15 -0
- package/dist/src/services/infrastructure/CdkEventMonitoring.js +1 -1
- package/dist/src/services/infrastructure/CdkOutputAnalyser.d.ts +8 -0
- package/dist/src/services/infrastructure/CdkOutputAnalyser.js +2 -1
- package/dist/src/services/infrastructure/CdkService.js +3 -3
- package/dist/src/services/infrastructure/CfnRegistryService.d.ts +68 -0
- package/dist/src/services/infrastructure/CfnRegistryService.js +1 -0
- package/dist/src/services/infrastructure/CloudFormationService.d.ts +5 -2
- package/dist/src/services/infrastructure/CloudFormationService.js +1 -1
- package/dist/src/services/infrastructure/EcsServiceResolver.d.ts +23 -3
- package/dist/src/services/infrastructure/EcsServiceResolver.js +1 -1
- package/dist/src/services/infrastructure/cfnRegistrySeed.d.ts +10 -0
- package/dist/src/services/infrastructure/cfnRegistrySeed.js +1 -0
- package/dist/src/services/infrastructure/changeSetProbe.d.ts +88 -0
- package/dist/src/services/infrastructure/changeSetProbe.js +1 -0
- package/dist/src/services/infrastructure/index.d.ts +2 -0
- package/dist/src/services/infrastructure/index.js +1 -1
- package/dist/src/types/callbackKeys.d.ts +1 -1
- package/dist/src/types/callbackKeys.js +1 -1
- package/dist/src/types/callbacks.d.ts +22 -0
- package/dist/src/types/config/entitlements.d.ts +9 -0
- package/dist/src/types/config/orgConfig.d.ts +6 -0
- package/dist/src/types/deploymentEventSchema.d.ts +1 -0
- package/dist/src/types/deploymentEventSchema.js +1 -1
- package/dist/src/types/destruction.d.ts +212 -0
- package/dist/src/types/destruction.js +1 -0
- package/dist/src/types/drift.d.ts +72 -0
- package/dist/src/types/drift.js +1 -0
- package/dist/src/types/index.d.ts +8 -2
- package/dist/src/types/index.js +1 -1
- package/dist/src/types/params.d.ts +142 -0
- package/dist/src/types/remediation.d.ts +126 -0
- package/dist/src/types/remediation.js +1 -0
- package/package.json +5 -4
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy-plan loading — reads each changed stack's synthesised template from
|
|
3
|
+
* cdk.out and delegates to {@link computeDeployPlan} (live-template diff +
|
|
4
|
+
* registry overlay). Extracted from the approval gate so the unconditional
|
|
5
|
+
* destruction gate shares the SAME computation: `createMemoisedPlanLoader`
|
|
6
|
+
* guarantees a plan-gated deploy never diffs twice, while a non-engaged deploy
|
|
7
|
+
* computes the plan exactly once for the destruction gate.
|
|
8
|
+
*/
|
|
9
|
+
import { type Result } from "@fjall/generator";
|
|
10
|
+
import type { CfnRegistrySummaryReader } from "../../../services/infrastructure/CfnRegistryService.js";
|
|
11
|
+
import type { ChangeSetProbeRunner } from "../../../services/infrastructure/changeSetProbe.js";
|
|
12
|
+
import { type CfnTemplateReader } from "./computeDeployPlan.js";
|
|
13
|
+
import type { DeployPlan } from "./types.js";
|
|
14
|
+
export interface LoadDeployPlanParams {
|
|
15
|
+
cfnService: CfnTemplateReader;
|
|
16
|
+
cdkOutPath: string;
|
|
17
|
+
changedStacks: string[];
|
|
18
|
+
assemblyDigest: string;
|
|
19
|
+
registry?: CfnRegistrySummaryReader;
|
|
20
|
+
changeSetProbe?: ChangeSetProbeRunner;
|
|
21
|
+
abortSignal?: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
/** A zero-argument plan supplier shared between the gates. */
|
|
24
|
+
export type DeployPlanLoader = () => Promise<Result<DeployPlan, Error>>;
|
|
25
|
+
export declare function loadDeployPlan(params: LoadDeployPlanParams): Promise<Result<DeployPlan, Error>>;
|
|
26
|
+
/**
|
|
27
|
+
* Memoise the plan computation for one deploy invocation: the first call
|
|
28
|
+
* computes (template reads + live diff + registry overlay), every later call
|
|
29
|
+
* returns the same settled promise — including a failure, which both gates
|
|
30
|
+
* must observe identically.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createMemoisedPlanLoader(params: LoadDeployPlanParams): DeployPlanLoader;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{join as c}from"node:path";import{readFile as a}from"node:fs/promises";import{failure as l}from"@fjall/generator";import{maskSensitiveOutput as s}from"@fjall/util";import{computeDeployPlan as f}from"./computeDeployPlan.js";async function d(e){const t=[];for(const r of e.changedStacks){const i=c(e.cdkOutPath,`${r}.template.json`);let o;try{o=JSON.parse(await a(i,"utf8"))}catch(n){return l(new Error(s(`Failed to read synthesised template for ${r}: ${n instanceof Error?n.message:String(n)}`)))}t.push({stackName:r,newTemplate:o})}return f({cfnService:e.cfnService,stacks:t,assemblyDigest:e.assemblyDigest,...e.registry!==void 0?{registry:e.registry}:{},...e.changeSetProbe!==void 0?{changeSetProbe:e.changeSetProbe}:{},...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}})}function y(e){let t;return()=>(t??=d(e),t)}export{y as createMemoisedPlanLoader,d as loadDeployPlan};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned-name detection: a resource is custom-named iff the property named by
|
|
3
|
+
* the registry schema's `primaryIdentifier` (fallback: the small
|
|
4
|
+
* `@fjall/util` map for types whose identifier is a generated attribute) is
|
|
5
|
+
* explicitly present in the synthesised template with a LITERAL value. The
|
|
6
|
+
* template is the source of truth — CDK-generated names arrive as intrinsics
|
|
7
|
+
* (`Ref` / `Fn::*`) or are omitted entirely, so a literal string here means
|
|
8
|
+
* the author pinned the physical name.
|
|
9
|
+
*
|
|
10
|
+
* `replacement-required ∧ custom-named ⇒ pinned-name-replacement`: the update
|
|
11
|
+
* fails at apply time with "CloudFormation cannot update a stack when a
|
|
12
|
+
* custom-named resource requires replacing" — the Phase 2 destruction gate
|
|
13
|
+
* consumes this signal; Phase 1 annotates it on the plan row.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Detect a pinned physical name on a template resource. Returns the literal
|
|
17
|
+
* name, or undefined when the resource is CDK-named (intrinsic or absent) or
|
|
18
|
+
* the naming property cannot be determined.
|
|
19
|
+
*/
|
|
20
|
+
export declare function findPinnedPhysicalName(params: {
|
|
21
|
+
resourceType: string;
|
|
22
|
+
/** The resource's template `Properties` block. */
|
|
23
|
+
properties: Record<string, unknown> | undefined;
|
|
24
|
+
/** Registry `primaryIdentifier` pointers, when the oracle resolved them. */
|
|
25
|
+
primaryIdentifier?: readonly string[];
|
|
26
|
+
}): string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Extract a resource's `Properties` block from a parsed CloudFormation
|
|
29
|
+
* template without asserting the template's overall shape.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resourcePropertiesFromTemplate(template: unknown, logicalId: string): Record<string, unknown> | undefined;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{PHYSICAL_NAME_FALLBACK_PROPERTIES as f}from"@fjall/util";const o="/properties/";function d(e){if(e===void 0||e.length!==1)return;const r=e[0];if(r===void 0||!r.startsWith(o))return;const n=r.slice(o.length);if(!(n===""||n.includes("/")))return n}function u(e){if(typeof e=="string"&&e!=="")return e;if(typeof e=="number")return String(e)}function s(e){if(e.properties===void 0)return;const r=d(e.primaryIdentifier);if(r!==void 0){const t=u(e.properties[r]);if(t!==void 0)return t}const n=f[e.resourceType];if(n!==void 0)return u(e.properties[n])}function p(e,r){if(typeof e!="object"||e===null)return;const n=e.Resources;if(typeof n!="object"||n===null)return;const t=n[r];if(typeof t!="object"||t===null)return;const i=t.Properties;if(!(typeof i!="object"||i===null||Array.isArray(i)))return i}export{s as findPinnedPhysicalName,p as resourcePropertiesFromTemplate};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry verdict overlay — Layer 1 of the replacement oracle. The
|
|
3
|
+
* cloudformation-diff verdict (Layer 0) is computed from a stale bundled CDK
|
|
4
|
+
* resource spec and is retained only as the trigger; every changed property
|
|
5
|
+
* path is recomputed against the live registry catalogue
|
|
6
|
+
* (`createOnlyProperties` / `conditionalCreateOnlyProperties`):
|
|
7
|
+
*
|
|
8
|
+
* path ∈ createOnly → `will` (destructive)
|
|
9
|
+
* path ∈ conditionalCreateOnly → `conditional` (destructive worst-case,
|
|
10
|
+
* pending the Phase 3 change-set escalation)
|
|
11
|
+
* path in NEITHER → `none` — even where cloudformation-diff
|
|
12
|
+
* said WILL_REPLACE (kills the false
|
|
13
|
+
* AWS::RDS::DBInstance.Port replacement)
|
|
14
|
+
*
|
|
15
|
+
* Nested-path conservatism: cloudformation-diff reports top-level property
|
|
16
|
+
* names, so a change to a property with a createOnly SUB-pointer
|
|
17
|
+
* (`/properties/Foo/Bar`) is `conditional`, never silently `none`.
|
|
18
|
+
*
|
|
19
|
+
* Fail-closed degradation: when the oracle is unavailable for a type, the
|
|
20
|
+
* original diff verdict stands, annotated `verdictSource: "cdk-spec
|
|
21
|
+
* (unverified)"` — the oracle never fails the deploy.
|
|
22
|
+
*
|
|
23
|
+
* Every Layer-0 / Layer-1 disagreement is logged as stale-spec telemetry.
|
|
24
|
+
*/
|
|
25
|
+
import type { CfnRegistrySchemaSummary } from "../../../services/infrastructure/CfnRegistryService.js";
|
|
26
|
+
import type { CfnRegistrySummaryReader } from "../../../services/infrastructure/CfnRegistryService.js";
|
|
27
|
+
import type { ReplacementMode } from "../../../types/deployEvent.js";
|
|
28
|
+
import type { StackTemplatePair } from "./buildDeployPlan.js";
|
|
29
|
+
import type { DeployPlan, DeployPlanResourceChange } from "./types.js";
|
|
30
|
+
type RegistryPathVerdict = "will" | "conditional" | "none";
|
|
31
|
+
/**
|
|
32
|
+
* Advisory interruption notes for in-place updates that still disrupt service.
|
|
33
|
+
* Keyed `<resourceType>#<topLevelProperty>`. Display-only — never gates.
|
|
34
|
+
* Exported for the probe-parity pin: any note teaching "pin it" must name a
|
|
35
|
+
* pair `pinProbeCoverage` covers, or the note teaches a consent the
|
|
36
|
+
* destruction gate would halt as unprobeable.
|
|
37
|
+
*/
|
|
38
|
+
export declare const INTERRUPTION_NOTES: Readonly<Record<string, string>>;
|
|
39
|
+
/** Recompute one changed top-level property's verdict from the catalogue. */
|
|
40
|
+
export declare function registryVerdictForProperty(propertyName: string, summary: CfnRegistrySchemaSummary): RegistryPathVerdict;
|
|
41
|
+
/** A Layer-0 vs Layer-1 disagreement — logged as stale-spec telemetry. */
|
|
42
|
+
export interface RegistryVerdictDisagreement {
|
|
43
|
+
stack: string;
|
|
44
|
+
address: string;
|
|
45
|
+
resourceType: string;
|
|
46
|
+
cdkSpecMode: ReplacementMode;
|
|
47
|
+
registryMode: ReplacementMode;
|
|
48
|
+
/** The changed property paths whose verdicts drove the registry mode. */
|
|
49
|
+
paths: string[];
|
|
50
|
+
}
|
|
51
|
+
export interface OverlayResourceChangeResult {
|
|
52
|
+
change: DeployPlanResourceChange;
|
|
53
|
+
disagreement?: RegistryVerdictDisagreement;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Pure per-row recomputation. `summary === undefined` means the oracle was
|
|
57
|
+
* unavailable — the diff verdict stands, annotated unverified.
|
|
58
|
+
*/
|
|
59
|
+
export declare function overlayResourceChange(change: DeployPlanResourceChange, summary: CfnRegistrySchemaSummary | undefined, templateProperties: Record<string, unknown> | undefined): OverlayResourceChangeResult;
|
|
60
|
+
/** Per-action tally — shared with the change-set escalation's recount. */
|
|
61
|
+
export declare function recountActions(changes: readonly DeployPlanResourceChange[]): {
|
|
62
|
+
create: number;
|
|
63
|
+
update: number;
|
|
64
|
+
replace: number;
|
|
65
|
+
delete: number;
|
|
66
|
+
read: number;
|
|
67
|
+
"no-op": number;
|
|
68
|
+
};
|
|
69
|
+
export interface ApplyRegistryOverlayParams {
|
|
70
|
+
plan: DeployPlan;
|
|
71
|
+
registry: CfnRegistrySummaryReader;
|
|
72
|
+
/** The diffed template pairs — pinned-name detection reads Properties. */
|
|
73
|
+
stacks: readonly StackTemplatePair[];
|
|
74
|
+
abortSignal?: AbortSignal;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Apply the registry overlay to a computed plan. Never throws and never
|
|
78
|
+
* fails the plan: unavailable summaries degrade per-row to
|
|
79
|
+
* `"cdk-spec (unverified)"`. Summaries are fetched once per distinct resource
|
|
80
|
+
* type (the service memoises per run underneath).
|
|
81
|
+
*/
|
|
82
|
+
export declare function applyRegistryOverlay(params: ApplyRegistryOverlayParams): Promise<DeployPlan>;
|
|
83
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{maskSensitiveOutput as S}from"@fjall/util";import{logger as m}from"@fjall/util/logger";import{isAborted as g}from"../../../aws/organisations/types.js";import{isDataLoss as O}from"./classify.js";import{findPinnedPhysicalName as v,resourcePropertiesFromTemplate as h}from"./pinnedNames.js";import{renderPlanSummary as R}from"./renderDeployPlan.js";const P="RegistryOverlay",C={"AWS::RDS::DBInstance#Port":"port changes apply in place with a brief interruption (instance reboot + dependent-service re-point window); to keep the live port instead, pin it \u2014 set `port` in your fjall config to the current live value and the change disappears","AWS::RDS::DBCluster#Port":"port changes apply in place with a brief interruption (cluster reboot + dependent-service re-point window); to keep the live port instead, pin it \u2014 set `port` in your fjall config to the current live value and the change disappears"},N="/properties/";function k(e,t){const o=`${N}${e}`;if(t.createOnlyProperties.includes(o))return"will";if(t.conditionalCreateOnlyProperties.includes(o))return"conditional";const i=`${o}/`;return t.createOnlyProperties.some(s=>s.startsWith(i))||t.conditionalCreateOnlyProperties.some(s=>s.startsWith(i))?"conditional":"none"}function I(e){return e.includes("will")?"will":e.includes("conditional")?"conditional":"none"}function b(e,t){const o=t.map(i=>C[`${e.resourceType}#${i}`]).filter(i=>i!==void 0);if(o.length!==0)return o.join("; ")}function T(e){return(e.action==="update"||e.action==="replace")&&!e.retained}function D(e){return T(e)||e.action==="delete"}function M(e,t,o){if(e.action==="delete"&&t!==void 0){const n=v({resourceType:e.resourceType,properties:o,primaryIdentifier:t.primaryIdentifier});return{change:{...e,...n!==void 0?{pinnedPhysicalName:n}:{}}}}if(!T(e))return{change:e};if(t===void 0||e.propertyChanges.length===0)return{change:{...e,verdictSource:"cdk-spec (unverified)"}};const i=e.propertyChanges.map(n=>({path:n.path,verdict:k(n.path,t)})),p=I(i.map(n=>n.verdict)),s=p==="none"?"update":"replace",c=p,y=p!=="none",d=O({action:s,retained:e.retained,resourceType:e.resourceType}),u=e.propertyChanges.map((n,w)=>({...n,forcesReplacement:i[w]?.verdict!=="none"})),r=i.filter(n=>n.verdict==="none").map(n=>n.path),a=p==="none"?b(e,r):void 0,l=v({resourceType:e.resourceType,properties:o,primaryIdentifier:t.primaryIdentifier}),f={...e,action:s,replacementMode:c,destructive:y,dataLoss:d,propertyChanges:u,verdictSource:"registry",...a!==void 0?{interruptionNote:a}:{},...l!==void 0?{pinnedPhysicalName:l}:{}};return e.replacementMode===c?{change:f}:{change:f,disagreement:{stack:e.stack,address:e.address,resourceType:e.resourceType,cdkSpecMode:e.replacementMode,registryMode:c,paths:i.map(n=>n.path)}}}function x(e){const t={create:0,update:0,replace:0,delete:0,read:0,"no-op":0};for(const o of e)t[o.action]+=1;return t}async function B(e){const{plan:t,registry:o,stacks:i,abortSignal:p}=e,s=new Set(t.changes.filter(D).map(r=>r.resourceType));if(s.size===0)return t;const c=new Map;for(const r of s){if(g(p))break;const a=await o.getSchemaSummary(r,p);a.success?c.set(r,a.data):m.warn(P,"Registry oracle unavailable for type",{typeName:r,error:S(a.error.message)})}const y=new Map(i.map(r=>[r.stackName,r])),d=t.changes.map(r=>{const a=y.get(r.stack),l=r.action==="delete"?h(a?.oldTemplate,r.address):h(a?.newTemplate,r.address),{change:f,disagreement:n}=M(r,c.get(r.resourceType),l);return n!==void 0&&m.warn(P,"Registry verdict disagrees with bundled CDK spec",{...n,paths:n.paths.join(",")}),f}),u=x(d);return u["no-op"]=t.counts["no-op"],{...t,changes:d,counts:u,hasDestructiveChanges:d.some(r=>r.destructive),summary:R({counts:u,changes:d})}}export{C as INTERRUPTION_NOTES,B as applyRegistryOverlay,M as overlayResourceChange,x as recountActions,k as registryVerdictForProperty};
|
|
@@ -14,5 +14,8 @@ export declare function renderPlanSummary(plan: Pick<DeployPlan, "counts" | "cha
|
|
|
14
14
|
/** One line per resource change, grouped by action, e.g.
|
|
15
15
|
* "± replace AWS::ECS::Service WebAppCompute/Service (data loss)". */
|
|
16
16
|
export declare function renderPlanLines(plan: Pick<DeployPlan, "changes">): string[];
|
|
17
|
-
/** Map a computed plan's changes down to the wire `plan.available` PlanChange[].
|
|
17
|
+
/** Map a computed plan's changes down to the wire `plan.available` PlanChange[].
|
|
18
|
+
* `verdictSource` is deliberately NOT threaded here — the wire shape is a
|
|
19
|
+
* cross-package contract; it lands with the Phase 2 destruction-ticket wire
|
|
20
|
+
* contract. */
|
|
18
21
|
export declare function toWirePlanChanges(plan: Pick<DeployPlan, "changes">): PlanChange[];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
const
|
|
1
|
+
const i={create:0,update:1,replace:2,delete:3,read:4,"no-op":5},c={create:"+",update:"~",replace:"\xB1",delete:"-",read:">","no-op":" "};function p(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)=>i[e.action]-i[t.action]||`${e.stack}/${e.address}`.localeCompare(`${t.stack}/${t.address}`)).map(e=>{const t=[];e.replacementMode==="may"&&t.push("may replace"),e.replacementMode==="conditional"&&t.push("conditional replace"),e.dataLoss&&t.push("data loss"),e.retained&&t.push("retained"),(e.verdictSource==="cdk-spec (unverified)"||e.verdictSource==="registry (change-set unverified)")&&t.push("unverified");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,p as renderPlanSummary,l as toWirePlanChanges};
|
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
* maps a DeployPlan down to the wire shape.
|
|
9
9
|
*/
|
|
10
10
|
import type { ChangeCounts, PlanAction, PlanPropertyChange, ReplacementMode } from "../../../types/deployEvent.js";
|
|
11
|
+
/**
|
|
12
|
+
* Which oracle layer produced a row's replacement verdict. Precedence:
|
|
13
|
+
* change-set > registry > cdk-spec. `"change-set"` rows carry the
|
|
14
|
+
* authoritative verdict from the Phase 3 review-only change-set probe
|
|
15
|
+
* (`applyChangeSetEscalation`). `"registry (change-set unverified)"` marks a
|
|
16
|
+
* registry-conditional row the probe was escalated for but could not verify
|
|
17
|
+
* (probe denied, timed out, or aborted) — the conservative registry verdict
|
|
18
|
+
* stands. `"cdk-spec (unverified)"` marks rows the registry oracle could not
|
|
19
|
+
* verify (oracle unavailable, or a replacement verdict with no property paths
|
|
20
|
+
* to recompute from) — the stale bundled CDK spec is never silently treated
|
|
21
|
+
* as authoritative.
|
|
22
|
+
*/
|
|
23
|
+
export declare const REPLACEMENT_VERDICT_SOURCES: readonly ["registry", "registry (change-set unverified)", "change-set", "cdk-spec (unverified)"];
|
|
24
|
+
export type ReplacementVerdictSource = (typeof REPLACEMENT_VERDICT_SOURCES)[number];
|
|
11
25
|
/** A single resource change in a computed deploy plan. */
|
|
12
26
|
export interface DeployPlanResourceChange {
|
|
13
27
|
/** The stack the resource belongs to. */
|
|
@@ -28,6 +42,23 @@ export interface DeployPlanResourceChange {
|
|
|
28
42
|
retained: boolean;
|
|
29
43
|
/** Per-property change detail — paths only, never values (no secret leak). */
|
|
30
44
|
propertyChanges: PlanPropertyChange[];
|
|
45
|
+
/**
|
|
46
|
+
* Oracle layer behind the replacement verdict. Absent when no verdict
|
|
47
|
+
* question arises (creates, imports, rows the overlay never evaluated).
|
|
48
|
+
*/
|
|
49
|
+
verdictSource?: ReplacementVerdictSource;
|
|
50
|
+
/**
|
|
51
|
+
* Advisory service-interruption note for an in-place update (e.g. an RDS
|
|
52
|
+
* port change reboots the instance). Display-only — never gates.
|
|
53
|
+
*/
|
|
54
|
+
interruptionNote?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Literal physical name pinned in the template (via the registry
|
|
57
|
+
* `primaryIdentifier` or the `@fjall/util` fallback map). A pinned name on
|
|
58
|
+
* a replacement row means CloudFormation will REFUSE the update
|
|
59
|
+
* ("cannot update a stack when a custom-named resource requires replacing").
|
|
60
|
+
*/
|
|
61
|
+
pinnedPhysicalName?: string;
|
|
31
62
|
}
|
|
32
63
|
/** A fully computed deploy plan, ready to render and to gate on. */
|
|
33
64
|
export interface DeployPlan {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e=["registry","registry (change-set unverified)","change-set","cdk-spec (unverified)"];export{e as REPLACEMENT_VERDICT_SOURCES};
|
|
@@ -3,7 +3,7 @@ import type { TrailLifecycleState } from "@fjall/util/config";
|
|
|
3
3
|
import type { ResourceEvent } from "@fjall/util/aws";
|
|
4
4
|
import type { OrgConfig } from "../types/config/orgConfig.js";
|
|
5
5
|
import type { DeployIdentity } from "../types/credentials.js";
|
|
6
|
-
import type { AwsProviderCredentials } from "../aws/AwsProvider.js";
|
|
6
|
+
import type { AwsProvider, AwsProviderCredentials } from "../aws/AwsProvider.js";
|
|
7
7
|
import { SimpleAwsProvider } from "../aws/SimpleAwsProvider.js";
|
|
8
8
|
import type { DeploymentContext } from "../types/deployment/DeploymentTypes.js";
|
|
9
9
|
import type { DeployCallbacks } from "../types/callbacks.js";
|
|
@@ -76,6 +76,42 @@ export declare function buildParamsContext(params: {
|
|
|
76
76
|
* provider the Account stack legitimately owns.
|
|
77
77
|
*/
|
|
78
78
|
export declare function resolveAccountBootstrapSkipOidc(deployType: "organisation" | "platform" | "account", orgConfig: OrgConfig | undefined, explicitSkipOidc: boolean | undefined, targetIsOrganisationTier?: boolean): boolean | undefined;
|
|
79
|
+
/** Synth-time context flags the OIDC gate mirror reads — see {@link synthCarriedOidcConnector}. */
|
|
80
|
+
export interface SynthOidcContextFlags {
|
|
81
|
+
fjallOrgId?: string;
|
|
82
|
+
fjallOidcConfigured?: boolean;
|
|
83
|
+
fjallAccountGlobalsConfigured?: boolean;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether the synthesised Account/Platform stack carried the OidcConnector —
|
|
87
|
+
* the deploy-core mirror of the construct gate in
|
|
88
|
+
* `components/infrastructure/lib/patterns/aws/account.ts` (`receivesDeployRole()
|
|
89
|
+
* && fjallOrgId && !oidcAlreadyConfigured && !accountGlobalsConfigured`).
|
|
90
|
+
* `receivesDeployRole` is structural here: only platform/account deploys reach
|
|
91
|
+
* the post-deploy OIDC reconcile, and both patterns receive the deploy role
|
|
92
|
+
* (the Organisation pattern, which does not, only synthesises under
|
|
93
|
+
* `deployType: "organisation"`). An empty-string `fjallOrgId` reads as absent,
|
|
94
|
+
* matching both coupled sites: the construct gate tests truthiness and
|
|
95
|
+
* CdkArgumentBuilder drops the `-c fjallOrgId` argument entirely for `""`.
|
|
96
|
+
*
|
|
97
|
+
* Gates {@link reconcileOidcProviderIfCarried}: the reconcile enforces
|
|
98
|
+
* provider presence only where the current template claims ownership. A stack
|
|
99
|
+
* that deliberately skipped the connector — solo/standalone-management
|
|
100
|
+
* (Quick-Create owns its provider; restore is RECONNECT, never redeploy),
|
|
101
|
+
* non-home region, or no org identity — must never have a provider created
|
|
102
|
+
* for it on the deploy path.
|
|
103
|
+
*/
|
|
104
|
+
export declare function synthCarriedOidcConnector(context: SynthOidcContextFlags): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Post-CFN OIDC provider reconcile, gated on {@link synthCarriedOidcConnector}.
|
|
107
|
+
* A pre-2.20.0 → current template migration deletes the account-global OIDC
|
|
108
|
+
* provider during CloudFormation's cleanup phase (after the replacement
|
|
109
|
+
* adopt-or-create resource has already run), so callers MUST invoke this only
|
|
110
|
+
* after their deploy — or unchanged-skip probe — returns. Composing the gate
|
|
111
|
+
* with the reconcile here keeps any future deploy path from invoking the
|
|
112
|
+
* reconcile ungated.
|
|
113
|
+
*/
|
|
114
|
+
export declare function reconcileOidcProviderIfCarried(context: SynthOidcContextFlags, provider: Pick<AwsProvider, "getClient">, callbacks: DeployCallbacks, abortSignal?: AbortSignal): Promise<void>;
|
|
79
115
|
/** Forward onOutput callback — reduces lambda repetition across orchestration files. */
|
|
80
116
|
export declare function forwardOutput(callbacks: DeployCallbacks): (chunk: string) => void;
|
|
81
117
|
/** Forward onResourceProgress callback — reduces lambda repetition across orchestration files. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{success as f,failure as
|
|
1
|
+
import{success as f,failure as s}from"@fjall/generator";import{getErrorMessage as m,maskSensitiveOutput as a,sleep as y}from"@fjall/util";import{logger as O}from"@fjall/util/logger";import{IAMClient as R}from"@aws-sdk/client-iam";import{hasOrganisationTierAccount as S}from"../aws/organisations/accountGlobals.js";import{reconcileOidcProviderAfterDeploy as w}from"../aws/organisations/oidcProvider.js";import{SimpleAwsProvider as b}from"../aws/SimpleAwsProvider.js";const h={account:"active",draining:"draining",org:"removed"};function T(e,r){return e!==void 0&&e!==""&&r!==void 0&&r!==""&&e!==r}function F(e){const r=T(e.region,e.primaryRegion);return{...e.orgConfig!==void 0?{orgConfig:JSON.stringify(e.orgConfig)}:{},...e.identity!==void 0?{fjallOrgId:e.identity.fjallOrgId}:{},...e.skipOidc?{fjallOidcConfigured:!0}:{},...r?{fjallAccountGlobalsConfigured:!0}:{},...e.trailLifecycle!==void 0?{fjallAccountTrailState:h[e.trailLifecycle]}:{}}}function Y(e,r,t,n){if(t!==void 0)return t;if(e==="account")return S(r?.providerAccounts)?n===!0:!0}function _(e){return e.fjallOrgId!==void 0&&e.fjallOrgId!==""&&e.fjallOidcConfigured!==!0&&e.fjallAccountGlobalsConfigured!==!0}async function G(e,r,t,n){_(e)&&await w(r.getClient(R),t,n)}function $(e){return r=>e.onOutput?.(r)}function N(e){return r=>e.onResourceProgress?.(r)}function U(e){if(!e.success||e.data.length===0)return;const r={};for(const t of e.data)t.OutputKey&&t.OutputValue!==void 0&&(r[t.OutputKey]=t.OutputValue);return Object.keys(r).length>0?r:void 0}const g="OrganizationAccountAccessRole",l=5,v=5e3,K=3e4;function x(e){return`arn:aws:iam::${e}:role/${g}`}function M(e,r){return r===void 0?y(e):r.aborted?Promise.resolve():new Promise(t=>{const n=()=>{t()};r.addEventListener("abort",n,{once:!0}),y(e).then(()=>{r.removeEventListener("abort",n),t()})})}async function V(e,r,t,n,o){if(!e.assumeRole)return s(new Error("AwsProvider does not support assumeRole"));const u=x(r),E=e.assumeRole.bind(e);let i;for(let c=0;c<=l;c++)try{i=await E(u,n);break}catch(d){const p=d instanceof Error?d.name:void 0;if(p==="AccessDenied"||p==="AccessDeniedException")return s(new Error(`Access denied assuming ${g} in account ${r}. The role may not exist or may not trust the management account.`));if(c<l){const A=Math.min(v*2**c,K);if(O.debug("assumeCascadeRole",`Attempt ${c+1} failed for account ${r}, retrying in ${Math.round(A/1e3)}s`,{error:a(m(d))}),await M(A,o),o?.aborted)return s(new Error(`Aborted while retrying assume-role for account ${r}`));continue}return s(new Error(`Failed to assume role in account ${r} after ${l+1} attempts: ${a(m(d))}`))}if(!i)return s(new Error(`Failed to assume role in account ${r}`));const C=new b({accessKeyId:i.accessKeyId,secretAccessKey:i.secretAccessKey,sessionToken:i.sessionToken,region:t,accountId:r});return f({provider:C,credentials:{accessKeyId:i.accessKeyId,secretAccessKey:i.secretAccessKey,sessionToken:i.sessionToken}})}async function z(e,r,t,n){const o=await e.cdkService.runCdkSynth(r,u=>t.onCdkOutput?.(u,"synth"));if(!o.success){const u=new Error(a(`${n}: ${o.error}`));return t.onError?.(u),s(u)}return f(void 0)}async function J(e,r,t){t.onCDKBootstrap?.("bootstrapping");const n=await e.cdkService.runCdkBootstrap(r,$(t));if(!n.success){t.onCDKBootstrap?.("failed");const o=new Error(a(`Bootstrap failed: ${n.error}`));return t.onError?.(o),s(o)}return t.onCDKBootstrap?.("complete"),f(void 0)}export{V as assumeCascadeRole,J as bootstrapOrFail,x as buildCascadeRoleArn,F as buildParamsContext,U as collectStackOutputs,$ as forwardOutput,N as forwardResourceProgress,G as reconcileOidcProviderIfCarried,Y as resolveAccountBootstrapSkipOidc,_ as synthCarriedOidcConnector,z as synthOrFail,T as targetsNonPrimaryRegion};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class 1 drift classification over a stack's last CloudFormation operation
|
|
3
|
+
* (drift-remediation plan § Phase 5 item 1). Fetches the failure events of
|
|
4
|
+
* the most recent operation via `DescribeStackEvents` (newest-first), maps
|
|
5
|
+
* them onto the shared NotFound anchors (`@fjall/util/aws` `driftSuspects`,
|
|
6
|
+
* derived from the Phase 0 BC fixture `__tests__/fixtures/bc-stack-events.json`)
|
|
7
|
+
* and returns out-of-band-deletion SUSPECTS — never verdicts; `driftProbe.ts`
|
|
8
|
+
* confirms.
|
|
9
|
+
*
|
|
10
|
+
* Suspect `statusReason`s are masked at construction (a CloudFormation status
|
|
11
|
+
* reason can embed anything a failed handler echoed), so journal writes and
|
|
12
|
+
* CLI sinks may surface them directly.
|
|
13
|
+
*/
|
|
14
|
+
import { type Result } from "@fjall/generator";
|
|
15
|
+
import { type ResourceEvent } from "@fjall/util/aws";
|
|
16
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
17
|
+
import type { DriftSuspect } from "../../types/drift.js";
|
|
18
|
+
/**
|
|
19
|
+
* Fetch the events of the stack's most recent operation, newest-first. Pages
|
|
20
|
+
* `DescribeStackEvents` until the operation's opening event (inclusive — the
|
|
21
|
+
* shared `isOperationOpeningEvent` boundary, which a CancelUpdateStack
|
|
22
|
+
* rollback's "User Initiated" stamp does NOT satisfy) or the page budget is
|
|
23
|
+
* exhausted.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fetchLastOperationEvents(aws: AwsProvider, stackName: string, abortSignal?: AbortSignal): Promise<Result<ResourceEvent[], Error>>;
|
|
26
|
+
/**
|
|
27
|
+
* Classify a stack's last-operation events into drift suspects. Pure —
|
|
28
|
+
* callers fetch events via {@link fetchLastOperationEvents} or hand over a
|
|
29
|
+
* monitor's history.
|
|
30
|
+
*/
|
|
31
|
+
export declare function classifyDriftFailure(stackName: string, events: readonly ResourceEvent[]): DriftSuspect[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{CloudFormationClient as p,DescribeStackEventsCommand as m}from"@aws-sdk/client-cloudformation";import{success as a,failure as d}from"@fjall/generator";import{getErrorMessage as g,maskSensitiveOutput as l}from"@fjall/util";import{classifyDriftSuspectEvents as y,isOperationOpeningEvent as R}from"@fjall/util/aws";import{logger as E}from"@fjall/util/logger";import{composeSdkAbortSignal as S,isAborted as v}from"../../aws/organisations/types.js";const I="DriftClassify",h=5;function T(e){return e.includes("FAILED")}function D(e){return e.LogicalResourceId===void 0||e.ResourceType===void 0||e.ResourceStatus===void 0?null:{logicalId:e.LogicalResourceId,resourceType:e.ResourceType,status:e.ResourceStatus,timestamp:e.Timestamp??new Date(0),...e.PhysicalResourceId!==void 0&&e.PhysicalResourceId!==""?{physicalId:e.PhysicalResourceId}:{},...e.ResourceStatusReason!==void 0?{statusReason:e.ResourceStatusReason}:{}}}async function L(e,n,r){const t=e.getClient(p),o=[];let s,u=0;try{do{if(v(r))return d(new Error(`Drift event scan aborted for ${n}`));const i=await t.send(new m({StackName:n,...s!==void 0?{NextToken:s}:{}}),{abortSignal:S(r)});u+=1;for(const f of i.StackEvents??[]){const c=D(f);if(c!==null&&(o.push(c),R(c,n)))return a(o)}if(s=i.NextToken,u>=h&&s!==void 0)return E.debug(I,"Event scan truncated at page budget",{stackName:n,pages:u,eventsCollected:o.length}),a(o)}while(s!==void 0);return a(o)}catch(i){return d(new Error(`Failed to read stack events for ${n}: ${l(g(i))}`))}}function O(e,n){const r=n.filter(t=>T(t.status));return y(r).map(t=>({stackName:e,logicalId:t.logicalId,resourceType:t.resourceType,...t.physicalId!==void 0?{physicalId:t.physicalId}:{},failedStatus:t.failedStatus,statusReason:l(t.statusReason)}))}export{O as classifyDriftFailure,L as fetchLastOperationEvents};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drift-detection pipeline for one stack: last-operation event scan →
|
|
3
|
+
* suspect classification → journal merge → probe → journal upkeep. Shared by
|
|
4
|
+
* `fjall drift detect` and the [G2] deploy pre-flight so both surfaces report
|
|
5
|
+
* identical findings for identical state.
|
|
6
|
+
*
|
|
7
|
+
* Journal upkeep: confirmed or unconfirmed findings (the wedge persists or
|
|
8
|
+
* could not be ruled out) re-record the journal so the next deploy re-probes;
|
|
9
|
+
* all-present or stack-missing findings clear it.
|
|
10
|
+
*/
|
|
11
|
+
import { type Result } from "@fjall/generator";
|
|
12
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
13
|
+
import type { DriftFinding, DriftSuspect } from "../../types/drift.js";
|
|
14
|
+
import type { DriftProbeRunner } from "./driftProbe.js";
|
|
15
|
+
import { type DriftJournalOptions } from "./driftSuspectJournal.js";
|
|
16
|
+
export interface DetectStackDriftDeps {
|
|
17
|
+
aws: AwsProvider;
|
|
18
|
+
probe: DriftProbeRunner;
|
|
19
|
+
journal?: DriftJournalOptions;
|
|
20
|
+
/** Injectable clock for journal timestamps. */
|
|
21
|
+
now?: () => Date;
|
|
22
|
+
}
|
|
23
|
+
export interface DetectStackDriftParams {
|
|
24
|
+
stackName: string;
|
|
25
|
+
abortSignal?: AbortSignal;
|
|
26
|
+
}
|
|
27
|
+
export interface StackDriftReport {
|
|
28
|
+
stackName: string;
|
|
29
|
+
suspects: DriftSuspect[];
|
|
30
|
+
findings: DriftFinding[];
|
|
31
|
+
/** How many suspects came from the live event scan vs the journal. */
|
|
32
|
+
suspectSources: {
|
|
33
|
+
events: number;
|
|
34
|
+
journal: number;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export declare function detectStackDrift(deps: DetectStackDriftDeps, params: DetectStackDriftParams): Promise<Result<StackDriftReport, Error>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{STACK_NOT_FOUND_PATTERN as b}from"@fjall/util/aws";import{success as g,failure as S}from"@fjall/generator";import{classifyDriftFailure as D,fetchLastOperationEvents as h}from"./classifyDriftFailure.js";import{clearDriftSuspects as j,readDriftSuspects as _,recordDriftSuspects as I}from"./driftSuspectJournal.js";async function E(e,m){const{stackName:t,abortSignal:o}=m,n=e.aws.getAccountId(),a=e.aws.getRegion(),r=await h(e.aws,t,o);let c=[];if(r.success)c=D(t,r.data);else if(!r.error.message.includes(b))return S(r.error);const l=await _(n,a,t,e.journal),v=new Set(c.map(s=>s.logicalId)),f=(l?.suspects??[]).filter(s=>!v.has(s.logicalId)),i=[...c,...f];if(i.length===0)return g({stackName:t,suspects:[],findings:[],suspectSources:{events:0,journal:0}});const u=await e.probe.probeSuspects({stackName:t,suspects:i,...o!==void 0?{abortSignal:o}:{}});if(!u.success)return S(u.error);const d=u.data,p=d.filter(s=>s.verdict==="confirmed-deleted"||s.verdict==="unconfirmed").map(({verdict:s,verdictDetail:R,probeMethod:A,verbs:N,...w})=>w);return p.length>0?await I({accountId:n,region:a,stackName:t,recordedAt:(e.now?.()??new Date).toISOString(),suspects:p},e.journal):l!==null&&await j(n,a,t,e.journal),g({stackName:t,suspects:i,findings:d,suspectSources:{events:c.length,journal:f.length}})}export{E as detectStackDrift};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [G2] Stateless drift pre-flight (drift-remediation plan § Phase 5).
|
|
3
|
+
* Before the cdk spawn, each stack about to deploy is checked for the Class 1
|
|
4
|
+
* wedge: a stack in a failed/rolled-back status, or one carrying journalled
|
|
5
|
+
* suspects, is probed (bounded); a CONFIRMED out-of-band deletion blocks the
|
|
6
|
+
* deploy with a named finding — the deploy would only re-wedge.
|
|
7
|
+
*
|
|
8
|
+
* Statelessness: the trigger reads live stack status, so a fresh machine with
|
|
9
|
+
* an empty journal still catches the wedge. Deviation from the plan text
|
|
10
|
+
* recorded in the Phase 5 report: the plan assumed the active-deployment
|
|
11
|
+
* guard's DescribeStacks already ran on the deploy path — it is destroy-only,
|
|
12
|
+
* so this pre-flight issues ONE `getStackStatus` (DescribeStacks) per stack
|
|
13
|
+
* to deploy. Healthy stacks with an empty journal pay nothing further.
|
|
14
|
+
*
|
|
15
|
+
* Fail-open on probe infrastructure errors: only `confirmed-deleted` blocks;
|
|
16
|
+
* unconfirmed findings surface as warnings and the deploy proceeds.
|
|
17
|
+
*/
|
|
18
|
+
import { type Result } from "@fjall/generator";
|
|
19
|
+
import type { CloudFormationService } from "../../services/infrastructure/CloudFormationService.js";
|
|
20
|
+
import type { DriftFinding } from "../../types/drift.js";
|
|
21
|
+
import { type DetectStackDriftDeps } from "./detectStackDrift.js";
|
|
22
|
+
/**
|
|
23
|
+
* Stack statuses that evidence a possibly-wedged last operation. Narrow by
|
|
24
|
+
* design: rollback-complete and update-failed are where the BC wedge lands;
|
|
25
|
+
* rollback-failed is the same class one step worse. IN_PROGRESS states are
|
|
26
|
+
* the active-deployment guard's concern, not drift's.
|
|
27
|
+
*/
|
|
28
|
+
export declare const DRIFT_PREFLIGHT_TRIGGER_STATUSES: ReadonlySet<string>;
|
|
29
|
+
/** Bounds the whole pre-flight so a slow probe cannot stall the deploy. */
|
|
30
|
+
export declare const DRIFT_PREFLIGHT_BUDGET_MS = 90000;
|
|
31
|
+
export interface RunDriftPreFlightParams {
|
|
32
|
+
stackNames: readonly string[];
|
|
33
|
+
cfnService: Pick<CloudFormationService, "getStackStatus">;
|
|
34
|
+
abortSignal?: AbortSignal;
|
|
35
|
+
budgetMs?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface DriftPreFlightOutcome {
|
|
38
|
+
confirmed: DriftFinding[];
|
|
39
|
+
unconfirmed: DriftFinding[];
|
|
40
|
+
/** Stacks whose status or journal triggered a probe. */
|
|
41
|
+
probedStacks: string[];
|
|
42
|
+
}
|
|
43
|
+
export declare function runDriftPreFlight(deps: DetectStackDriftDeps, params: RunDriftPreFlightParams): Promise<Result<DriftPreFlightOutcome, Error>>;
|
|
44
|
+
export interface DriftPreFlightBlockExtras {
|
|
45
|
+
/** Journal says flip-applied but the live template VERIFIABLY no longer
|
|
46
|
+
* carries the flip — surgery must re-run before deploying. */
|
|
47
|
+
flipNotLive?: ReadonlyArray<{
|
|
48
|
+
finding: DriftFinding;
|
|
49
|
+
opId: string;
|
|
50
|
+
}>;
|
|
51
|
+
/** The live template could not be read, so the flip's liveness is
|
|
52
|
+
* unverifiable — the cure is retrying the deploy, not re-running repair.
|
|
53
|
+
* `message` is pre-masked at construction. */
|
|
54
|
+
flipUnverified?: ReadonlyArray<{
|
|
55
|
+
finding: DriftFinding;
|
|
56
|
+
opId: string;
|
|
57
|
+
message: string;
|
|
58
|
+
}>;
|
|
59
|
+
/** Flip is live but the deploy's synth still declares the target — this
|
|
60
|
+
* deploy is not the converge. */
|
|
61
|
+
stillDeclared?: ReadonlyArray<{
|
|
62
|
+
finding: DriftFinding;
|
|
63
|
+
opId: string;
|
|
64
|
+
}>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Compose the blocking failure message for confirmed pre-flight findings.
|
|
68
|
+
* `inProgressOpIds` names forget operations that cover blocked findings but
|
|
69
|
+
* have not yet applied the Retain flip — the cure is resuming them, not
|
|
70
|
+
* deploying ([G2] × remediation-journal interaction). `extras` carries the
|
|
71
|
+
* partition's suppression-refused buckets, each with its own cure.
|
|
72
|
+
*/
|
|
73
|
+
export declare function formatDriftPreFlightBlock(confirmed: readonly DriftFinding[], inProgressOpIds?: readonly string[], extras?: DriftPreFlightBlockExtras): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import{success as g}from"@fjall/generator";import{detectStackDrift as $}from"./detectStackDrift.js";import{readDriftSuspects as v}from"./driftSuspectJournal.js";const y=new Set(["UPDATE_ROLLBACK_COMPLETE","UPDATE_ROLLBACK_FAILED","UPDATE_FAILED"]),b=9e4;async function D(o,l){const n=l.budgetMs??b,d=l.abortSignal!==void 0?AbortSignal.any([l.abortSignal,AbortSignal.timeout(n)]):AbortSignal.any([AbortSignal.timeout(n)]),r={confirmed:[],unconfirmed:[],probedStacks:[]},u=await Promise.all(l.stackNames.map(async i=>{const a=await l.cfnService.getStackStatus(i);return{stackName:i,status:a.success?a.data?.status??null:null}}));for(const{stackName:i,status:a}of u){if(!(a!==null&&y.has(a))){const t=await v(o.aws.getAccountId(),o.aws.getRegion(),i,o.journal);if(t===null||t.suspects.length===0)continue}r.probedStacks.push(i);const c=await $(o,{stackName:i,abortSignal:d});if(c.success)for(const t of c.data.findings)t.verdict==="confirmed-deleted"?r.confirmed.push(t):(t.verdict==="unconfirmed"||t.verdict==="stack-missing")&&r.unconfirmed.push(t)}return g(r)}function I(o,l=[],n={}){const d=o.map(e=>` ${e.stackName} \u203A ${e.logicalId} (${e.resourceType})${e.physicalId!==void 0?` [${e.physicalId}]`:""} \u2014 ${e.verdictDetail}`),r=l.map(e=>`Remediation ${e} is in progress \u2014 run 'fjall drift resume ${e}' to continue it.`),u=(n.flipNotLive??[]).map(({finding:e,opId:s})=>`Remediation ${s} recorded its Retain flip on ${e.stackName} \u203A ${e.logicalId}, but the flip is no longer live on the stack \u2014 re-run 'fjall drift repair' to re-verify before deploying.`),i=(n.flipUnverified??[]).map(({finding:e,opId:s,message:h})=>`Remediation ${s} should complete with this deploy, but ${e.stackName} \u203A ${e.logicalId} could not be verified: the live template could not be read to confirm the Retain flip is still live \u2014 ${h}. Retry the deploy.`),a=(n.stillDeclared??[]).map(({finding:e,opId:s})=>`Remediation ${s} is ready to converge, but ${e.stackName} \u203A ${e.logicalId} is still declared in your fjall config \u2014 remove the resource from the config, then deploy to complete the forget.`),f=[...r,...u,...i,...a],c=new Set((n.flipUnverified??[]).map(({finding:e})=>`${e.stackName}/${e.logicalId}`)),t=o.length>0&&o.every(e=>c.has(`${e.stackName}/${e.logicalId}`)),m=t?`Deploy blocked: the Retain-flip state of ${o.length} mid-remediation resource(s) could not be verified (the live template could not be read):
|
|
2
|
+
`:`Deploy blocked: CloudFormation still tracks ${o.length} resource(s) deleted outside CloudFormation \u2014 deploying would fail and re-wedge the stack:
|
|
3
|
+
`,p=t?"Retry the deploy \u2014 verification is transient when the live template read fails; if it keeps failing, check CloudFormation access to the stack template.":"Run 'fjall drift detect' for the full report, then 'fjall drift repair --remediate <physicalName>=<verb>' to remediate confirmed deletions.";return m+`${d.join(`
|
|
4
|
+
`)}
|
|
5
|
+
${p}${f.length>0?`
|
|
6
|
+
${f.join(`
|
|
7
|
+
`)}`:""}`}export{b as DRIFT_PREFLIGHT_BUDGET_MS,y as DRIFT_PREFLIGHT_TRIGGER_STATUSES,I as formatDriftPreFlightBlock,D as runDriftPreFlight};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift-suspect confirmation probe (drift-remediation plan § Phase 5 item 2).
|
|
3
|
+
* Turns SUSPECTS from `classifyDriftFailure.ts` into per-resource verdicts:
|
|
4
|
+
*
|
|
5
|
+
* - A `DescribeStacks` existence check first — a deleted stack short-circuits
|
|
6
|
+
* every suspect to `stack-missing` (it must never read as "nothing wrong").
|
|
7
|
+
* - A `ListStackResources` membership gate next — a suspect the stack no
|
|
8
|
+
* longer tracks (cured by removing it from the template) reads `present`
|
|
9
|
+
* (recovered), never probed against the owning service; probing its
|
|
10
|
+
* long-dead physicalId would wrongly confirm and re-journal forever.
|
|
11
|
+
* - Direct per-type probes (KMS DescribeKey, S3 HeadBucket/GetBucketPolicy,
|
|
12
|
+
* Secrets Manager DescribeSecret) — only an authoritative NotFound from the
|
|
13
|
+
* owning service (or a KMS key in PendingDeletion/PendingReplicaDeletion,
|
|
14
|
+
* which the CloudFormation handler maps to NotFound) yields
|
|
15
|
+
* `confirmed-deleted`.
|
|
16
|
+
* - CloudFormation drift-API fallback (`DetectStackDrift` → poll →
|
|
17
|
+
* `DescribeStackResourceDrifts`) for types without a direct probe.
|
|
18
|
+
*
|
|
19
|
+
* Fail-closed throughout: throttles, AccessDenied, network failures and abort
|
|
20
|
+
* all yield `unconfirmed` (repair-only presentation downstream), never a
|
|
21
|
+
* destructive-eligible verdict.
|
|
22
|
+
*
|
|
23
|
+
* [G8] boundary audit (2026-07-08): every action this probe issues —
|
|
24
|
+
* cloudformation:DescribeStacks/ListStackResources/DetectStackDrift/
|
|
25
|
+
* DescribeStackDriftDetectionStatus/DescribeStackResourceDrifts,
|
|
26
|
+
* kms:DescribeKey, s3:HeadBucket (s3:ListBucket)/GetBucketPolicy,
|
|
27
|
+
* secretsmanager:DescribeSecret — is read-only and appears in no Deny of the
|
|
28
|
+
* deploy session policy (`deploySessionPolicy.ts` fences foreign CFN/ECR
|
|
29
|
+
* MUTATION only) nor of any security-tier permissions boundary
|
|
30
|
+
* (`generator/src/schemas/securityBoundary.ts`). No policy change needed.
|
|
31
|
+
*
|
|
32
|
+
* Finding `verdictDetail`s are masked at construction, so CLI/journal sinks
|
|
33
|
+
* may surface them directly.
|
|
34
|
+
*/
|
|
35
|
+
import { type Result } from "@fjall/generator";
|
|
36
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
37
|
+
import { type DriftFinding, type DriftSuspect, type DriftVerdict } from "../../types/drift.js";
|
|
38
|
+
export interface ProbeDriftSuspectsParams {
|
|
39
|
+
stackName: string;
|
|
40
|
+
suspects: readonly DriftSuspect[];
|
|
41
|
+
abortSignal?: AbortSignal;
|
|
42
|
+
}
|
|
43
|
+
/** The narrow surface callers need — DriftProbe satisfies it; tests stub. */
|
|
44
|
+
export interface DriftProbeRunner {
|
|
45
|
+
probeSuspects(params: ProbeDriftSuspectsParams): Promise<Result<DriftFinding[], Error>>;
|
|
46
|
+
}
|
|
47
|
+
export interface DriftProbeOptions {
|
|
48
|
+
/** Poll cadence for the drift-API fallback; defaults to 3 s. */
|
|
49
|
+
pollIntervalMs?: number;
|
|
50
|
+
/** Overall drift-API poll budget; defaults to 90 s. */
|
|
51
|
+
pollTimeoutMs?: number;
|
|
52
|
+
/** Injectable clock for timeout tests. */
|
|
53
|
+
now?: () => number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* probeMethod stamped by the ListStackResources membership gate below. Its
|
|
57
|
+
* "present" verdict means "no longer tracked by the stack — cured by removal
|
|
58
|
+
* from the template", NOT physical presence: no owning service was consulted.
|
|
59
|
+
* Forget-closure accounting discriminates on this constant to keep
|
|
60
|
+
* membership-gate verdicts out of the retained-by-redeclaration bucket
|
|
61
|
+
* (coupled values — shared at 2 occurrences with forgetResource.ts).
|
|
62
|
+
*/
|
|
63
|
+
export declare const MEMBERSHIP_GATE_PROBE_METHOD = "cloudformation:ListStackResources";
|
|
64
|
+
export interface PhysicalPresenceOutcome {
|
|
65
|
+
/** `present` (alive), `confirmed-deleted`, or `unconfirmed` — never a
|
|
66
|
+
* membership-gate verdict; the stack listing is deliberately not consulted. */
|
|
67
|
+
verdict: Extract<DriftVerdict, "present" | "confirmed-deleted" | "unconfirmed">;
|
|
68
|
+
detail: string;
|
|
69
|
+
/** The owning-service action consulted, or "none" for unsupported types. */
|
|
70
|
+
probeMethod: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* [G6] Gate-bypassing physical-presence probe: ask the OWNING SERVICE whether
|
|
74
|
+
* `physicalId` is alive, with no ListStackResources membership gate in front.
|
|
75
|
+
* `probeSuspects`' gate is correct for drift confirmation (an untracked
|
|
76
|
+
* suspect is cured) but poisonous at absent-bucket closure — every
|
|
77
|
+
* absent-from-stack target would read "present"/cured with ZERO physical
|
|
78
|
+
* verification, so a DELETE_FAILED orphan closed by a later update-cleanup
|
|
79
|
+
* strip would be recorded as an executed deletion. Types without a direct
|
|
80
|
+
* probe return `unconfirmed` (probeMethod "none") — callers close hedged.
|
|
81
|
+
* Fail-soft: SDK failures are `unconfirmed`, never a throw.
|
|
82
|
+
*/
|
|
83
|
+
export declare function probePhysicalPresence(aws: AwsProvider, params: {
|
|
84
|
+
resourceType: string;
|
|
85
|
+
physicalId: string;
|
|
86
|
+
abortSignal?: AbortSignal;
|
|
87
|
+
}): Promise<PhysicalPresenceOutcome>;
|
|
88
|
+
export declare class DriftProbe implements DriftProbeRunner {
|
|
89
|
+
private readonly aws;
|
|
90
|
+
private readonly pollIntervalMs;
|
|
91
|
+
private readonly pollTimeoutMs;
|
|
92
|
+
private readonly now;
|
|
93
|
+
constructor(aws: AwsProvider, options?: DriftProbeOptions);
|
|
94
|
+
probeSuspects(params: ProbeDriftSuspectsParams): Promise<Result<DriftFinding[], Error>>;
|
|
95
|
+
private checkStackExists;
|
|
96
|
+
/**
|
|
97
|
+
* The stack's current resources: logicalId → PhysicalResourceId ("" when
|
|
98
|
+
* CloudFormation reports none). Key presence is the membership ground
|
|
99
|
+
* truth for the cured-by-template-removal gate, so this ALWAYS lists —
|
|
100
|
+
* suspect-supplied physicalIds (journal-sourced) must never bypass the
|
|
101
|
+
* membership check. Returns null when the listing fails or aborts
|
|
102
|
+
* mid-pagination (fail-soft: probing proceeds on suspect-supplied ids and
|
|
103
|
+
* membership stays unverified rather than misread as "not a member").
|
|
104
|
+
*/
|
|
105
|
+
private listStackResources;
|
|
106
|
+
/**
|
|
107
|
+
* Generic fallback for types without a direct probe: run stack drift
|
|
108
|
+
* detection, then read each suspect's drift status. Only a DELETED drift
|
|
109
|
+
* status confirms; IN_SYNC/MODIFIED prove presence; everything else
|
|
110
|
+
* (detection failure, NOT_CHECKED, unsupported type) stays unconfirmed.
|
|
111
|
+
*/
|
|
112
|
+
private probeViaDriftApi;
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{CloudFormationClient as T,DescribeStacksCommand as R,DescribeStackDriftDetectionStatusCommand as _,DescribeStackResourceDriftsCommand as P,DetectStackDriftCommand as K,ListStackResourcesCommand as O}from"@aws-sdk/client-cloudformation";import{KMSClient as N,DescribeKeyCommand as C}from"@aws-sdk/client-kms";import{S3Client as I,HeadBucketCommand as v,GetBucketPolicyCommand as F}from"@aws-sdk/client-s3";import{SecretsManagerClient as A,DescribeSecretCommand as B}from"@aws-sdk/client-secrets-manager";import{success as w,failure as L}from"@fjall/generator";import{getErrorMessage as b,maskSensitiveOutput as E}from"@fjall/util";import{STACK_NOT_FOUND_PATTERN as $}from"@fjall/util/aws";import{logger as U}from"@fjall/util/logger";import{composeSdkAbortSignal as l,extractErrorName as D,isAborted as k}from"../../aws/organisations/types.js";import{sleepAbortable as G}from"../../util/sleepAbortable.js";import{verbsForDriftVerdict as H}from"../../types/drift.js";const W="DriftProbe",V=3e3,Y=9e4,M=new Set(["NotFoundException"]),j=new Set(["NotFound","NoSuchBucket"]),q=new Set(["NoSuchBucketPolicy","NoSuchBucket","NotFound"]),z=new Set(["ResourceNotFoundException"]);function p(o){return{verdict:"confirmed-deleted",detail:o}}function y(o){return{verdict:"present",detail:o}}function g(o){return{verdict:"unconfirmed",detail:`Probe could not confirm: ${E(b(o))}`}}async function J(o,r,t){try{const n=(await o.getClient(N).send(new C({KeyId:r}),{abortSignal:l(t)})).KeyMetadata?.KeyState;return n==="PendingDeletion"||n==="PendingReplicaDeletion"?p(`Key is scheduled for deletion (state: ${n}) \u2014 CloudFormation treats it as NotFound; cancellable via kms:CancelKeyDeletion until the waiting period ends`):y(n!==void 0?`Key exists (state: ${n})`:"Key exists")}catch(e){return M.has(D(e))?p("kms:DescribeKey returned NotFoundException"):g(e)}}async function Q(o,r,t){try{const n=(await o.getClient(N).send(new C({KeyId:r}),{abortSignal:l(t)})).KeyMetadata?.KeyState;return y(n!==void 0?`Alias exists (resolved key state: ${n})`:"Alias exists")}catch(e){return M.has(D(e))?p("kms:DescribeKey returned NotFoundException for the alias"):g(e)}}async function X(o,r,t){try{return await o.getClient(I).send(new v({Bucket:r}),{abortSignal:l(t)}),y("Bucket exists")}catch(e){return j.has(D(e))?p("s3:HeadBucket returned NotFound"):g(e)}}async function Z(o,r,t){try{return await o.getClient(I).send(new F({Bucket:r}),{abortSignal:l(t)}),y("Bucket policy exists")}catch(e){return q.has(D(e))?p("s3:GetBucketPolicy reported no such bucket policy or bucket"):g(e)}}async function ee(o,r,t){try{const e=await o.getClient(A).send(new B({SecretId:r}),{abortSignal:l(t)});return e.DeletedDate!==void 0?p(`Secret is scheduled for deletion (DeletedDate: ${e.DeletedDate.toISOString()}) \u2014 restorable via secretsmanager:RestoreSecret until the recovery window ends`):y("Secret exists")}catch(e){return z.has(D(e))?p("secretsmanager:DescribeSecret returned ResourceNotFoundException"):g(e)}}const te="cloudformation:ListStackResources",x={"AWS::KMS::Key":{probe:J,method:"kms:DescribeKey"},"AWS::KMS::Alias":{probe:Q,method:"kms:DescribeKey"},"AWS::S3::Bucket":{probe:X,method:"s3:HeadBucket"},"AWS::S3::BucketPolicy":{probe:Z,method:"s3:GetBucketPolicy"},"AWS::SecretsManager::Secret":{probe:ee,method:"secretsmanager:DescribeSecret"}};async function me(o,r){const t=x[r.resourceType];if(t===void 0)return{verdict:"unconfirmed",detail:`no direct owning-service probe for ${r.resourceType} in this release`,probeMethod:"none"};if(k(r.abortSignal))return{verdict:"unconfirmed",detail:"Probe aborted",probeMethod:"none"};const e=await t.probe(o,r.physicalId,r.abortSignal);return e.verdict!=="present"&&e.verdict!=="confirmed-deleted"?{verdict:"unconfirmed",detail:e.detail,probeMethod:t.method}:{verdict:e.verdict,detail:e.detail,probeMethod:t.method}}function u(o,r,t,e){return{...o,verdict:r,verdictDetail:t,probeMethod:e,verbs:H(r)}}class Se{aws;pollIntervalMs;pollTimeoutMs;now;constructor(r,t){this.aws=r,this.pollIntervalMs=t?.pollIntervalMs??V,this.pollTimeoutMs=t?.pollTimeoutMs??Y,this.now=t?.now??(()=>Date.now())}async probeSuspects(r){const{stackName:t,suspects:e,abortSignal:n}=r;if(e.length===0)return w([]);if(k(n))return L(new Error(`Drift probe aborted before starting for ${t}`));const a=await this.checkStackExists(t,n);if(a==="missing")return w(e.map(i=>u(i,"stack-missing",`Stack ${t} no longer exists in CloudFormation`,"cloudformation:DescribeStacks")));if(a!=="exists")return w(e.map(i=>u(i,"unconfirmed",a.detail,"cloudformation:DescribeStacks")));const f=await this.listStackResources(t,n),d=[],S=[];for(const i of e){if(k(n)){d.push(u(i,"unconfirmed","Probe aborted","none"));continue}if(f!==null&&!f.has(i.logicalId)){d.push(u(i,"present",`No longer tracked by stack ${t} \u2014 cured by removal from the template`,te));continue}const h=x[i.resourceType],m=f?.get(i.logicalId),c=m!==void 0&&m!==""?m:i.physicalId;if(h===void 0||c===void 0||c===""){S.push(i);continue}const s=await h.probe(this.aws,c,n);d.push(u({...i,physicalId:c},s.verdict,s.detail,h.method))}return S.length>0&&d.push(...await this.probeViaDriftApi(t,S,n)),w(d)}async checkStackExists(r,t){try{return await this.aws.getClient(T).send(new R({StackName:r}),{abortSignal:l(t)}),"exists"}catch(e){return b(e).includes($)?"missing":{detail:`Stack existence check failed: ${E(b(e))}`}}}async listStackResources(r,t){const e=new Map,n=this.aws.getClient(T);try{let a;do{if(k(t))return null;const f=await n.send(new O({StackName:r,...a!==void 0?{NextToken:a}:{}}),{abortSignal:l(t)});for(const d of f.StackResourceSummaries??[])d.LogicalResourceId!==void 0&&e.set(d.LogicalResourceId,d.PhysicalResourceId??"");a=f.NextToken}while(a!==void 0)}catch(a){return U.debug(W,"Stack resource listing failed \u2014 falling back",{stackName:r,error:E(b(a))}),null}return e}async probeViaDriftApi(r,t,e){const n="cloudformation:DetectStackDrift",a=this.aws.getClient(T);try{const d=(await a.send(new K({StackName:r}),{abortSignal:l(e)})).StackDriftDetectionId;if(d===void 0)return t.map(c=>u(c,"unconfirmed","Drift detection returned no detection id",n));const S=this.now();let i;for(;!k(e)&&(i=(await a.send(new _({StackDriftDetectionId:d}),{abortSignal:l(e)})).DetectionStatus,i==="DETECTION_IN_PROGRESS");){if(this.now()-S>=this.pollTimeoutMs)return t.map(s=>u(s,"unconfirmed",`Drift detection timed out after ${this.pollTimeoutMs}ms`,n));await G(this.pollIntervalMs,e)}if(i!=="DETECTION_COMPLETE")return t.map(c=>u(c,"unconfirmed",`Drift detection did not complete (status: ${i??"aborted"})`,n));const h=new Map;let m;do{if(k(e))break;const c=await a.send(new P({StackName:r,...m!==void 0?{NextToken:m}:{}}),{abortSignal:l(e)});for(const s of c.StackResourceDrifts??[])s.LogicalResourceId!==void 0&&s.StackResourceDriftStatus!==void 0&&h.set(s.LogicalResourceId,s.StackResourceDriftStatus);m=c.NextToken}while(m!==void 0);return t.map(c=>{const s=h.get(c.logicalId);return s==="DELETED"?u(c,"confirmed-deleted","CloudFormation drift detection reported the resource DELETED",n):s==="IN_SYNC"||s==="MODIFIED"?u(c,"present",`CloudFormation drift detection reported ${s}`,n):u(c,"unconfirmed",s===void 0?"Resource not reported by drift detection (type may not support it)":`Drift detection reported ${s}`,n)})}catch(f){const d=`Drift-API fallback failed: ${E(b(f))}`;return t.map(S=>u(S,"unconfirmed",d,n))}}}export{Se as DriftProbe,te as MEMBERSHIP_GATE_PROBE_METHOD,me as probePhysicalPresence};
|