@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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `pin` arm — the non-destructive escape hatch (plan § 3): probe the LIVE
|
|
3
|
+
* value of each offending property from its owning service and emit an
|
|
4
|
+
* edit-ready codemod into the user's fjall config so the diff disappears.
|
|
5
|
+
* Degrades to report-only carrying the exact config line whenever any step
|
|
6
|
+
* cannot complete confidently — never a partial edit (the
|
|
7
|
+
* `restoreAndReconcileQuarantinedBucket` reconcile doctrine). The codemod is
|
|
8
|
+
* OFFERED to the caller (Q6): this module only computes `modifiedContent`;
|
|
9
|
+
* the surface that owns the config file decides to write it.
|
|
10
|
+
*
|
|
11
|
+
* v1 probe table: RDS DBInstance/DBCluster `Port`. Every other
|
|
12
|
+
* (resourceType, property) pair degrades honestly to `no-probe-support`
|
|
13
|
+
* naming the pair.
|
|
14
|
+
*
|
|
15
|
+
* Failure messages are pre-masked at construction.
|
|
16
|
+
*/
|
|
17
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
18
|
+
export interface PinTarget {
|
|
19
|
+
stack: string;
|
|
20
|
+
logicalId: string;
|
|
21
|
+
resourceType: string;
|
|
22
|
+
/** Ticket `physicalName` — a pinned literal, or the shared placeholder
|
|
23
|
+
* shape (`remediationPlaceholderPhysicalId`) for unpinned rows. */
|
|
24
|
+
physicalName: string;
|
|
25
|
+
/** Offending property paths from the ticket row (bare names or
|
|
26
|
+
* `/properties/`-prefixed pointers). */
|
|
27
|
+
offendingProperties: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
/** One probed live value plus the exact config line that pins it. */
|
|
30
|
+
export interface PinnedProperty {
|
|
31
|
+
/** CloudFormation property name (e.g. "Port"). */
|
|
32
|
+
property: string;
|
|
33
|
+
/** fjall config property the codemod writes (e.g. "port"). */
|
|
34
|
+
configProperty: string;
|
|
35
|
+
liveValue: string | number;
|
|
36
|
+
/** The exact config line for manual application (e.g. `port: 5433`). */
|
|
37
|
+
configLine: string;
|
|
38
|
+
}
|
|
39
|
+
export type PinReportOnlyReason = "no-probe-support" | "physical-id-unresolved" | "probe-failed" | "no-infrastructure-content" | "unresolved-construct" | "edit-failed"
|
|
40
|
+
/** The codemod was a byte-identical no-op — the config already carries the
|
|
41
|
+
* probed value(s), so claiming an edit would loop the deploy forever. */
|
|
42
|
+
| "already-pinned";
|
|
43
|
+
interface PinTargetIdentity {
|
|
44
|
+
stack: string;
|
|
45
|
+
logicalId: string;
|
|
46
|
+
resourceType: string;
|
|
47
|
+
physicalName: string;
|
|
48
|
+
}
|
|
49
|
+
export type PinResourceOutcome = (PinTargetIdentity & {
|
|
50
|
+
outcome: "edit-ready";
|
|
51
|
+
/** The construct symbol the codemod edited. */
|
|
52
|
+
constructName: string;
|
|
53
|
+
pinned: PinnedProperty[];
|
|
54
|
+
}) | (PinTargetIdentity & {
|
|
55
|
+
outcome: "report-only";
|
|
56
|
+
reason: PinReportOnlyReason;
|
|
57
|
+
/** Pre-masked human detail (unsupported pairs, probe error, edit error). */
|
|
58
|
+
detail?: string;
|
|
59
|
+
/** Probed values that WERE resolved — the exact config lines the
|
|
60
|
+
* operator can apply by hand even though no edit was emitted. */
|
|
61
|
+
pinned?: PinnedProperty[];
|
|
62
|
+
/** Candidate construct names on ambiguous resolution. */
|
|
63
|
+
candidates?: string[];
|
|
64
|
+
});
|
|
65
|
+
export interface PinRemediationReport {
|
|
66
|
+
outcomes: PinResourceOutcome[];
|
|
67
|
+
/** The config content with EVERY edit-ready outcome applied, present iff
|
|
68
|
+
* at least one target reached edit-ready. Later targets compose on top of
|
|
69
|
+
* earlier edits. */
|
|
70
|
+
modifiedContent?: string;
|
|
71
|
+
/** The exact source snapshot the edits were composed against — present iff
|
|
72
|
+
* `modifiedContent` is. The write-back owner MUST verify the file on disk
|
|
73
|
+
* still matches this snapshot before replacing it wholesale: the consent
|
|
74
|
+
* ceremony can sit for minutes, and a whole-file write composed from a
|
|
75
|
+
* stale read silently reverts a concurrent user edit (lost update). */
|
|
76
|
+
sourceContent?: string;
|
|
77
|
+
}
|
|
78
|
+
export interface PinProbeCoverage {
|
|
79
|
+
/** True iff EVERY offending property has a live-value probe. */
|
|
80
|
+
covered: boolean;
|
|
81
|
+
/** The `<resourceType>#<property>` pairs without a probe (all pairs when
|
|
82
|
+
* no offending properties were recorded — nothing is probeable). */
|
|
83
|
+
unsupportedPairs: string[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Whether the v1 probe table can execute a pin for this resource. The
|
|
87
|
+
* destruction gate consults this BEFORE accepting a `pin` consent — declaring
|
|
88
|
+
* the verb executable and then degrading every target to report-only
|
|
89
|
+
* (no-probe-support) would stop the deploy for a write-back that can never
|
|
90
|
+
* happen.
|
|
91
|
+
*/
|
|
92
|
+
export declare function pinProbeCoverage(resourceType: string, offendingProperties: readonly string[]): PinProbeCoverage;
|
|
93
|
+
/**
|
|
94
|
+
* One-line detail for a pin outcome (everything after the physical name).
|
|
95
|
+
* Shared by `renderPinReportLines` and the CLI's labelled row rendering so
|
|
96
|
+
* both surfaces stay byte-identical. Identifier-only plus pre-masked details
|
|
97
|
+
* — safe for any sink.
|
|
98
|
+
*/
|
|
99
|
+
export declare function formatPinOutcomeDetail(outcome: PinResourceOutcome): string;
|
|
100
|
+
/**
|
|
101
|
+
* Render the per-resource pin outcome lines every surface shares (engine
|
|
102
|
+
* callbacks, CLI human output, agent output).
|
|
103
|
+
*/
|
|
104
|
+
export declare function renderPinReportLines(report: PinRemediationReport): string[];
|
|
105
|
+
export interface RunPinRemediationParams {
|
|
106
|
+
targets: readonly PinTarget[];
|
|
107
|
+
/** The user's infrastructure config source. Absent (worker surfaces with
|
|
108
|
+
* no config checkout) degrades every target to report-only with the exact
|
|
109
|
+
* config lines. */
|
|
110
|
+
infrastructureContent?: string;
|
|
111
|
+
/** Display path for codemod diagnostics. */
|
|
112
|
+
infrastructureFilePath?: string;
|
|
113
|
+
abortSignal?: AbortSignal;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Run the pin arm over consented targets. Never throws; every failure mode
|
|
117
|
+
* is an in-band report-only outcome (pre-masked at construction). Outcomes
|
|
118
|
+
* may be fewer than targets when the abort signal fires mid-loop.
|
|
119
|
+
*/
|
|
120
|
+
export declare function runPinRemediation(aws: AwsProvider, params: RunPinRemediationParams): Promise<PinRemediationReport>;
|
|
121
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{CloudFormationClient as w,DescribeStackResourceCommand as B}from"@aws-sdk/client-cloudformation";import{DescribeDBClustersCommand as O,DescribeDBInstancesCommand as T,RDSClient as R}from"@aws-sdk/client-rds";import{success as h,failure as f}from"@fjall/generator";import{listResources as L,modifyResource as A}from"@fjall/generator/codemod";import{getErrorMessage as P,maskSensitiveOutput as D}from"@fjall/util";import{logger as j}from"@fjall/util/logger";import{composeSdkAbortSignal as C,isAborted as k}from"../../aws/organisations/types.js";import{remediationPlaceholderPhysicalId as x}from"../../types/remediation.js";const F="PinRemediation",N="/properties/";async function V(e,n,o){try{const i=(await e.getClient(R).send(new T({DBInstanceIdentifier:n}),{abortSignal:C(o)})).DBInstances?.[0]?.Endpoint?.Port;return i===void 0?f(new Error(`rds:DescribeDBInstances returned no endpoint port for '${n}'`)):h(i)}catch(t){return f(new Error(D(P(t))))}}async function W(e,n,o){try{const i=(await e.getClient(R).send(new O({DBClusterIdentifier:n}),{abortSignal:C(o)})).DBClusters?.[0]?.Port;return i===void 0?f(new Error(`rds:DescribeDBClusters returned no port for '${n}'`)):h(i)}catch(t){return f(new Error(D(P(t))))}}const v={"AWS::RDS::DBInstance#Port":{configProperty:"port",constructType:"database",method:"rds:DescribeDBInstances",probe:V},"AWS::RDS::DBCluster#Port":{configProperty:"port",constructType:"database",method:"rds:DescribeDBClusters",probe:W}};function E(e){return e.startsWith(N)?e.slice(N.length):e}function Z(e,n){if(n.length===0)return{covered:!1,unsupportedPairs:[`${e} (no offending properties recorded)`]};const o=n.map(E).filter(t=>v[`${e}#${t}`]===void 0).map(t=>`${e}#${t}`);return{covered:o.length===0,unsupportedPairs:o}}function _(e){return typeof e=="number"?String(e):JSON.stringify(e)}async function G(e,n,o){const t=x(n.stack,n.logicalId);if(n.physicalName!==t)return h(n.physicalName);try{const s=(await e.getClient(w).send(new B({StackName:n.stack,LogicalResourceId:n.logicalId}),{abortSignal:C(o)})).StackResourceDetail?.PhysicalResourceId;return s===void 0||s===""?f(new Error(`cfn:DescribeStackResource resolved no physical id for ${n.stack}/${n.logicalId}`)):h(s)}catch(r){return f(new Error(D(P(r))))}}function H(e,n,o){const t=L(e,n);if(!t.success)return{detail:`config parse failed: ${t.error.kind}`};const r=t.data.resources.filter(i=>i.type===o);return r.length===1&&r[0]!==void 0?{constructName:r[0].name}:r.length===0?{detail:`no ${o} construct found in the config`}:{candidates:r.map(i=>i.name),detail:`${r.length} ${o} constructs found \u2014 cannot pin unambiguously`}}function J(e){if(e.outcome==="edit-ready")return`pinned ${e.pinned.map(r=>r.configLine).join(", ")} on construct '${e.constructName}' \u2014 edit ready`;const n=e.detail!==void 0?` \u2014 ${e.detail}`:"",o=e.candidates!==void 0&&e.candidates.length>0?`; candidate constructs: ${e.candidates.join(", ")}`:"",t=e.reason!=="already-pinned"&&e.pinned!==void 0&&e.pinned.length>0?`; apply by hand: ${e.pinned.map(r=>r.configLine).join(", ")}`:"";return`report-only (${e.reason})${n}${o}${t}`}function ee(e){return e.outcomes.map(n=>`${n.physicalName}: ${J(n)}`)}async function ne(e,n){const o=n.infrastructureFilePath??"infrastructure.ts",t=[];let r=n.infrastructureContent,i=!1;for(const s of n.targets){if(k(n.abortSignal)){j.debug(F,"Aborted mid-loop \u2014 remaining targets not probed",{remaining:n.targets.length-t.length});break}const d={stack:s.stack,logicalId:s.logicalId,resourceType:s.resourceType,physicalName:s.physicalName},g=s.offendingProperties.map(E).map(c=>({property:c,spec:v[`${s.resourceType}#${c}`]})),b=g.filter(c=>c.spec!==void 0);if(g.length===0||b.length<g.length){const c=g.filter(u=>u.spec===void 0),p=g.length===0?`${s.resourceType} (no offending properties recorded)`:c.map(u=>`${s.resourceType}#${u.property}`).join(", ");t.push({...d,outcome:"report-only",reason:"no-probe-support",detail:`no live-value probe for ${p} in this release`});continue}const $=await G(e,s,n.abortSignal);if(!$.success){t.push({...d,outcome:"report-only",reason:"physical-id-unresolved",detail:$.error.message});continue}const a=[];let m;for(const{property:c,spec:p}of b){if(k(n.abortSignal)){m="aborted before probing completed";break}const u=await p.probe(e,$.data,n.abortSignal);if(!u.success){m=`${p.method}: ${u.error.message}`;break}a.push({property:c,configProperty:p.configProperty,liveValue:u.data,configLine:`${p.configProperty}: ${_(u.data)}`})}if(m!==void 0){t.push({...d,outcome:"report-only",reason:"probe-failed",detail:m,...a.length>0?{pinned:a}:{}});continue}if(r===void 0){t.push({...d,outcome:"report-only",reason:"no-infrastructure-content",detail:"no infrastructure config available on this surface \u2014 apply the config line(s) by hand",pinned:a});continue}const S=b[0]?.spec.constructType??"database",l=H(r,o,S);if(l.constructName===void 0){t.push({...d,outcome:"report-only",reason:"unresolved-construct",...l.detail!==void 0?{detail:l.detail}:{},...l.candidates!==void 0?{candidates:l.candidates}:{},pinned:a});continue}const I={};for(const c of a)I[c.configProperty]=c.liveValue;const y=A(r,{type:S,name:l.constructName,properties:I,filePath:o});if(!y.success){t.push({...d,outcome:"report-only",reason:"edit-failed",detail:`codemod failed: ${y.error.kind}`,pinned:a});continue}if(y.data.content===r){t.push({...d,outcome:"report-only",reason:"already-pinned",detail:"the config already contains the probed value(s) \u2014 no edit needed; if the deploy still plans a replacement, the drift lies outside these properties",pinned:a});continue}r=y.data.content,i=!0,t.push({...d,outcome:"edit-ready",constructName:l.constructName,pinned:a})}return{outcomes:t,...i&&r!==void 0&&n.infrastructureContent!==void 0?{modifiedContent:r,sourceContent:n.infrastructureContent}:{}}}export{J as formatPinOutcomeDetail,Z as pinProbeCoverage,ee as renderPinReportLines,ne as runPinRemediation};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-session post-failure repair offer (drift-remediation plan § 8 Phase 6):
|
|
3
|
+
* when a stack deploy step fails, the engine re-probes that stack in the SAME
|
|
4
|
+
* session through the bounded [G2] pipeline (the failure analysis has just
|
|
5
|
+
* journalled any NotFound-classified suspects). A CONFIRMED out-of-band
|
|
6
|
+
* deletion upgrades the returned error to the typed
|
|
7
|
+
* `DriftRepairAvailableError` — the interactive surface defers on the class
|
|
8
|
+
* (the `BuildxUnavailableInteractive` shape) while the non-interactive
|
|
9
|
+
* surface reads the same offer from the message text.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort by contract: any probe failure, abort, or empty verdict returns
|
|
12
|
+
* the ORIGINAL error untouched — the offer must never mask the deploy's true
|
|
13
|
+
* root cause.
|
|
14
|
+
*
|
|
15
|
+
* Messages are pre-masked at construction: the original analysed error is
|
|
16
|
+
* masked at the monitor boundary, and the appended offer lines are
|
|
17
|
+
* identifier-only (stack, logical ID, resource type, physical ID). The
|
|
18
|
+
* command teaches the flag SHAPE with a placeholder name, never a filled
|
|
19
|
+
* consent [G5].
|
|
20
|
+
*/
|
|
21
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
22
|
+
import type { CloudFormationService } from "../../services/infrastructure/CloudFormationService.js";
|
|
23
|
+
import type { DriftFinding } from "../../types/drift.js";
|
|
24
|
+
import type { DriftJournalOptions } from "../drift/driftSuspectJournal.js";
|
|
25
|
+
import { type RemediationJournalOptions } from "./remediationJournal.js";
|
|
26
|
+
export declare class DriftRepairAvailableError extends Error {
|
|
27
|
+
readonly findings: readonly DriftFinding[];
|
|
28
|
+
constructor(message: string, findings: readonly DriftFinding[]);
|
|
29
|
+
}
|
|
30
|
+
/** Identifier-only offer block appended beneath the analysed deploy error. */
|
|
31
|
+
export declare function formatDriftRepairOffer(findings: readonly DriftFinding[], appName?: string, repairTarget?: string): string;
|
|
32
|
+
/** One confirmed finding covered by an in-flight recreate journal [G1]. */
|
|
33
|
+
export interface RecreateCoveredFinding {
|
|
34
|
+
finding: DriftFinding;
|
|
35
|
+
opId: string;
|
|
36
|
+
appName?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* [G1] Identifier-only notice for confirmed deletions COVERED by an in-flight
|
|
40
|
+
* recreate operation. The `=forget` offer must never be taught for these —
|
|
41
|
+
* the forget arm's verb guard fails closed on the recreate journal, whose
|
|
42
|
+
* teach names the deploy, whose failure re-offers `=forget`: a circular teach
|
|
43
|
+
* loop. The two-cure block is the only terminating exit.
|
|
44
|
+
*/
|
|
45
|
+
export declare function formatRecreateCoveredNotice(covered: readonly RecreateCoveredFinding[], fallbackAppName?: string): string;
|
|
46
|
+
export interface ComposePostFailureRepairOfferParams {
|
|
47
|
+
/** The stack(s) whose deploy step just failed. */
|
|
48
|
+
stackNames: readonly string[];
|
|
49
|
+
/** Names the repair command's target in the offer. */
|
|
50
|
+
appName?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Overrides the repair command's target fragment in the offer (e.g.
|
|
53
|
+
* "--stack Account" for a single-component deploy, whose stack is not
|
|
54
|
+
* addressable by app name). `appName` still names the recreate cures —
|
|
55
|
+
* those teach 'fjall deploy', where a flag fragment would be wrong.
|
|
56
|
+
*/
|
|
57
|
+
repairTarget?: string;
|
|
58
|
+
originalError: Error;
|
|
59
|
+
cfnService: Pick<CloudFormationService, "getStackStatus">;
|
|
60
|
+
journal?: DriftJournalOptions;
|
|
61
|
+
/** Remediation-journal root override — tests point this at a temp dir. */
|
|
62
|
+
remediationJournal?: RemediationJournalOptions;
|
|
63
|
+
abortSignal?: AbortSignal;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Upgrade a failed deploy's error with the in-session repair offer when the
|
|
67
|
+
* post-failure probe CONFIRMS an out-of-band deletion on the failed stack.
|
|
68
|
+
* Returns the original error on every other outcome.
|
|
69
|
+
*
|
|
70
|
+
* [G1] Findings covered by an in-flight RECREATE journal never receive the
|
|
71
|
+
* `=forget` offer (circular teach — see `formatRecreateCoveredNotice`): a
|
|
72
|
+
* covered-only verdict returns a PLAIN Error carrying the two-cure notice
|
|
73
|
+
* (never `DriftRepairAvailableError`, whose interactive drift-repair offer
|
|
74
|
+
* would walk the same loop), and a mixed verdict offers `=forget` for the
|
|
75
|
+
* uncovered findings only, with the covered notice appended.
|
|
76
|
+
*/
|
|
77
|
+
export declare function composePostFailureRepairOffer(aws: AwsProvider, params: ComposePostFailureRepairOfferParams): Promise<Error>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import{getErrorMessage as m,maskSensitiveOutput as g}from"@fjall/util";import{logger as h}from"@fjall/util/logger";import{DriftProbe as $}from"../drift/driftProbe.js";import{runDriftPreFlight as N}from"../drift/driftPreFlight.js";import{formatRecreateInFlightCures as v}from"./forgetResource.js";import{listActiveRemediationOps as y}from"./remediationJournal.js";const I="PostFailureRepairOffer";class b extends Error{findings;constructor(e,r){super(e),this.name="DriftRepairAvailableError",this.findings=r}}function k(o,e,r){const i=o.map(n=>` ${n.stackName} \u203A ${n.logicalId} (${n.resourceType})${n.physicalId!==void 0?` [${n.physicalId}]`:""} \u2014 ${n.verdictDetail}`),t=r!==void 0&&r!==""?r:e!==void 0&&e!==""?e:"<target>";return`Out-of-band deletion CONFIRMED \u2014 CloudFormation still tracks ${o.length} deleted resource(s):
|
|
2
|
+
${i.join(`
|
|
3
|
+
`)}
|
|
4
|
+
Repair in this session: fjall drift repair ${t} --remediate <physicalName>=forget
|
|
5
|
+
(names are exact and case-sensitive; 'fjall drift detect' shows the full report)`}function u(o,e){const r=o.map(({finding:t,opId:n})=>` ${t.stackName} \u203A ${t.logicalId} (${t.resourceType})${t.physicalId!==void 0?` [${t.physicalId}]`:""} \u2014 covered by in-flight recreate remediation ${n}`),i=o.find(t=>t.appName!==void 0)?.appName??e;return`${o.length} deleted resource(s) are already covered by an in-flight recreate remediation:
|
|
6
|
+
${r.join(`
|
|
7
|
+
`)}
|
|
8
|
+
Do not use '--remediate <physicalName>=forget' for these \u2014 the forget arm fails closed on the in-flight recreate journal. ${v(i)}`}async function F(o,e){try{const r=await N({aws:o,probe:new $(o),...e.journal!==void 0?{journal:e.journal}:{}},{stackNames:e.stackNames,cfnService:e.cfnService,...e.abortSignal!==void 0?{abortSignal:e.abortSignal}:{}});if(!r.success||r.data.confirmed.length===0)return e.originalError;const{covered:i,uncovered:t}=await R(o,r.data.confirmed,e.remediationJournal);if(t.length===0)return new Error(`${e.originalError.message}
|
|
9
|
+
|
|
10
|
+
${u(i,e.appName)}`);const n=i.length>0?`
|
|
11
|
+
|
|
12
|
+
${u(i,e.appName)}`:"";return new b(`${e.originalError.message}
|
|
13
|
+
|
|
14
|
+
${k(t,e.appName,e.repairTarget)}${n}`,t)}catch(r){return h.debug(I,"Post-failure repair probe threw \u2014 offer skipped",{stackNames:[...e.stackNames],error:g(m(r))}),e.originalError}}async function R(o,e,r){const i=o.getAccountId(),t=o.getRegion(),n=new Map,l=[],d=[];for(const a of e){let c=n.get(a.stackName);c===void 0&&(c=await y(i,t,a.stackName,r),n.set(a.stackName,c));const s=c.find(f=>f.verb==="recreate"&&f.targets.some(p=>p.logicalId===a.logicalId));s!==void 0?l.push({finding:a,opId:s.opId,...s.appName!==void 0?{appName:s.appName}:{}}):d.push(a)}return{covered:l,uncovered:d}}export{b as DriftRepairAvailableError,F as composePostFailureRepairOffer,k as formatDriftRepairOffer,u as formatRecreateCoveredNotice};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recreate pre-flight — ONE pure-read pass over EVERY consented stack
|
|
3
|
+
* BEFORE any recreate surgery mutates anything (drift-remediation plan §
|
|
4
|
+
* Phase 7 fix pass 1). The surgery's removal phase can only fail EXPENSIVELY
|
|
5
|
+
* once the snapshot flip has landed (a burned UpdateStack, an orphaned
|
|
6
|
+
* mid-surgery journal), so every check that can be answered from reads runs
|
|
7
|
+
* here, pre-capture, across the whole consented set:
|
|
8
|
+
*
|
|
9
|
+
* 1. reference scan — `deriveRemovalTemplate` over the live template bytes:
|
|
10
|
+
* a remaining resource referencing a target fails closed with the paths;
|
|
11
|
+
* 2. export-consumer scan — every Output the removal would strip that
|
|
12
|
+
* carries an `Export` is checked via cfn:ListImports: CloudFormation
|
|
13
|
+
* refuses to drop an export while imports exist, so an imported export
|
|
14
|
+
* fails closed naming the importing stacks;
|
|
15
|
+
* 3. delete-blocking probe — targets whose owning service exposes a
|
|
16
|
+
* delete-refusal setting (RDS DeletionProtection — fjall's own database
|
|
17
|
+
* constructs default it ON) are probed live: an enabled setting fails
|
|
18
|
+
* closed pre-capture, because CloudFormation's delete would DELETE_FAILED
|
|
19
|
+
* mid-surgery after the flip already mutated the stack.
|
|
20
|
+
*
|
|
21
|
+
* All-stacks-before-any-flip: a multi-stack consent must never delete stack
|
|
22
|
+
* A's resource and then discover stack B was doomed — the caller passes every
|
|
23
|
+
* consented stack and nothing runs until all pass.
|
|
24
|
+
*
|
|
25
|
+
* Failure messages are pre-masked at construction (identifier-only content
|
|
26
|
+
* plus masked SDK error text).
|
|
27
|
+
*/
|
|
28
|
+
import { type Result } from "@fjall/generator";
|
|
29
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
30
|
+
import { type RemediationTarget } from "../../types/remediation.js";
|
|
31
|
+
export interface RecreatePreFlightStack {
|
|
32
|
+
stackName: string;
|
|
33
|
+
targets: readonly RemediationTarget[];
|
|
34
|
+
}
|
|
35
|
+
export interface RunRecreatePreFlightParams {
|
|
36
|
+
/** EVERY consented stack — the pass must clear all of them before the
|
|
37
|
+
* caller flips any. */
|
|
38
|
+
stacks: readonly RecreatePreFlightStack[];
|
|
39
|
+
abortSignal?: AbortSignal;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Run the merged pre-flight over every consented stack. Pure reads only —
|
|
43
|
+
* the first failure aborts the whole consented set before any surgery runs.
|
|
44
|
+
* Safe on resume paths too: targets already absent from the live template
|
|
45
|
+
* (removal landed) skip their checks.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runRecreatePreFlight(aws: AwsProvider, params: RunRecreatePreFlightParams): Promise<Result<void, Error>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{CloudFormationClient as y,ListImportsCommand as D}from"@aws-sdk/client-cloudformation";import{DescribeDBClustersCommand as $,DescribeDBInstancesCommand as E,RDSClient as h}from"@aws-sdk/client-rds";import{success as l,failure as i}from"@fjall/generator";import{getErrorMessage as m,maskSensitiveOutput as p}from"@fjall/util";import{logger as I}from"@fjall/util/logger";import{composeSdkAbortSignal as f,isAborted as g}from"../../aws/organisations/types.js";import{isRemediationPlaceholderPhysicalId as N}from"../../types/remediation.js";import{fetchLiveStackState as C}from"./forgetResource.js";import{deriveRemovalTemplate as S}from"./removalUpdate.js";const k="RecreatePreFlight",R=/is not imported by any stack/i;function b(t){const o=typeof t=="object"&&t!==null&&"name"in t?String(t.name):"";return/NotFound/i.test(o)}async function x(t,o,r){try{const n=await t.getClient(h).send(new E({DBInstanceIdentifier:o}),{abortSignal:f(r)});return l({blocked:n.DBInstances?.[0]?.DeletionProtection===!0,setting:"DeletionProtection"})}catch(n){return b(n)?l({blocked:!1,setting:"DeletionProtection"}):i(new Error(p(m(n))))}}async function P(t,o,r){try{const n=await t.getClient(h).send(new $({DBClusterIdentifier:o}),{abortSignal:f(r)});return l({blocked:n.DBClusters?.[0]?.DeletionProtection===!0,setting:"DeletionProtection"})}catch(n){return b(n)?l({blocked:!1,setting:"DeletionProtection"}):i(new Error(p(m(n))))}}const v={"AWS::RDS::DBInstance":{probe:x,method:"rds:DescribeDBInstances",disableCommand:t=>`aws rds modify-db-instance --db-instance-identifier ${t} --no-deletion-protection`},"AWS::RDS::DBCluster":{probe:P,method:"rds:DescribeDBClusters",disableCommand:t=>`aws rds modify-db-cluster --db-cluster-identifier ${t} --no-deletion-protection`}};async function B(t,o,r){const n=t.getClient(y),d=[];let c;try{do{if(g(r))return i(new Error(`aborted while listing imports of export '${o}'`));const s=await n.send(new D({ExportName:o,...c!==void 0?{NextToken:c}:{}}),{abortSignal:f(r)});d.push(...s.Imports??[]),c=s.NextToken}while(c!==void 0);return l(d)}catch(s){return R.test(m(s))?l([]):i(new Error(p(m(s))))}}async function G(t,o){for(const{stackName:r,targets:n}of o.stacks){if(g(o.abortSignal))return i(new Error(`Recreate pre-flight aborted before ${r} was checked \u2014 nothing was mutated in this run.`));const d=n.map(e=>e.logicalId),c=await C(t,r,o.abortSignal);if(!c.success)return i(new Error(`${c.error.message} Nothing was mutated in this run.`));const s=S(c.data.templateBody,d,r);if(!s.success)return i(new Error(`${s.error.message} Nothing was mutated in this run.`));const w=new Set(s.data.alreadyAbsent);for(const e of s.data.strippedExports){if(e.exportName===void 0)return i(new Error(`Recreate pre-flight for ${r}: removing ${d.join(", ")} strips output '${e.outputName}' whose Export name is not a literal string, so its cross-stack consumers cannot be verified \u2014 perform the replacement manually. Nothing was mutated in this run.`));const a=await B(t,e.exportName,o.abortSignal);if(!a.success)return i(new Error(`Recreate pre-flight for ${r}: could not verify whether export '${e.exportName}' (output '${e.outputName}') is imported by other stacks: ${a.error.message}. Retry the deploy. Nothing was mutated in this run.`));if(a.data.length>0)return i(new Error(`Recreate pre-flight for ${r}: removing ${d.join(", ")} strips export '${e.exportName}' (output '${e.outputName}'), which is imported by ${a.data.join(", ")} \u2014 CloudFormation refuses to drop an export while imports exist, so the surgery would fail mid-deploy. Redeploy the importing stack(s) without the import first, or perform the replacement manually. Nothing was mutated in this run.`))}for(const e of n){if(w.has(e.logicalId))continue;const a=v[e.resourceType];if(a===void 0)continue;if(N(e,r)){I.debug(k,"No live physical id to probe \u2014 skipping",{stackName:r,logicalId:e.logicalId});continue}const u=await a.probe(t,e.physicalId,o.abortSignal);if(!u.success)return i(new Error(`Recreate pre-flight for ${r}: could not verify that ${e.physicalId} (${e.logicalId}) is deletable \u2014 ${a.method} failed: ${u.error.message}. Retry the deploy. Nothing was mutated in this run.`));if(u.data.blocked)return i(new Error(`Recreate pre-flight for ${r}: ${e.physicalId} (${e.logicalId}) has ${u.data.setting} enabled \u2014 CloudFormation cannot delete it, so the pinned-name replacement would fail mid-surgery. Disable it on the live resource (e.g. '${a.disableCommand(e.physicalId)}'), then re-run the deploy. Nothing was mutated in this run.`))}}return l(void 0)}export{G as runRecreatePreFlight};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `recreate` orchestrator — the snapshot-flip variant of the forget
|
|
3
|
+
* surgery (drift-remediation plan § Phase 7), for the pinned-name-replacement
|
|
4
|
+
* class: CloudFormation refuses to replace a custom-named resource in place
|
|
5
|
+
* ("cannot update a stack when a custom-named resource requires replacing"),
|
|
6
|
+
* so fjall performs the replacement surgically inside the SAME deploy run:
|
|
7
|
+
*
|
|
8
|
+
* captured → flip-verified (snapshot flip) → flip-applied →
|
|
9
|
+
* removal-applied (surgical UpdateStack deletes the physical resource,
|
|
10
|
+
* snapshotting per policy) → (the deploy continues and re-CREATEs the
|
|
11
|
+
* resource under its pinned name) → done (post-deploy sweep)
|
|
12
|
+
*
|
|
13
|
+
* Unlike forget, the removal phase IS here: the synthesised template still
|
|
14
|
+
* declares the resource, so the converge cannot remove it — a surgical
|
|
15
|
+
* live-template-minus-targets update (`removalUpdate.ts`) deletes it first,
|
|
16
|
+
* freeing the pinned name. Closure then flows through the SAME machinery as
|
|
17
|
+
* forget (`completeForgetAfterConverge` → `resolveConvergedTargets`, marker
|
|
18
|
+
* key derived from the journal's verb): a re-created target is
|
|
19
|
+
* present-unmarked and physically probed live. Absent targets at sweep
|
|
20
|
+
* closure were removed instead of re-created (the config no longer declares
|
|
21
|
+
* them) — correct to close, the deletion was consented.
|
|
22
|
+
*
|
|
23
|
+
* Consent is the CALLER's job (the destruction ceremony runs before 3f
|
|
24
|
+
* invokes this). Resumability rides the deploy path, not `drift resume`: at
|
|
25
|
+
* any phase before removal-applied the pinned-name finding still fires, the
|
|
26
|
+
* ceremony re-consents, and this surgery resumes idempotently; after
|
|
27
|
+
* removal-applied the plan shows a plain CREATE (no ticket) and the sweep
|
|
28
|
+
* closes the journal. `drift resume`/`drift repair` fail closed on recreate
|
|
29
|
+
* journals (verb guard in `forgetResource.ts`) teaching the deploy re-run.
|
|
30
|
+
*
|
|
31
|
+
* Cross-incarnation edge: a NEW pinned-name finding on the same stack +
|
|
32
|
+
* logical-ID set reuses the opId (derivation excludes the verb and has no
|
|
33
|
+
* incarnation component). A resume that finds the prior incarnation at
|
|
34
|
+
* removal-applied first attempts closure; a closed journal then archives and
|
|
35
|
+
* restarts fresh under `doneJournalPolicy: "archive-and-restart"` (the 3f
|
|
36
|
+
* caller's policy), so the new consent gets a new surgery.
|
|
37
|
+
*
|
|
38
|
+
* The `fjall:recreate` marker key is deliberately distinct from
|
|
39
|
+
* `fjall:forget`: the two surgeries' resume/ownership scans never adopt each
|
|
40
|
+
* other's markers. Overlapping forget + recreate operations on one resource
|
|
41
|
+
* are an operator error, guarded at the opId collision point by the journal
|
|
42
|
+
* verb checks.
|
|
43
|
+
*
|
|
44
|
+
* Failure messages are pre-masked at construction.
|
|
45
|
+
*/
|
|
46
|
+
import { type Result } from "@fjall/generator";
|
|
47
|
+
import type { AwsProvider } from "../../aws/AwsProvider.js";
|
|
48
|
+
import type { DriftFinding } from "../../types/drift.js";
|
|
49
|
+
import { type RemediationJournalRecord, type RemediationPhase, type RemediationTarget } from "../../types/remediation.js";
|
|
50
|
+
import { type ForgetOrchestrationOptions } from "./forgetResource.js";
|
|
51
|
+
import { type FlipDeletionPolicy } from "./retainFlip.js";
|
|
52
|
+
/** Options shape shared with the forget orchestrator (coupled machinery). */
|
|
53
|
+
export type RecreateOrchestrationOptions = ForgetOrchestrationOptions;
|
|
54
|
+
/**
|
|
55
|
+
* CFN resource types that support `DeletionPolicy: Snapshot`
|
|
56
|
+
* (CloudFormation UserGuide § aws-attribute-deletionpolicy). Everything else
|
|
57
|
+
* flips to `Delete` — the data loss was consented at the ceremony; the
|
|
58
|
+
* snapshot is best-effort mitigation where the platform offers it.
|
|
59
|
+
*/
|
|
60
|
+
export declare const SNAPSHOT_CAPABLE_RESOURCE_TYPES: ReadonlySet<string>;
|
|
61
|
+
export declare function snapshotPolicyForType(resourceType: string): Extract<FlipDeletionPolicy, "Snapshot" | "Delete">;
|
|
62
|
+
export interface RecreateSurgeryParams {
|
|
63
|
+
stackName: string;
|
|
64
|
+
targets: readonly RemediationTarget[];
|
|
65
|
+
/** The deploy target that owns the stack — names the resume command. */
|
|
66
|
+
appName?: string;
|
|
67
|
+
/** Evidence persisted in the forensics bundle. */
|
|
68
|
+
findings?: readonly DriftFinding[];
|
|
69
|
+
/** Audit trail: the exact physical names consented at the ceremony. */
|
|
70
|
+
consentedPhysicalNames?: readonly string[];
|
|
71
|
+
/** Mirror of the forget arm's policy — the 3f deploy caller passes
|
|
72
|
+
* "archive-and-restart" so a done journal from a prior incarnation of the
|
|
73
|
+
* same stack + logical-ID set never false-reports completion. */
|
|
74
|
+
doneJournalPolicy?: "report-completed" | "archive-and-restart";
|
|
75
|
+
/**
|
|
76
|
+
* [G7] Logical IDs the CURRENT deploy plan replaces under a pinned name.
|
|
77
|
+
* A removal-applied resume whose closure could NOT verify a target
|
|
78
|
+
* (converge-pending) must FAIL CLOSED when that target overlaps this set —
|
|
79
|
+
* a converge-pending success would fall through to the native pinned-name
|
|
80
|
+
* replacement CloudFormation refuses, burning the run. The 3f caller passes
|
|
81
|
+
* its consented rows' logical IDs (by construction the plan replaces every
|
|
82
|
+
* one of them); callers with no planned replacement may omit.
|
|
83
|
+
*/
|
|
84
|
+
plannedReplacementLogicalIds?: readonly string[];
|
|
85
|
+
onProgress?: (message: string) => void;
|
|
86
|
+
abortSignal?: AbortSignal;
|
|
87
|
+
}
|
|
88
|
+
export interface RecreateSurgeryReport {
|
|
89
|
+
opId: string;
|
|
90
|
+
/** Journal phase after this pass. */
|
|
91
|
+
phase: RemediationPhase;
|
|
92
|
+
/** True until the deploy re-creates the targets and the post-deploy sweep
|
|
93
|
+
* closes the journal — the caller MUST continue the deploy on true. */
|
|
94
|
+
convergePending: boolean;
|
|
95
|
+
forensicsPath?: string;
|
|
96
|
+
/** The DeletionPolicy stamped per target logical ID. */
|
|
97
|
+
snapshotPolicies: Record<string, "Snapshot" | "Delete">;
|
|
98
|
+
journal: RemediationJournalRecord;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The orchestrated recreate: forensics capture (abort on failure — no
|
|
102
|
+
* capture, no mutate), snapshot-flip derivation + both proofs, flip apply,
|
|
103
|
+
* surgical removal (proof + apply + GetTemplate absence verification), then
|
|
104
|
+
* removal-applied. The report is convergePending — the SAME deploy run
|
|
105
|
+
* continues past the surgery and re-creates the resource under its pinned
|
|
106
|
+
* name; the post-deploy sweep closes the journal. Idempotent and resumable:
|
|
107
|
+
* safe to re-run at any phase (resumption rides the deploy path — the
|
|
108
|
+
* pinned-name finding re-fires and re-consents until the removal lands).
|
|
109
|
+
*/
|
|
110
|
+
export declare function runRecreateSurgery(aws: AwsProvider, params: RecreateSurgeryParams, options?: RecreateOrchestrationOptions): Promise<Result<RecreateSurgeryReport, Error>>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{success as j,failure as w}from"@fjall/generator";import{completeForgetAfterConverge as z,fetchLiveStackState as B}from"./forgetResource.js";import{archiveCompletedRemediationJournal as Q,computeRemediationOpId as X,formatArchivedOperationNotice as Z,formatCompletedOperationNotice as ee,readRemediationJournal as te,writeRemediationJournal as ae}from"./remediationJournal.js";import{captureRemediationForensics as re}from"./forensicsCapture.js";import{applyRetainFlip as x,computeTemplateDigest as H,deriveRetainFlipTemplate as ne,findMarkedResourcesByOpId as oe,proveFlipMetadataOnly as ie,readRetainFlipState as I,verifyRetainFlipDiff as se,RECREATE_MARKER_KEY as A}from"./retainFlip.js";import{deriveRemovalTemplate as le,findRemovalDeletionFailures as ce,proveRemovalTargetsOnly as de}from"./removalUpdate.js";const pe=new Set(["AWS::DocDB::DBCluster","AWS::EC2::Volume","AWS::ElastiCache::CacheCluster","AWS::ElastiCache::ReplicationGroup","AWS::Neptune::DBCluster","AWS::RDS::DBCluster","AWS::RDS::DBInstance","AWS::Redshift::Cluster"]);function ue(i){return pe.has(i)?"Snapshot":"Delete"}const fe=30*6e4;function me(i){const o={};for(const s of i)o[s.logicalId]=ue(s.resourceType);return o}function U(i,o,s,e){const R=e.filter(n=>n!==s);if(R.length===0)return j(void 0);const p=R.map(n=>{const f=oe(i,n,o,A);return`${n} covers ${f.success&&f.data.length>0?f.data.join(", "):"(could not recover its target set)"}`});return w(new Error(`Recreate operation ${s} on ${o} found target resources already carrying a ${A} marker from a different operation \u2014 its journal may live on another machine, and completing this operation over it would strand that surgery. Marked resources recovered from the live template: ${p.join("; ")}. Complete the foreign operation first (re-run its converge deploy on the machine that holds its journal), then re-run this deploy. Nothing was mutated.`))}async function we(i,o,s){const{stackName:e,targets:R,onProgress:p,abortSignal:n}=o;if(R.length===0)return w(new Error("Recreate surgery requires at least one target"));const f=s?.now??(()=>new Date),O=i.getAccountId(),N=i.getRegion(),u=R.map(a=>a.logicalId),l=X(e,u),P=me(R),b={markerKey:A,policyForTarget:a=>P[a]??"Delete"},E=o.appName!==void 0?`Re-run 'fjall deploy ${o.appName}' to resume the recreate.`:"Re-run the deploy to resume the recreate.",F=a=>a.snapshotPolicies??P;let t=await te(O,N,e,l,s?.journal);if(t!==null&&t.verb!=="recreate"&&t.phase!=="done")return w(new Error(`Recreate operation ${l} on ${e} collides with an in-flight '${t.verb}' operation covering the same resources \u2014 complete it first with 'fjall drift resume ${l}', then re-run this deploy. Nothing was mutated.`));if(t!==null&&t.phase==="removal-applied"){const a=await z(i,{journal:t,...n!==void 0?{abortSignal:n}:{}},s);if(!a.success)return a;if(t=a.data.journal,a.data.status==="converge-pending"){const d=a.data.unverified,y=(o.plannedReplacementLogicalIds??[]).filter(c=>d.includes(c));if(y.length>0){const c=o.appName!==void 0?`fjall deploy ${o.appName}`:"fjall deploy";return w(new Error(`Recreate operation ${l} on ${e} already applied its removal, but the closure check could not verify ${y.join(", ")} (re-created, removed, or still orphaned) \u2014 and this deploy plans a pinned-name replacement for the same target(s), which CloudFormation would refuse while the name may still be held. Nothing further was mutated. Retry the deploy once AWS settles \u2014 a re-assert or converge still in flight clears on its own. If it persists, verify the resource state in the owning service; the terminating cure is to remove the resource(s) from the fjall config and re-run '${c}' \u2014 the converge clears the unverifiable declaration and the operation closes as removed \u2014 then re-add the configuration to re-create them under their pinned names.`))}return p?.(`Removal already applied on ${e} \u2014 the deploy re-creates any still-missing ${u.join(", ")} under the pinned names; already-present targets close once the post-deploy sweep verifies them`),j({opId:l,phase:t.phase,convergePending:!0,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t})}}if(t!==null&&t.phase==="done"){if(o.doneJournalPolicy!=="archive-and-restart")return p?.(ee(t)),j({opId:l,phase:"done",convergePending:!1,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t});const a=await Q(t,s?.journal);if(!a.success)return a;p?.(Z(t)),t=null}const C=async a=>{const d=await ae(a,s?.journal);return d.success?j(a):w(d.error)};let m;if(t===null){const a=await B(i,e,n);if(!a.success)return a;const d=I(a.data.templateBody,u,e,b);if(!d.success)return d;const y=U(a.data.templateBody,e,l,d.data.markerOpIds);if(!y.success)return y;if(d.data.markerOpIds.includes(l)){p?.(`Recognised in-flight recreate markers on ${e} \u2014 resuming from the ${A} marker`);const c=f().toISOString(),$=await C({version:1,opId:l,verb:"recreate",accountId:O,region:N,stackName:e,...o.appName!==void 0?{appName:o.appName}:{},targets:[...R],phase:d.data.allFlipped?"flip-applied":"captured",createdAt:c,updatedAt:c,snapshotPolicies:P});if(!$.success)return $;t=$.data,m=a.data}else{p?.(`Capturing forensics for ${e} (${l})`);const c=await re(i,{stackName:e,opId:l,findings:o.findings??[],...n!==void 0?{abortSignal:n}:{}},s?.forensics);if(!c.success)return c;const $=f().toISOString(),D=await C({version:1,opId:l,verb:"recreate",accountId:O,region:N,stackName:e,...o.appName!==void 0?{appName:o.appName}:{},targets:[...R],phase:"captured",createdAt:$,updatedAt:$,forensicsPath:c.data.forensicsPath,snapshotPolicies:P,...o.consentedPhysicalNames!==void 0?{consentedPhysicalNames:[...o.consentedPhysicalNames]}:{}});if(!D.success)return D;t=D.data,m={templateBody:c.data.liveTemplateBody,capabilities:c.data.capabilities,parameterKeys:c.data.parameterKeys}}}if(m===void 0){const a=await B(i,e,n);if(!a.success)return a;m=a.data}let h=m.templateBody,v=I(h,u,e,b);if(!v.success)return v;const W=U(h,e,l,v.data.markerOpIds);if(!W.success)return W;const q=new Set(v.data.absentTargets),g=u.filter(a=>!q.has(a));if(g.length>0){if(v.data.unflippedTargets.filter(r=>g.includes(r)).length>0){p?.(`Deriving the snapshot flip for ${e}`);const r=ne({liveTemplateBody:h,stackName:e,targets:g,opId:l,at:f().toISOString(),spec:b});if(!r.success)return r;const T=se(h,r.data.flipTemplateBody,g,e,A);if(!T.success)return T;p?.("Proving the flip is metadata-only (review change set)");const S=await ie(i,{stackName:e,flipTemplateBody:r.data.flipTemplateBody,targets:g,parameterKeys:m.parameterKeys,...n!==void 0?{abortSignal:n}:{}},s?.flip);if(!S.success)return S;const K=await C({...t,phase:"flip-verified",updatedAt:f().toISOString(),liveTemplateDigest:r.data.liveTemplateDigest,proof:{diffGuardPassed:!0,changeSetMetadataOnly:!0,noChanges:S.data.noChanges,provenAt:f().toISOString()}});if(!K.success)return K;t=K.data,p?.(`Applying the snapshot flip to ${e}`);const M=await x(i,{stackName:e,flipTemplateBody:r.data.flipTemplateBody,capabilities:m.capabilities,parameterKeys:m.parameterKeys,derivedFromTemplateDigest:r.data.liveTemplateDigest,opId:l,operationLabel:"Snapshot-flip",resumeHint:E,...n!==void 0?{abortSignal:n}:{}},s?.flip);if(!M.success)return M;const _=await B(i,e,n);if(!_.success)return _;if(h=_.data.templateBody,v=I(h,u,e,b),!v.success)return v;const V=new Set(v.data.absentTargets),J=v.data.unflippedTargets.filter(Y=>!V.has(Y));if(J.length>0)return w(new Error(`Snapshot flip on ${e} reported ${M.data.outcome} but GetTemplate does not show the snapshot policy + the ${A} marker on ${J.join(", ")} \u2014 aborting to manual repair.`))}if(t.phase==="captured"||t.phase==="flip-verified"){const r=await C({...t,phase:"flip-applied",updatedAt:f().toISOString(),liveTemplateDigest:H(h)});if(!r.success)return r;t=r.data}p?.(`Deriving the surgical removal of ${g.join(", ")} from ${e}`);const d=le(h,u,e);if(!d.success)return w(new Error(`${d.error.message} The snapshot flip has already been applied on ${e}. ${E}`));p?.("Proving the removal touches only the recreate targets (review change set)");const y=await de(i,{stackName:e,removalTemplateBody:d.data.removalTemplateBody,targets:u,parameterKeys:m.parameterKeys,...n!==void 0?{abortSignal:n}:{}},s?.flip);if(!y.success)return y;if(!y.data.noChanges){const r=g.map(S=>`${S} \u2192 ${P[S]??"Delete"}`).join(", ");p?.(`Removing ${g.join(", ")} from ${e} (deletion policies: ${r})`);const T=await x(i,{stackName:e,flipTemplateBody:d.data.removalTemplateBody,capabilities:m.capabilities,parameterKeys:m.parameterKeys,derivedFromTemplateDigest:d.data.liveTemplateDigest,opId:l,operationLabel:"Recreate-removal",resumeHint:E,...n!==void 0?{abortSignal:n}:{}},{...s?.flip,updateTimeoutMs:s?.flip?.updateTimeoutMs??fe});if(!T.success)return T}const c=await B(i,e,n);if(!c.success)return c;h=c.data.templateBody;const $=I(h,u,e,b);if(!$.success)return $;const D=new Set($.data.absentTargets),L=u.filter(r=>!D.has(r));if(L.length>0)return w(new Error(`Recreate removal on ${e} completed but GetTemplate still shows ${L.join(", ")} \u2014 aborting to manual repair. ${E}`));if(!y.data.noChanges){const r=await ce(i,e,u,n);if(!r.success)return w(new Error(`Recreate removal on ${e} removed ${g.join(", ")} from the template, but the physical deletion outcome could not be verified: ${r.error.message}. ${E}`));if(r.data.length>0){const T=r.data.map(S=>`${S.logicalId}${S.statusReason!==void 0?` (${S.statusReason})`:""}`).join("; ");return w(new Error(`Recreate removal on ${e} removed ${g.join(", ")} from the template, but CloudFormation reported DELETE_FAILED for ${T} \u2014 the physical resource(s) still exist outside the stack and hold the pinned name(s), so the deploy's re-create would collide. Resolve the deletion failure and delete the orphaned resource(s) manually, then re-run the deploy to resume.`))}}const G=g.filter(r=>P[r]==="Snapshot");G.length>0&&p?.(`Final snapshots were requested for ${G.join(", ")} \u2014 locate them in the owning service's console to restore data into the re-created resources`)}const k=await C({...t,phase:"removal-applied",updatedAt:f().toISOString(),liveTemplateDigest:H(h)});return k.success?(t=k.data,p?.(`Surgical removal complete on ${e} \u2014 the deploy now re-creates ${u.join(", ")} under the pinned names`),j({opId:l,phase:t.phase,convergePending:!0,...t.forensicsPath!==void 0?{forensicsPath:t.forensicsPath}:{},snapshotPolicies:F(t),journal:t})):k}export{pe as SNAPSHOT_CAPABLE_RESOURCE_TYPES,we as runRecreateSurgery,ue as snapshotPolicyForType};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent remediation journal for the two-deploy `forget` orchestration
|
|
3
|
+
* (drift-remediation plan § 3.2). One JSON file per operation under
|
|
4
|
+
* `~/.fjall/state/remediation/<accountId>/<region>/<stackName>/<opId>.json`;
|
|
5
|
+
* writes are atomic (tmp + rename) against concurrent processes sharing the
|
|
6
|
+
* directory.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the drift-suspect journal (a hint), journal WRITES here fail loudly
|
|
9
|
+
* — a phase transition that cannot persist aborts the orchestration rather
|
|
10
|
+
* than mutating without a resume record. READS stay fail-soft: a corrupt or
|
|
11
|
+
* schema-drifted file reads as `null` (warn-logged) because the AWS-side
|
|
12
|
+
* `fjall:forget` marker [G3] re-derives the state a lost journal would have
|
|
13
|
+
* carried.
|
|
14
|
+
*
|
|
15
|
+
* Completed records are the audit trail, retained 90 days
|
|
16
|
+
* (`sweepExpiredRemediationJournals`); the sweep also removes the record's
|
|
17
|
+
* forensics bundle — the done journal holds the only `forensicsPath` pointer,
|
|
18
|
+
* so a bundle outliving its journal would accumulate unreferenced forever.
|
|
19
|
+
*/
|
|
20
|
+
import { type Result } from "@fjall/generator";
|
|
21
|
+
import { type RemediationJournalRecord } from "../../types/remediation.js";
|
|
22
|
+
export declare const REMEDIATION_JOURNAL_RETENTION_DAYS = 90;
|
|
23
|
+
export interface RemediationJournalOptions {
|
|
24
|
+
/** Override the journal root — tests point this at a temp dir. */
|
|
25
|
+
journalDir?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function defaultRemediationJournalDir(): string;
|
|
28
|
+
/**
|
|
29
|
+
* opId = digest of stack + logical-ID set (plan §3.2) — order-insensitive so
|
|
30
|
+
* re-detection reproduces the same id and a re-run resumes instead of forking
|
|
31
|
+
* a second operation. 16 hex chars keeps it path- and prompt-friendly.
|
|
32
|
+
*/
|
|
33
|
+
export declare function computeRemediationOpId(stackName: string, logicalIds: readonly string[]): string;
|
|
34
|
+
/**
|
|
35
|
+
* [G5] Verb-accurate done-journal notices, shared by BOTH surgery orchestrators
|
|
36
|
+
* (`runForgetSurgery` and `runRecreateSurgery` each meet cross-verb DONE
|
|
37
|
+
* journals — coupled values, one home). Deriving from `journal.verb` keeps a
|
|
38
|
+
* done RECREATE journal read through the forget arm from reporting "Forget
|
|
39
|
+
* operation … already completed", and vice versa.
|
|
40
|
+
*/
|
|
41
|
+
export declare function formatCompletedOperationNotice(journal: Pick<RemediationJournalRecord, "verb" | "opId">): string;
|
|
42
|
+
export declare function formatArchivedOperationNotice(journal: Pick<RemediationJournalRecord, "verb" | "opId">): string;
|
|
43
|
+
/**
|
|
44
|
+
* Persist the journal record (atomic tmp + rename). Fails loudly — callers
|
|
45
|
+
* abort the orchestration when the resume record cannot be written.
|
|
46
|
+
*/
|
|
47
|
+
export declare function writeRemediationJournal(record: RemediationJournalRecord, options?: RemediationJournalOptions): Promise<Result<string, Error>>;
|
|
48
|
+
/**
|
|
49
|
+
* Read one operation's journal. Missing, corrupt, or schema-drifted files
|
|
50
|
+
* read as `null` (warn-logged) — the fjall:forget marker [G3] re-derives.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readRemediationJournal(accountId: string, region: string, stackName: string, opId: string, options?: RemediationJournalOptions): Promise<RemediationJournalRecord | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Archive a COMPLETED journal out of the resume keyspace: renamed to
|
|
55
|
+
* `<opId>.done-<completedAt>.json` in place, it stops matching
|
|
56
|
+
* `findRemediationJournalByOpId` and the fresh-repair read while remaining a
|
|
57
|
+
* parseable done record — `listActiveRemediationOps` still excludes it and
|
|
58
|
+
* the retention sweep still ages it out. Used when a fresh repair meets a
|
|
59
|
+
* done journal from a prior incarnation of the same opId.
|
|
60
|
+
*/
|
|
61
|
+
export declare function archiveCompletedRemediationJournal(record: RemediationJournalRecord, options?: RemediationJournalOptions): Promise<Result<string, Error>>;
|
|
62
|
+
export interface RemediationJournalContext {
|
|
63
|
+
accountId: string;
|
|
64
|
+
region: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Locate a journal by opId alone (`fjall drift resume <opId>` has no stack
|
|
68
|
+
* context). Scans the account/region/stack keyspace for `<opId>.json`. The
|
|
69
|
+
* opId has no account/region component, so the same operation replayed in two
|
|
70
|
+
* contexts (staging + prod) collides — with multiple matches the one under
|
|
71
|
+
* the current credentials' account/region wins; when none matches, the
|
|
72
|
+
* failure enumerates every match so the operator can switch context.
|
|
73
|
+
*/
|
|
74
|
+
export declare function findRemediationJournalByOpId(opId: string, context?: RemediationJournalContext, options?: RemediationJournalOptions): Promise<Result<RemediationJournalRecord | null, Error>>;
|
|
75
|
+
/** All non-done operations for one stack — the [G2] pre-flight consumes. */
|
|
76
|
+
export declare function listActiveRemediationOps(accountId: string, region: string, stackName: string, options?: RemediationJournalOptions): Promise<RemediationJournalRecord[]>;
|
|
77
|
+
/**
|
|
78
|
+
* All non-done operations across EVERY stack in one account/region — the
|
|
79
|
+
* post-deploy closure sweep consumes (an open recreate journal can live on a
|
|
80
|
+
* stack the current deploy skipped, and that sweep is the recreate arm's
|
|
81
|
+
* sole closure point).
|
|
82
|
+
*/
|
|
83
|
+
export declare function listActiveRemediationOpsForContext(accountId: string, region: string, options?: RemediationJournalOptions): Promise<RemediationJournalRecord[]>;
|
|
84
|
+
/**
|
|
85
|
+
* Best-effort sweep of completed journals past the 90-day audit retention.
|
|
86
|
+
* Never fails the caller.
|
|
87
|
+
*/
|
|
88
|
+
export declare function sweepExpiredRemediationJournals(options?: RemediationJournalOptions, now?: () => Date): Promise<void>;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{createHash as J}from"node:crypto";import{mkdir as b,readdir as j,readFile as $,rename as N,rm as x,rmdir as E,writeFile as S}from"node:fs/promises";import{homedir as T}from"node:os";import{basename as O,dirname as I,join as m}from"node:path";import{success as h,failure as g}from"@fjall/generator";import{getErrorMessage as f,maskSensitiveOutput as s}from"@fjall/util";import{logger as l}from"@fjall/util/logger";import{RemediationJournalRecordSchema as _}from"../../types/remediation.js";const d="RemediationJournal",k=90;function w(){return m(T(),".fjall","state","remediation")}function V(e,n){const r=J("sha256");r.update(e);for(const t of[...n].sort())r.update(`
|
|
2
|
+
`),r.update(t);return r.digest("hex").slice(0,16)}function q(e){return`${C(e.verb)} operation ${e.opId} already completed`}function Q(e){return`Archived completed ${e.verb} operation ${e.opId} from a previous incarnation \u2014 starting a fresh operation`}function C(e){return e.charAt(0).toUpperCase()+e.slice(1)}const D=/^[A-Za-z0-9][A-Za-z0-9-]*$/,F=/^[a-f0-9]{8,64}$/;function v(e,n,r,t,a){for(const[o,u,i]of[["accountId",n,D],["region",r,D],["stackName",t,D],["opId",a,F]])if(!i.test(u))return g(new Error(`Refusing remediation journal path: unsafe ${o} segment "${s(u)}"`));return h(m(e,n,r,t,`${a}.json`))}async function X(e,n){const r=n?.journalDir??w(),t=v(r,e.accountId,e.region,e.stackName,e.opId);if(!t.success)return t;try{await b(I(t.data),{recursive:!0});const a=`${t.data}.tmp-${process.pid}`;return await S(a,JSON.stringify(e,null,2),"utf8"),await N(a,t.data),h(t.data)}catch(a){return g(new Error(`Failed to write remediation journal for ${e.stackName}/${e.opId}: ${s(f(a))}`))}}async function P(e,n,r,t,a){const o=a?.journalDir??w(),u=v(o,e,n,r,t);if(!u.success)return null;let i;try{i=await $(u.data,"utf8")}catch{return null}return R(i,`${r}/${t}`)}function R(e,n){let r;try{r=JSON.parse(e)}catch(a){return l.warn(d,"Corrupt remediation journal ignored",{label:n,error:s(f(a))}),null}const t=_.safeParse(r);return t.success?t.data:(l.warn(d,"Schema-drifted remediation journal ignored",{label:n,error:s(t.error.message)}),null)}async function ee(e,n){const r=n?.journalDir??w(),t=v(r,e.accountId,e.region,e.stackName,e.opId);if(!t.success)return t;const a=(e.completedAt??e.updatedAt).replace(/[^0-9A-Za-z.-]/g,""),o=t.data.replace(/\.json$/,`.done-${a}.json`);try{return await N(t.data,o),h(o)}catch(u){return g(new Error(`Failed to archive the completed remediation journal for ${e.stackName}/${e.opId}: ${s(f(u))}`))}}async function te(e,n,r){if(!F.test(e))return h(null);const t=r?.journalDir??w();let a;try{a=await A(t,3)}catch{return h(null)}const o=[];for(const i of a.filter(c=>c.endsWith(`/${e}.json`))){let c;try{c=await $(i,"utf8")}catch{continue}const p=R(c,e);p!==null&&o.push(p)}if(o.length===0)return h(null);if(o.length===1)return h(o[0]??null);const u=o.find(i=>i.accountId===n?.accountId&&i.region===n?.region);return u!==void 0?h(u):g(new Error(`Remediation operation ${e} exists in more than one context: ${o.map(i=>`${i.accountId}/${i.region}`).join(", ")}${n!==void 0?` \u2014 none matches the current credentials (${n.accountId}/${n.region}). Switch to the matching account/region and re-run 'fjall drift resume ${e}'.`:"."}`))}async function M(e,n,r,t){const a=t?.journalDir??w(),o=m(a,e,n,r);if(!D.test(r))return[];let u;try{u=await j(o)}catch{return[]}const i=[];for(const c of u){if(!c.endsWith(".json"))continue;let p;try{p=await $(m(o,c),"utf8")}catch{continue}const y=R(p,`${r}/${c}`);y!==null&&y.phase!=="done"&&i.push(y)}return i}async function re(e,n,r){const t=r?.journalDir??w();let a;try{a=await j(m(t,e,n))}catch{return[]}const o=[];for(const u of a)o.push(...await M(e,n,u,r));return o}async function ne(e,n=()=>new Date){const r=e?.journalDir??w(),t=n().getTime()-k*24*60*60*1e3;let a;try{a=await A(r,3)}catch{return}for(const o of a){let u;try{u=await $(o,"utf8")}catch{continue}const i=R(u,o);if(i===null||i.phase!=="done")continue;const c=Date.parse(i.completedAt??i.updatedAt);if(Number.isNaN(c)||c>=t)continue;let p=!0;await x(o,{force:!0}).catch(y=>{p=!1,l.debug(d,"Failed to sweep expired remediation journal",{file:o,error:s(f(y))})}),!(!p||i.forensicsPath===void 0)&&await K(i.forensicsPath,i.opId)}}async function K(e,n){const r=I(e),t=O(e)===n;if(!F.test(n)||!t&&O(r)!==n){l.debug(d,"Kept forensics bundle \u2014 unexpected path shape",{forensicsPath:e});return}if(t){await z(e);return}let a=!0;await x(e,{recursive:!0,force:!0}).catch(o=>{a=!1,l.warn(d,"Failed to sweep the expired journal's forensics",{forensicsPath:e,error:s(f(o))})}),a&&await E(r).catch(o=>{l.debug(d,"Kept forensics opId directory",{opDir:r,error:s(f(o))})})}async function z(e){let n;try{n=await j(e,{withFileTypes:!0})}catch(t){l.warn(d,"Failed to sweep the expired journal's forensics",{forensicsPath:e,error:s(f(t))});return}let r=!1;for(const t of n){if(!t.isFile()){r=!0;continue}await x(m(e,t.name),{force:!0}).catch(a=>{r=!0,l.warn(d,"Failed to sweep the expired journal's forensics",{forensicsPath:m(e,t.name),error:s(f(a))})})}if(r){l.debug(d,"Kept flat forensics directory \u2014 nested bundles live",{forensicsPath:e});return}await E(e).catch(t=>{l.debug(d,"Kept forensics opId directory",{forensicsPath:e,error:s(f(t))})})}async function A(e,n){const r=await j(e,{withFileTypes:!0}),t=[];for(const a of r){const o=m(e,a.name);a.isFile()&&a.name.endsWith(".json")?t.push(o):a.isDirectory()&&n>0&&t.push(...await A(o,n-1).catch(()=>[]))}return t}export{k as REMEDIATION_JOURNAL_RETENTION_DAYS,ee as archiveCompletedRemediationJournal,V as computeRemediationOpId,w as defaultRemediationJournalDir,te as findRemediationJournalByOpId,Q as formatArchivedOperationNotice,q as formatCompletedOperationNotice,M as listActiveRemediationOps,re as listActiveRemediationOpsForContext,P as readRemediationJournal,ne as sweepExpiredRemediationJournals,X as writeRemediationJournal};
|