@intentius/chant 0.18.19 → 0.18.21
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/build.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts +18 -2
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/lexicon.d.ts +10 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +10 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts +1 -0
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +4 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/op/builders.d.ts +2 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.test.ts +43 -0
- package/src/build.ts +11 -0
- package/src/cli/handlers/lifecycle.test.ts +42 -0
- package/src/cli/handlers/lifecycle.ts +154 -72
- package/src/components/cli-support.ts +2 -2
- package/src/config.ts +17 -0
- package/src/discovery/collect.test.ts +91 -0
- package/src/discovery/collect.ts +153 -60
- package/src/discovery/index.ts +1 -1
- package/src/lexicon.ts +10 -0
- package/src/lifecycle/git.ts +13 -0
- package/src/lifecycle/snapshot.test.ts +24 -0
- package/src/lifecycle/snapshot.ts +7 -4
- package/src/lifecycle/types.ts +4 -0
- package/src/op/builders.ts +6 -0
package/src/discovery/collect.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
1
|
+
import { basename, dirname, relative, resolve } from "node:path";
|
|
2
2
|
import { isDeclarable, type Declarable } from "../declarable";
|
|
3
3
|
import { isCompositeInstance, expandComposite } from "../composite";
|
|
4
4
|
import { isLexiconOutput } from "../lexicon-output";
|
|
5
5
|
import { DiscoveryError } from "../errors";
|
|
6
|
-
import { setProvenance } from "../provenance";
|
|
6
|
+
import { setProvenance, type EntityProvenance } from "../provenance";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* The entity key for an export. `export default` is per-module — the `Op` pattern
|
|
@@ -18,84 +18,177 @@ function exportKey(rawName: string, file: string): string {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* @throws {DiscoveryError} with type "resolution" if duplicate export names are found
|
|
21
|
+
* A stable, CloudFormation-valid prefix identifying the stack directory a file
|
|
22
|
+
* belongs to, relative to the build root. Used to disambiguate an entity name
|
|
23
|
+
* that legitimately repeats across sibling stack directories (see
|
|
24
|
+
* {@link collectEntities}). Derived from the path *relative to the build root*
|
|
25
|
+
* (not the absolute path) so the resulting key — and therefore any build digest
|
|
26
|
+
* that hashes it — is portable across machines and checkouts. Punctuation
|
|
27
|
+
* (`/`, `-`, `.`) is dropped and each segment PascalCased, keeping the key
|
|
28
|
+
* within CloudFormation's `^[A-Za-z0-9]+$` logical-id grammar.
|
|
30
29
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
34
|
-
const
|
|
30
|
+
function stackPrefix(file: string, buildRoot: string | undefined): string {
|
|
31
|
+
const dir = dirname(file);
|
|
32
|
+
const rel = buildRoot ? relative(resolve(buildRoot), resolve(dir)) : dir;
|
|
33
|
+
const segments = rel.split(/[^A-Za-z0-9]+/).filter((s) => s.length > 0);
|
|
34
|
+
return segments.map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One entity to be placed into the map, produced by {@link enumerateEntries}. */
|
|
38
|
+
interface PendingEntry {
|
|
39
|
+
/** The un-disambiguated map key this entity would take (export name, indexed
|
|
40
|
+
* array name, or composite-expanded member name). */
|
|
41
|
+
bareKey: string;
|
|
42
|
+
value: Declarable;
|
|
43
|
+
file: string;
|
|
44
|
+
provenance: EntityProvenance;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Flatten every module's exports into the ordered list of entities they
|
|
49
|
+
* contribute — declarables directly, arrays element-by-element (indexed names),
|
|
50
|
+
* composite instances expanded into members, and LexiconOutputs. Order is
|
|
51
|
+
* preserved so downstream serializers emit resources in a stable order.
|
|
52
|
+
*/
|
|
53
|
+
function enumerateEntries(
|
|
54
|
+
modules: Array<{ file: string; exports: Record<string, unknown> }>,
|
|
55
|
+
): PendingEntry[] {
|
|
56
|
+
const entries: PendingEntry[] = [];
|
|
35
57
|
|
|
36
58
|
for (const { file, exports } of modules) {
|
|
37
59
|
for (const [rawName, value] of Object.entries(exports)) {
|
|
38
60
|
const name = exportKey(rawName, file);
|
|
39
61
|
if (isDeclarable(value)) {
|
|
40
|
-
|
|
41
|
-
// Same object re-exported from multiple files (e.g. re-exports from multiple files) is fine
|
|
42
|
-
if (entities.get(name) !== value) {
|
|
43
|
-
throw new DiscoveryError(
|
|
44
|
-
file,
|
|
45
|
-
`Duplicate export name "${name}" found`,
|
|
46
|
-
"resolution"
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
} else {
|
|
50
|
-
setProvenance(value, { sourceFile: file });
|
|
51
|
-
entities.set(name, value);
|
|
52
|
-
}
|
|
62
|
+
entries.push({ bareKey: name, value, file, provenance: { sourceFile: file } });
|
|
53
63
|
} else if (Array.isArray(value)) {
|
|
54
|
-
// Arrays of Declarables or CompositeInstances — each element gets an indexed name: exportName_0, ...
|
|
55
64
|
for (let i = 0; i < value.length; i++) {
|
|
56
65
|
const item = value[i];
|
|
57
66
|
if (isDeclarable(item)) {
|
|
58
|
-
|
|
59
|
-
if (entities.has(indexedName) && entities.get(indexedName) !== item) {
|
|
60
|
-
throw new DiscoveryError(file, `Duplicate entity name "${indexedName}"`, "resolution");
|
|
61
|
-
}
|
|
62
|
-
setProvenance(item, { sourceFile: file });
|
|
63
|
-
entities.set(indexedName, item);
|
|
67
|
+
entries.push({ bareKey: `${name}_${i}`, value: item, file, provenance: { sourceFile: file } });
|
|
64
68
|
} else if (isCompositeInstance(item)) {
|
|
65
69
|
const indexedName = `${name}_${i}`;
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
setProvenance(entity, { sourceFile: file, compositeInstance: indexedName });
|
|
76
|
-
entities.set(expandedName, entity);
|
|
70
|
+
for (const [expandedName, entity] of expandComposite(indexedName, item)) {
|
|
71
|
+
entries.push({
|
|
72
|
+
bareKey: expandedName,
|
|
73
|
+
value: entity,
|
|
74
|
+
file,
|
|
75
|
+
provenance: { sourceFile: file, compositeInstance: indexedName },
|
|
76
|
+
});
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
} else if (isCompositeInstance(value)) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
setProvenance(entity, { sourceFile: file, compositeInstance: name });
|
|
91
|
-
entities.set(expandedName, entity);
|
|
81
|
+
for (const [expandedName, entity] of expandComposite(name, value)) {
|
|
82
|
+
entries.push({
|
|
83
|
+
bareKey: expandedName,
|
|
84
|
+
value: entity,
|
|
85
|
+
file,
|
|
86
|
+
provenance: { sourceFile: file, compositeInstance: name },
|
|
87
|
+
});
|
|
92
88
|
}
|
|
93
89
|
} else if (isLexiconOutput(value)) {
|
|
94
|
-
// LexiconOutput is not a Declarable but build() expects to find them
|
|
95
|
-
//
|
|
96
|
-
|
|
90
|
+
// LexiconOutput is not a Declarable but build() expects to find them in
|
|
91
|
+
// the entities map so it can collect and pass them to serializers.
|
|
92
|
+
entries.push({ bareKey: name, value: value as unknown as Declarable, file, provenance: { sourceFile: file } });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return entries;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Collects all declarable entities from imported modules.
|
|
102
|
+
* CompositeInstance exports are expanded into individual entities
|
|
103
|
+
* with `{exportName}_{memberName}` naming.
|
|
104
|
+
* LexiconOutput exports are also collected so that build() can
|
|
105
|
+
* extract them and pass them to the serializer.
|
|
106
|
+
*
|
|
107
|
+
* A bare entity name must be unique *within a single stack directory*, not
|
|
108
|
+
* across the whole project. A multi-stack project (independently-deployed
|
|
109
|
+
* sibling stacks under one root — e.g. `src/loom-backend/`, `src/loom-agents/`)
|
|
110
|
+
* legitimately reuses conventional cross-stack `Parameter` names like
|
|
111
|
+
* `pArtifactBucket`/`pImageUri` across siblings, because each directory is its
|
|
112
|
+
* own CloudFormation template where that name is the real, deployed logical id.
|
|
113
|
+
* When the same bare name is declared (as distinct objects) in two different
|
|
114
|
+
* directories, each is disambiguated by a stack prefix derived from its
|
|
115
|
+
* directory ({@link stackPrefix}) rather than throwing — so an unscoped
|
|
116
|
+
* whole-project build / `chant lifecycle snapshot|diff` no longer collides
|
|
117
|
+
* (#932). A per-stack scoped build (`chant build <dir>`) has a single directory
|
|
118
|
+
* relative to its root, so nothing is prefixed and the deployed logical ids are
|
|
119
|
+
* unchanged. A genuine *same-directory* duplicate is still an error.
|
|
120
|
+
*
|
|
121
|
+
* @param modules - Array of module records with their exports
|
|
122
|
+
* @param buildRoot - The build root the discovery ran against; stack prefixes
|
|
123
|
+
* are derived relative to it so keys stay portable across machines.
|
|
124
|
+
* @returns Map of export name to Declarable entity
|
|
125
|
+
* @throws {DiscoveryError} with type "resolution" if a name is duplicated within one directory
|
|
126
|
+
*/
|
|
127
|
+
export function collectEntities(
|
|
128
|
+
modules: Array<{ file: string; exports: Record<string, unknown> }>,
|
|
129
|
+
buildRoot?: string,
|
|
130
|
+
): Map<string, Declarable> {
|
|
131
|
+
const entries = enumerateEntries(modules);
|
|
132
|
+
|
|
133
|
+
// Which bare keys collide across more than one directory (as distinct
|
|
134
|
+
// objects)? Those — and only those — get a stack prefix. A bare key that
|
|
135
|
+
// resolves to a single object (even one re-exported from several files) keeps
|
|
136
|
+
// its raw name, so single-stack projects are unaffected.
|
|
137
|
+
const dirsByKey = new Map<string, Map<string, Set<Declarable>>>();
|
|
138
|
+
for (const { bareKey, value, file } of entries) {
|
|
139
|
+
const dir = dirname(file);
|
|
140
|
+
let byDir = dirsByKey.get(bareKey);
|
|
141
|
+
if (!byDir) {
|
|
142
|
+
byDir = new Map();
|
|
143
|
+
dirsByKey.set(bareKey, byDir);
|
|
144
|
+
}
|
|
145
|
+
let objs = byDir.get(dir);
|
|
146
|
+
if (!objs) {
|
|
147
|
+
objs = new Set();
|
|
148
|
+
byDir.set(dir, objs);
|
|
149
|
+
}
|
|
150
|
+
objs.add(value);
|
|
151
|
+
}
|
|
152
|
+
const crossDirKeys = new Set<string>();
|
|
153
|
+
for (const [bareKey, byDir] of dirsByKey) {
|
|
154
|
+
const dirsWithObjects = [...byDir.values()].filter((objs) => objs.size > 0).length;
|
|
155
|
+
const distinctObjects = new Set<Declarable>();
|
|
156
|
+
for (const objs of byDir.values()) for (const o of objs) distinctObjects.add(o);
|
|
157
|
+
if (dirsWithObjects > 1 && distinctObjects.size > 1) crossDirKeys.add(bareKey);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const entities = new Map<string, Declarable>();
|
|
161
|
+
// Per bare key, the object already claimed for each directory — a second,
|
|
162
|
+
// *different* object in the same directory is a genuine duplicate.
|
|
163
|
+
const claimedByDir = new Map<string, Map<string, Declarable>>();
|
|
164
|
+
|
|
165
|
+
for (const { bareKey, value, file, provenance } of entries) {
|
|
166
|
+
const dir = dirname(file);
|
|
167
|
+
let perDir = claimedByDir.get(bareKey);
|
|
168
|
+
if (!perDir) {
|
|
169
|
+
perDir = new Map();
|
|
170
|
+
claimedByDir.set(bareKey, perDir);
|
|
171
|
+
}
|
|
172
|
+
const claimed = perDir.get(dir);
|
|
173
|
+
if (claimed !== undefined && claimed !== value) {
|
|
174
|
+
// Same name, same directory, different object → a real collision.
|
|
175
|
+
throw new DiscoveryError(file, `Duplicate export name "${bareKey}" found`, "resolution");
|
|
176
|
+
}
|
|
177
|
+
perDir.set(dir, value);
|
|
178
|
+
|
|
179
|
+
const key = crossDirKeys.has(bareKey) ? `${stackPrefix(file, buildRoot)}${bareKey}` : bareKey;
|
|
180
|
+
const existing = entities.get(key);
|
|
181
|
+
if (existing !== undefined) {
|
|
182
|
+
// Same object re-exported (possibly from multiple files) is fine; a
|
|
183
|
+
// different object landing on the same disambiguated key would only
|
|
184
|
+
// happen if two directories produced an identical stack prefix.
|
|
185
|
+
if (existing !== value) {
|
|
186
|
+
throw new DiscoveryError(file, `Duplicate export name "${bareKey}" found`, "resolution");
|
|
97
187
|
}
|
|
188
|
+
continue;
|
|
98
189
|
}
|
|
190
|
+
setProvenance(value, provenance);
|
|
191
|
+
entities.set(key, value);
|
|
99
192
|
}
|
|
100
193
|
|
|
101
194
|
return entities;
|
package/src/discovery/index.ts
CHANGED
|
@@ -67,7 +67,7 @@ export async function discover(path: string): Promise<DiscoveryResult> {
|
|
|
67
67
|
let entities = new Map<string, Declarable>();
|
|
68
68
|
|
|
69
69
|
try {
|
|
70
|
-
entities = collectEntities(modules);
|
|
70
|
+
entities = collectEntities(modules, path);
|
|
71
71
|
} catch (error) {
|
|
72
72
|
// Collect resolution errors
|
|
73
73
|
if (error instanceof Error && error.name === "DiscoveryError") {
|
package/src/lexicon.ts
CHANGED
|
@@ -372,6 +372,13 @@ export interface LexiconPlugin {
|
|
|
372
372
|
buildOutput: string;
|
|
373
373
|
entityNames: string[];
|
|
374
374
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
375
|
+
/**
|
|
376
|
+
* The deployed stack name to observe, for a multi-stack project where the
|
|
377
|
+
* stack is not named after the environment (see `stacks` in {@link
|
|
378
|
+
* ChantConfig}). When omitted, an implementation keeps its single-stack
|
|
379
|
+
* convention (AWS: the stack named after `environment`).
|
|
380
|
+
*/
|
|
381
|
+
stack?: string;
|
|
375
382
|
/**
|
|
376
383
|
* Restrict the result to chant-owned resources (those carrying the
|
|
377
384
|
* ownership marker, #119). Where a lexicon has no durable marker channel,
|
|
@@ -418,6 +425,9 @@ export interface LexiconPlugin {
|
|
|
418
425
|
listArtifacts?(options: {
|
|
419
426
|
environment: string;
|
|
420
427
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
428
|
+
/** The deployed stack to scope enumeration to, for a multi-stack project
|
|
429
|
+
* (see `stacks` in {@link ChantConfig}). Omitted for single-stack projects. */
|
|
430
|
+
stack?: string;
|
|
421
431
|
}): Promise<Record<string, ArtifactMetadata>>;
|
|
422
432
|
|
|
423
433
|
// Live export (cloud → code)
|
package/src/lifecycle/git.ts
CHANGED
|
@@ -141,6 +141,19 @@ export async function readBlobFromPath(
|
|
|
141
141
|
return result.stdout;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Storage key for a snapshot on the orphan branch. Single-stack projects key by
|
|
146
|
+
* lexicon (`<env>/<lexicon>.json`, unchanged). A multi-stack project (see
|
|
147
|
+
* `stacks` in ChantConfig, #932) folds the stack in as `<stack>__<lexicon>`
|
|
148
|
+
* (`<env>/<stack>__<lexicon>.json`) so sibling stacks that deploy the same
|
|
149
|
+
* lexicon don't overwrite each other's snapshots. The `__` separator can't
|
|
150
|
+
* collide with a lexicon name (lexicons are single tokens) and round-trips
|
|
151
|
+
* through `readSnapshot`/`readEnvironmentSnapshots` unchanged.
|
|
152
|
+
*/
|
|
153
|
+
export function snapshotStorageKey(lexicon: string, stack?: string): string {
|
|
154
|
+
return stack ? `${stack}__${lexicon}` : lexicon;
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
/**
|
|
145
158
|
* Write a state snapshot JSON to the orphan branch.
|
|
146
159
|
*
|
|
@@ -8,6 +8,7 @@ const pushLifecycleMock = vi.fn();
|
|
|
8
8
|
|
|
9
9
|
vi.mock("./git", () => ({
|
|
10
10
|
writeSnapshot: (...args: unknown[]) => writeSnapshotMock(...args),
|
|
11
|
+
snapshotStorageKey: (lexicon: string, stack?: string) => (stack ? `${stack}__${lexicon}` : lexicon),
|
|
11
12
|
getHeadCommit: () => getHeadCommitMock(),
|
|
12
13
|
pushLifecycle: () => pushLifecycleMock(),
|
|
13
14
|
}));
|
|
@@ -57,6 +58,29 @@ describe("takeSnapshot", () => {
|
|
|
57
58
|
});
|
|
58
59
|
expect(writeSnapshotMock).toHaveBeenCalledTimes(1);
|
|
59
60
|
expect(pushLifecycleMock).toHaveBeenCalledTimes(1);
|
|
61
|
+
// Single-stack: written under the bare lexicon key, snapshot carries no stack.
|
|
62
|
+
expect(writeSnapshotMock.mock.calls[0][1]).toBe("aws");
|
|
63
|
+
expect(result.snapshots[0].stack).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// #932 — a multi-stack project observes each stack against its own live stack
|
|
67
|
+
// and stores its snapshot under a stack-scoped key so siblings don't overwrite.
|
|
68
|
+
test("stack option: observes the named stack and stores under a stack-scoped key", async () => {
|
|
69
|
+
let observedStack: string | undefined = "unset";
|
|
70
|
+
const plugin = createMockPlugin({
|
|
71
|
+
name: "aws",
|
|
72
|
+
describeResources: async (options: { stack?: string }) => {
|
|
73
|
+
observedStack = options.stack;
|
|
74
|
+
return { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { stack: "loom-backend" });
|
|
78
|
+
// describeResources was told which live stack to query.
|
|
79
|
+
expect(observedStack).toBe("loom-backend");
|
|
80
|
+
// The snapshot records its stack …
|
|
81
|
+
expect(result.snapshots[0].stack).toBe("loom-backend");
|
|
82
|
+
// … and is stored under `<stack>__<lexicon>`, not the bare lexicon key.
|
|
83
|
+
expect(writeSnapshotMock.mock.calls[0][1]).toBe("loom-backend__aws");
|
|
60
84
|
});
|
|
61
85
|
|
|
62
86
|
test("plugin without describeResources is skipped", async () => {
|
|
@@ -7,7 +7,7 @@ import type { BuildResult } from "../build";
|
|
|
7
7
|
import type { SerializerResult } from "../serializer";
|
|
8
8
|
import type { LifecycleSnapshot } from "./types";
|
|
9
9
|
import { computeBuildDigest } from "./digest";
|
|
10
|
-
import { writeSnapshot, getHeadCommit, pushLifecycle } from "./git";
|
|
10
|
+
import { writeSnapshot, snapshotStorageKey, getHeadCommit, pushLifecycle } from "./git";
|
|
11
11
|
import { sortedJsonReplacer } from "../utils";
|
|
12
12
|
|
|
13
13
|
/** Patterns in attribute names that suggest sensitive data. */
|
|
@@ -84,8 +84,9 @@ export async function takeSnapshot(
|
|
|
84
84
|
environment: string,
|
|
85
85
|
plugins: ObservationLexicon[],
|
|
86
86
|
buildResult: BuildResult,
|
|
87
|
-
opts?: { cwd?: string },
|
|
87
|
+
opts?: { cwd?: string; stack?: string },
|
|
88
88
|
): Promise<TakeSnapshotResult> {
|
|
89
|
+
const stack = opts?.stack;
|
|
89
90
|
const warnings: string[] = [];
|
|
90
91
|
const errors: string[] = [];
|
|
91
92
|
const snapshots: LifecycleSnapshot[] = [];
|
|
@@ -131,6 +132,7 @@ export async function takeSnapshot(
|
|
|
131
132
|
buildOutput,
|
|
132
133
|
entityNames,
|
|
133
134
|
entities,
|
|
135
|
+
stack,
|
|
134
136
|
});
|
|
135
137
|
const { valid, dropped, warnings: validationWarnings } = validateResources(raw);
|
|
136
138
|
warnings.push(...validationWarnings);
|
|
@@ -141,7 +143,7 @@ export async function takeSnapshot(
|
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
if (plugin.listArtifacts) {
|
|
144
|
-
const raw = await plugin.listArtifacts({ environment, entities });
|
|
146
|
+
const raw = await plugin.listArtifacts({ environment, entities, stack });
|
|
145
147
|
const { valid, dropped, warnings: validationWarnings } = validateResources(raw);
|
|
146
148
|
warnings.push(...validationWarnings);
|
|
147
149
|
if (dropped.length > 0) {
|
|
@@ -158,6 +160,7 @@ export async function takeSnapshot(
|
|
|
158
160
|
const snapshot: LifecycleSnapshot = {
|
|
159
161
|
lexicon: plugin.name,
|
|
160
162
|
environment,
|
|
163
|
+
...(stack ? { stack } : {}),
|
|
161
164
|
commit: headCommit,
|
|
162
165
|
timestamp,
|
|
163
166
|
resources,
|
|
@@ -179,7 +182,7 @@ export async function takeSnapshot(
|
|
|
179
182
|
const json = JSON.stringify(snapshot, sortedJsonReplacer, 2);
|
|
180
183
|
commitSha = await writeSnapshot(
|
|
181
184
|
snapshot.environment,
|
|
182
|
-
snapshot.lexicon,
|
|
185
|
+
snapshotStorageKey(snapshot.lexicon, snapshot.stack),
|
|
183
186
|
json,
|
|
184
187
|
opts,
|
|
185
188
|
);
|
package/src/lifecycle/types.ts
CHANGED
|
@@ -8,6 +8,10 @@ export type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
|
8
8
|
export interface LifecycleSnapshot {
|
|
9
9
|
lexicon: string;
|
|
10
10
|
environment: string;
|
|
11
|
+
/** Deployed stack name, for a multi-stack project (see `stacks` in
|
|
12
|
+
* ChantConfig). Absent for single-stack projects. Snapshots are stored per
|
|
13
|
+
* `<env>/<stack>/<lexicon>` when set, so sibling stacks don't overwrite. */
|
|
14
|
+
stack?: string;
|
|
11
15
|
/** Main branch commit this corresponds to */
|
|
12
16
|
commit: string;
|
|
13
17
|
/** ISO timestamp when the snapshot was taken */
|
package/src/op/builders.ts
CHANGED
|
@@ -106,6 +106,12 @@ export const waitForStack = (name: string, opts?: Record<string, unknown>): Acti
|
|
|
106
106
|
return activity("waitForStack", { name, ...args }, profile ?? "k8sWait");
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
+
/** Poll any operator-backed Kubernetes resource until it reports ready, driven by a data-only readiness spec (CRD-aware; #365). Defaults to the `k8sWait` profile (override via `opts.profile`). */
|
|
110
|
+
export const waitForReady = (kind: string, name: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
111
|
+
const { args, profile } = takeProfile(opts);
|
|
112
|
+
return activity("waitForReady", { kind, name, ...args }, profile ?? "k8sWait");
|
|
113
|
+
};
|
|
114
|
+
|
|
109
115
|
/** Trigger and wait for a GitLab CI pipeline to complete. Defaults to the `longInfra` profile (override via `opts.profile`). */
|
|
110
116
|
export const gitlabPipeline = (name: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
111
117
|
const { args, profile } = takeProfile(opts);
|