@intentius/chant 0.15.2 → 0.16.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/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +3 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +46 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +89 -0
- package/dist/graph-refs.d.ts.map +1 -0
- package/dist/lexicon.d.ts +23 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +31 -0
- package/dist/lifecycle/observe.d.ts.map +1 -0
- package/dist/op/builders.d.ts +84 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/emulator-lifecycle.d.ts +52 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -0
- package/dist/op/index.d.ts +3 -1
- package/dist/op/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/graph.ts +131 -3
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +3 -0
- package/src/graph-ir-live.test.ts +88 -0
- package/src/graph-ir.ts +90 -0
- package/src/graph-refs.test.ts +131 -0
- package/src/graph-refs.ts +196 -0
- package/src/lexicon.ts +25 -0
- package/src/lifecycle/observe.test.ts +65 -0
- package/src/lifecycle/observe.ts +83 -0
- package/src/op/builders.ts +135 -0
- package/src/op/emulator-lifecycle.test.ts +41 -0
- package/src/op/emulator-lifecycle.ts +129 -0
- package/src/op/index.ts +5 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live edge reconstruction (#778, the crux of epic #776).
|
|
3
|
+
*
|
|
4
|
+
* A source-derived IR gets its edges from declared AttrRefs. A live IR
|
|
5
|
+
* (`chant graph --live`) has none — observed resources reference each other by
|
|
6
|
+
* **physical identifier** buried in their attributes (a subnet's `VpcId`, an
|
|
7
|
+
* ALB listener's `TargetGroupArn`, an ECS service's `ClusterArn`). This module
|
|
8
|
+
* reconstructs those relationships from a per-lexicon **reference catalog**.
|
|
9
|
+
*
|
|
10
|
+
* Provider-agnostic: the engine here is pure and knows nothing about AWS; each
|
|
11
|
+
* lexicon ships its own `ReferenceCatalog` (data). Given the live nodes and a
|
|
12
|
+
* catalog, `reconstructEdges` returns:
|
|
13
|
+
* - `edges` — peer references → IR edges (holder → referenced)
|
|
14
|
+
* - `containment` — "inside" references (subnet ∈ VPC) → boundary hints for #779
|
|
15
|
+
* - `dangling` — references whose target isn't in the observed set
|
|
16
|
+
*
|
|
17
|
+
* The containment / edge split keeps subnet-in-VPC a boundary box (#779), not a
|
|
18
|
+
* cluttering line. Deterministic given a fixed node set.
|
|
19
|
+
*/
|
|
20
|
+
import type { IRNode, IREdge } from "./graph-ir";
|
|
21
|
+
|
|
22
|
+
/** Which attribute paths identify a resource kind (its id / ARN / name / DNS). */
|
|
23
|
+
export interface IdentityRule {
|
|
24
|
+
kind: string;
|
|
25
|
+
/** Attr paths whose values are identifiers others reference this kind by. */
|
|
26
|
+
ids: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A reference: an attr path on `from` whose value points at another resource. */
|
|
30
|
+
export interface RefRule {
|
|
31
|
+
/** Holder kind. */
|
|
32
|
+
from: string;
|
|
33
|
+
/** Attr path — supports `a.b` and `arr[].id`. */
|
|
34
|
+
path: string;
|
|
35
|
+
/** Which identifier the value is (currently informational; matching is exact). */
|
|
36
|
+
match?: "id" | "arn" | "name" | "any";
|
|
37
|
+
/** Constrain the target kind (disambiguates identifier collisions). */
|
|
38
|
+
targetKind?: string;
|
|
39
|
+
/** `reference` → an edge; `containment` → a boundary hint (#779), not an edge. */
|
|
40
|
+
relation: "reference" | "containment";
|
|
41
|
+
/** Edge / containment label (e.g. "in VPC", "sg", "targets"). */
|
|
42
|
+
label?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A lexicon's reference knowledge — its identity map and reference rules. */
|
|
46
|
+
export interface ReferenceCatalog {
|
|
47
|
+
identities: IdentityRule[];
|
|
48
|
+
refs: RefRule[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `child` is contained by `parent` (subnet ∈ VPC). For #779's boundary boxes. */
|
|
52
|
+
export interface ContainmentPair {
|
|
53
|
+
child: string;
|
|
54
|
+
parent: string;
|
|
55
|
+
label?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A reference whose target isn't in the observed set (cross-account, unmanaged,
|
|
59
|
+
* deleted) — surfaced, never turned into a wrong edge. */
|
|
60
|
+
export interface DanglingRef {
|
|
61
|
+
from: string;
|
|
62
|
+
path: string;
|
|
63
|
+
value: string;
|
|
64
|
+
targetKind?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ReconstructedEdges {
|
|
68
|
+
edges: IREdge[];
|
|
69
|
+
containment: ContainmentPair[];
|
|
70
|
+
dangling: DanglingRef[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function toStr(v: unknown): string | undefined {
|
|
74
|
+
if (typeof v === "string") return v;
|
|
75
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read all scalar values at an attr path. Supports nested keys (`a.b`) and array
|
|
81
|
+
* fan-out (`arr[]`, `arr[].id`). Returns every scalar found — a path through an
|
|
82
|
+
* array yields one value per element.
|
|
83
|
+
*/
|
|
84
|
+
export function readPath(obj: unknown, path: string): string[] {
|
|
85
|
+
let cur: unknown[] = [obj];
|
|
86
|
+
for (const part of path.split(".")) {
|
|
87
|
+
const isArr = part.endsWith("[]");
|
|
88
|
+
const key = isArr ? part.slice(0, -2) : part;
|
|
89
|
+
const next: unknown[] = [];
|
|
90
|
+
for (const c of cur) {
|
|
91
|
+
if (c == null || typeof c !== "object") continue;
|
|
92
|
+
const val = (c as Record<string, unknown>)[key];
|
|
93
|
+
if (isArr) {
|
|
94
|
+
if (Array.isArray(val)) next.push(...val);
|
|
95
|
+
} else if (val !== undefined) {
|
|
96
|
+
next.push(val);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
cur = next;
|
|
100
|
+
}
|
|
101
|
+
const out: string[] = [];
|
|
102
|
+
for (const c of cur) {
|
|
103
|
+
const s = toStr(c);
|
|
104
|
+
if (s !== undefined) out.push(s);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Merge several lexicons' catalogs into one (concatenate identities + refs). */
|
|
110
|
+
export function mergeCatalogs(catalogs: ReferenceCatalog[]): ReferenceCatalog {
|
|
111
|
+
return {
|
|
112
|
+
identities: catalogs.flatMap((c) => c.identities),
|
|
113
|
+
refs: catalogs.flatMap((c) => c.refs),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Reconstruct edges + containment from live nodes and a catalog. Pure and
|
|
119
|
+
* deterministic. Matching is exact on identifier value; identifier collisions
|
|
120
|
+
* across kinds are disambiguated by `targetKind`. Self-references are dropped.
|
|
121
|
+
*/
|
|
122
|
+
export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): ReconstructedEdges {
|
|
123
|
+
// Identity index: identifier value → the node(s) that own it.
|
|
124
|
+
const index = new Map<string, Array<{ id: string; kind: string }>>();
|
|
125
|
+
const add = (value: string, id: string, kind: string) => {
|
|
126
|
+
(index.get(value) ?? index.set(value, []).get(value)!).push({ id, kind });
|
|
127
|
+
};
|
|
128
|
+
for (const node of nodes) {
|
|
129
|
+
// Every node is identified by its own logical id — so references resolved to
|
|
130
|
+
// a logical id (e.g. a CloudFormation `{Ref: LogicalId}` from exportResources,
|
|
131
|
+
// #784) match directly — plus its physical id and any catalog identity attrs.
|
|
132
|
+
add(node.id, node.id, node.kind);
|
|
133
|
+
if (node.physicalId) add(node.physicalId, node.id, node.kind);
|
|
134
|
+
for (const rule of catalog.identities) {
|
|
135
|
+
if (rule.kind !== node.kind) continue;
|
|
136
|
+
for (const p of rule.ids) for (const v of readPath(node.attrs, p)) add(v, node.id, node.kind);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const edges: IREdge[] = [];
|
|
141
|
+
const containment: ContainmentPair[] = [];
|
|
142
|
+
const dangling: DanglingRef[] = [];
|
|
143
|
+
const seenEdge = new Set<string>();
|
|
144
|
+
const seenCont = new Set<string>();
|
|
145
|
+
|
|
146
|
+
for (const node of nodes) {
|
|
147
|
+
for (const rule of catalog.refs) {
|
|
148
|
+
if (rule.from !== node.kind) continue;
|
|
149
|
+
for (const value of readPath(node.attrs, rule.path)) {
|
|
150
|
+
const candidates = index.get(value) ?? [];
|
|
151
|
+
const match = rule.targetKind ? candidates.find((c) => c.kind === rule.targetKind) : candidates[0];
|
|
152
|
+
if (!match) {
|
|
153
|
+
dangling.push({ from: node.id, path: rule.path, value, ...(rule.targetKind ? { targetKind: rule.targetKind } : {}) });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (match.id === node.id) continue; // self-reference (e.g. an SG rule to its own group)
|
|
157
|
+
if (rule.relation === "containment") {
|
|
158
|
+
const k = `${node.id}|${match.id}`;
|
|
159
|
+
if (seenCont.has(k)) continue;
|
|
160
|
+
seenCont.add(k);
|
|
161
|
+
containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
|
|
162
|
+
} else {
|
|
163
|
+
const via = rule.label ?? rule.path;
|
|
164
|
+
const k = `${node.id}|${match.id}|${via}`;
|
|
165
|
+
if (seenEdge.has(k)) continue;
|
|
166
|
+
seenEdge.add(k);
|
|
167
|
+
edges.push({ from: node.id, to: match.id, kind: "ref", viaAttr: via });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
edges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`));
|
|
174
|
+
containment.sort((a, b) => `${a.child}|${a.parent}`.localeCompare(`${b.child}|${b.parent}`));
|
|
175
|
+
dangling.sort((a, b) => `${a.from}|${a.path}|${a.value}`.localeCompare(`${b.from}|${b.path}|${b.value}`));
|
|
176
|
+
|
|
177
|
+
return { edges, containment, dangling };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Invert containment pairs into grouping metadata (#779): container node id → the
|
|
182
|
+
* node ids directly inside it. The result is the `IRGroups.byContainer` shape a
|
|
183
|
+
* renderer draws as boundary boxes. It represents the full nesting *flatly* — a
|
|
184
|
+
* subnet is both a member of its VPC's entry and a key with its own members —
|
|
185
|
+
* so a boundary-box renderer recurses it (VPC ⊃ subnet ⊃ resources). Sorted for
|
|
186
|
+
* determinism.
|
|
187
|
+
*/
|
|
188
|
+
export function containmentGroups(pairs: ContainmentPair[]): Record<string, string[]> {
|
|
189
|
+
const byParent = new Map<string, Set<string>>();
|
|
190
|
+
for (const { child, parent } of pairs) {
|
|
191
|
+
(byParent.get(parent) ?? byParent.set(parent, new Set()).get(parent)!).add(child);
|
|
192
|
+
}
|
|
193
|
+
const out: Record<string, string[]> = {};
|
|
194
|
+
for (const parent of [...byParent.keys()].sort()) out[parent] = [...byParent.get(parent)!].sort();
|
|
195
|
+
return out;
|
|
196
|
+
}
|
package/src/lexicon.ts
CHANGED
|
@@ -9,6 +9,11 @@ import type { CompletionContext, CompletionItem, HoverContext, HoverInfo, CodeAc
|
|
|
9
9
|
import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
|
|
10
10
|
import type { DriverComponent } from "./components/driver";
|
|
11
11
|
import type { RuleMeta } from "./audit/catalog";
|
|
12
|
+
import type { ReferenceCatalog } from "./graph-refs";
|
|
13
|
+
|
|
14
|
+
// Re-exported so lexicons can author a reference catalog (#778) from the same
|
|
15
|
+
// `@intentius/chant/lexicon` entry they import the plugin contract from.
|
|
16
|
+
export type { ReferenceCatalog, IdentityRule, RefRule } from "./graph-refs";
|
|
12
17
|
|
|
13
18
|
/**
|
|
14
19
|
* Manifest for a packaged lexicon — metadata embedded in the tarball.
|
|
@@ -369,6 +374,26 @@ export interface LexiconPlugin {
|
|
|
369
374
|
owned?: boolean;
|
|
370
375
|
}): Promise<Record<string, ResourceMetadata>>;
|
|
371
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Reference catalog for live edge reconstruction (#778). Declares how this
|
|
379
|
+
* lexicon's observed resources reference each other — an identity map (which
|
|
380
|
+
* attrs identify each kind) plus reference rules (which attr paths point at
|
|
381
|
+
* other resources, as a peer edge or containment). Consumed by
|
|
382
|
+
* `chant graph --live` to turn a bag of live nodes into a graph. Data, not a
|
|
383
|
+
* method. Opt-in.
|
|
384
|
+
*/
|
|
385
|
+
referenceCatalog?: ReferenceCatalog;
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Enrich live IR node attributes for edge reconstruction (#784). Returns
|
|
389
|
+
* `nodeId → attributes` with cross-resource references resolved to the
|
|
390
|
+
* referenced node id, so the reference resolver ({@link referenceCatalog}) can
|
|
391
|
+
* match them. Opt-in — for lexicons whose `describeResources` metadata is too
|
|
392
|
+
* thin to carry references (e.g. AWS CloudFormation, where it's sourced from
|
|
393
|
+
* the fuller `exportResources` config).
|
|
394
|
+
*/
|
|
395
|
+
enrichLiveAttrs?(options: { environment: string; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
|
|
396
|
+
|
|
372
397
|
/**
|
|
373
398
|
* List runtime artifacts in the given environment. Opt-in.
|
|
374
399
|
*
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { observeResources } from "./observe";
|
|
3
|
+
import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
|
|
4
|
+
import type { BuildResult } from "../build";
|
|
5
|
+
|
|
6
|
+
function mockBuild(): BuildResult {
|
|
7
|
+
return {
|
|
8
|
+
outputs: new Map<string, string>([["aws", "{}"]]),
|
|
9
|
+
entities: new Map([
|
|
10
|
+
["web-vpc", { lexicon: "aws", entityType: "AWS::EC2::VPC", props: {} }],
|
|
11
|
+
]),
|
|
12
|
+
errors: [],
|
|
13
|
+
} as unknown as BuildResult;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function awsPlugin(
|
|
17
|
+
describe: (opts: { owned?: boolean; entityNames: string[] }) => Record<string, ResourceMetadata>,
|
|
18
|
+
): ObservationLexicon {
|
|
19
|
+
return {
|
|
20
|
+
name: "aws",
|
|
21
|
+
serializer: {} as ObservationLexicon["serializer"],
|
|
22
|
+
describeResources: async (opts) => describe(opts),
|
|
23
|
+
} as ObservationLexicon;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("observeResources", () => {
|
|
27
|
+
it("collects each plugin's resources as observations, defaulting owned=true", async () => {
|
|
28
|
+
let sawOwned: boolean | undefined;
|
|
29
|
+
const plugins = [
|
|
30
|
+
awsPlugin(({ owned }) => {
|
|
31
|
+
sawOwned = owned;
|
|
32
|
+
return { "web-vpc": { type: "AWS::EC2::VPC", status: "CREATE_COMPLETE", physicalId: "vpc-1", ownership: "owned" } };
|
|
33
|
+
}),
|
|
34
|
+
];
|
|
35
|
+
const { observations, errors } = await observeResources("prod", plugins, mockBuild());
|
|
36
|
+
expect(sawOwned).toBe(true); // managed-only
|
|
37
|
+
expect(errors).toEqual([]);
|
|
38
|
+
expect(observations).toHaveLength(1);
|
|
39
|
+
expect(observations[0].lexicon).toBe("aws");
|
|
40
|
+
expect(Object.keys(observations[0].resources)).toEqual(["web-vpc"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("passes the declared entity names for scoping", async () => {
|
|
44
|
+
let names: string[] = [];
|
|
45
|
+
const plugins = [awsPlugin(({ entityNames }) => { names = entityNames; return {}; })];
|
|
46
|
+
await observeResources("prod", plugins, mockBuild());
|
|
47
|
+
expect(names).toEqual(["web-vpc"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("collects a throwing plugin into errors instead of failing the whole graph", async () => {
|
|
51
|
+
const plugins = [
|
|
52
|
+
awsPlugin(() => { throw new Error("access denied"); }),
|
|
53
|
+
];
|
|
54
|
+
const { observations, errors } = await observeResources("prod", plugins, mockBuild());
|
|
55
|
+
expect(observations).toEqual([]);
|
|
56
|
+
expect(errors).toEqual(["aws: access denied"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("skips plugins with no describeResources and drops empty results", async () => {
|
|
60
|
+
const empty = awsPlugin(() => ({}));
|
|
61
|
+
const noObserve = { name: "gitlab", serializer: {} } as unknown as ObservationLexicon;
|
|
62
|
+
const { observations } = await observeResources("prod", [empty, noObserve], mockBuild());
|
|
63
|
+
expect(observations).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live observation without persistence — call each plugin's `describeResources()`
|
|
3
|
+
* for an environment and collect the results as `LiveObservation[]`, ready to
|
|
4
|
+
* project into the graph IR (`buildLiveGraphIr`, ../graph-ir.ts) for
|
|
5
|
+
* `chant graph --live`.
|
|
6
|
+
*
|
|
7
|
+
* This is the read half of what `takeSnapshot` (./snapshot.ts) does before it
|
|
8
|
+
* validates + writes to git: same per-plugin build-output/entities assembly, no
|
|
9
|
+
* side effects. Snapshotting keeps its own copy for now; a future refactor can
|
|
10
|
+
* fold both onto this primitive.
|
|
11
|
+
*/
|
|
12
|
+
import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
|
|
13
|
+
import type { BuildResult } from "../build";
|
|
14
|
+
import type { SerializerResult } from "../serializer";
|
|
15
|
+
import type { LiveObservation } from "../graph-ir";
|
|
16
|
+
|
|
17
|
+
export interface ObserveResult {
|
|
18
|
+
observations: LiveObservation[];
|
|
19
|
+
warnings: string[];
|
|
20
|
+
errors: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Query every plugin that implements `describeResources` for its resources in
|
|
25
|
+
* `environment`. `owned` (default true for the managed-only diagram, epic #776)
|
|
26
|
+
* restricts to resources carrying chant's ownership marker; a lexicon with no
|
|
27
|
+
* marker channel logs and returns everything (its own contract). Plugins that
|
|
28
|
+
* throw are collected into `errors` and skipped — one failing lexicon never
|
|
29
|
+
* sinks the whole graph.
|
|
30
|
+
*/
|
|
31
|
+
export async function observeResources(
|
|
32
|
+
environment: string,
|
|
33
|
+
plugins: ObservationLexicon[],
|
|
34
|
+
buildResult: BuildResult,
|
|
35
|
+
opts?: { owned?: boolean },
|
|
36
|
+
): Promise<ObserveResult> {
|
|
37
|
+
const owned = opts?.owned ?? true;
|
|
38
|
+
const observations: LiveObservation[] = [];
|
|
39
|
+
const warnings: string[] = [];
|
|
40
|
+
const errors: string[] = [];
|
|
41
|
+
|
|
42
|
+
for (const plugin of plugins) {
|
|
43
|
+
if (!plugin.describeResources) continue;
|
|
44
|
+
|
|
45
|
+
// Serialized build output + declared entities for this lexicon — the scope
|
|
46
|
+
// describeResources needs to know what to look for.
|
|
47
|
+
const rawOutput = buildResult.outputs.get(plugin.name);
|
|
48
|
+
const buildOutput =
|
|
49
|
+
rawOutput === undefined
|
|
50
|
+
? ""
|
|
51
|
+
: typeof rawOutput === "string"
|
|
52
|
+
? rawOutput
|
|
53
|
+
: (rawOutput as SerializerResult).primary;
|
|
54
|
+
|
|
55
|
+
const entityNames: string[] = [];
|
|
56
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
57
|
+
for (const [name, entity] of buildResult.entities) {
|
|
58
|
+
if (entity.lexicon !== plugin.name) continue;
|
|
59
|
+
entityNames.push(name);
|
|
60
|
+
entities.set(name, {
|
|
61
|
+
entityType: entity.entityType,
|
|
62
|
+
props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const resources: Record<string, ResourceMetadata> = await plugin.describeResources({
|
|
68
|
+
environment,
|
|
69
|
+
buildOutput,
|
|
70
|
+
entityNames,
|
|
71
|
+
entities,
|
|
72
|
+
owned,
|
|
73
|
+
});
|
|
74
|
+
if (Object.keys(resources).length > 0) {
|
|
75
|
+
observations.push({ lexicon: plugin.name, resources });
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
errors.push(`${plugin.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { observations, warnings, errors };
|
|
83
|
+
}
|
package/src/op/builders.ts
CHANGED
|
@@ -257,6 +257,27 @@ export const azDelete = (templatePath: string, opts?: Record<string, unknown>):
|
|
|
257
257
|
return activity("azDelete", { templatePath, ...args }, profile ?? "longInfra");
|
|
258
258
|
};
|
|
259
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Deploy a built CloudFormation template by calling the CloudFormation API
|
|
262
|
+
* directly (create-or-update + poll) — the direct twin of {@link azApply} /
|
|
263
|
+
* {@link gcpApply} for AWS, targeting a local Floci emulator or real AWS by
|
|
264
|
+
* endpoint override. Speaks the CFN API over HTTP rather than shelling `aws`
|
|
265
|
+
* (that path is still `nativeApply({ target: "cloudformation" })`). Provided by
|
|
266
|
+
* the aws lexicon; loaded when the project lists `aws`. Defaults to the
|
|
267
|
+
* `longInfra` profile. `opts` requires `stackName`; accepts `endpoint`, `region`,
|
|
268
|
+
* `capabilities`, `timeoutMs`, `intervalMs`.
|
|
269
|
+
*/
|
|
270
|
+
export const awsApply = (templatePath: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
271
|
+
const { args, profile } = takeProfile(opts);
|
|
272
|
+
return activity("awsApply", { templatePath, ...args }, profile ?? "longInfra");
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** Delete a CloudFormation stack — the inverse of {@link awsApply}. Defaults to the `longInfra` profile (override via `opts.profile`). `opts` requires `stackName`. */
|
|
276
|
+
export const awsDelete = (templatePath: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
277
|
+
const { args, profile } = takeProfile(opts);
|
|
278
|
+
return activity("awsDelete", { templatePath, ...args }, profile ?? "longInfra");
|
|
279
|
+
};
|
|
280
|
+
|
|
260
281
|
/**
|
|
261
282
|
* Apply chant's built GCP (CNRM) resources directly to the GCS REST API,
|
|
262
283
|
* targeting a local floci-gcp emulator or real GCP by endpoint override — the
|
|
@@ -277,6 +298,120 @@ export const gcpDelete = (manifestPath: string, opts?: Record<string, unknown>):
|
|
|
277
298
|
return activity("gcpDelete", { manifestPath, ...args }, profile ?? "longInfra");
|
|
278
299
|
};
|
|
279
300
|
|
|
301
|
+
// ── Sprites (#762) — imperative, checkpointable sandbox steps ──────────────────
|
|
302
|
+
//
|
|
303
|
+
// The builder and the executor-side activity function share a name (e.g.
|
|
304
|
+
// `spriteCreate`), and that is intentional: the builder here returns an
|
|
305
|
+
// `activity("spriteCreate", ...)` step; `loadActivities` loads the function of
|
|
306
|
+
// the same name from the temporal lexicon to run the HTTP. They live in
|
|
307
|
+
// different modules and are both exported — exactly how `flapsUp`/`flapsDown`
|
|
308
|
+
// already work. Op files import these builders; the executor resolves the
|
|
309
|
+
// function by name. Endpoint override + bearer are read by the activity from
|
|
310
|
+
// `SPRITES_BASE_URL` / `SPRITES_API_TOKEN` (or an explicit `endpoint` arg).
|
|
311
|
+
|
|
312
|
+
/** Create a sprite with a caller-chosen `name` (used as its id). Defaults to the `longInfra` profile (override via `profile`). */
|
|
313
|
+
export const spriteCreate = (args: {
|
|
314
|
+
name: string;
|
|
315
|
+
image?: string;
|
|
316
|
+
size?: string;
|
|
317
|
+
policy?: unknown;
|
|
318
|
+
endpoint?: string;
|
|
319
|
+
token?: string;
|
|
320
|
+
profile?: ActivityStep["profile"];
|
|
321
|
+
}): ActivityStep => {
|
|
322
|
+
const { profile, ...rest } = args;
|
|
323
|
+
return activity("spriteCreate", rest, profile ?? "longInfra");
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** Run a command in a sprite; a non-zero exit fails the step. Defaults to the `longInfra` profile (override via `profile`). */
|
|
327
|
+
export const spriteExec = (args: {
|
|
328
|
+
id: string;
|
|
329
|
+
cmd: string;
|
|
330
|
+
timeoutMs?: number;
|
|
331
|
+
endpoint?: string;
|
|
332
|
+
token?: string;
|
|
333
|
+
profile?: ActivityStep["profile"];
|
|
334
|
+
}): ActivityStep => {
|
|
335
|
+
const { profile, ...rest } = args;
|
|
336
|
+
return activity("spriteExec", rest, profile ?? "longInfra");
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** Checkpoint a sprite under a caller-chosen `comment` (the transactional boundary). Defaults to the `longInfra` profile (override via `profile`). */
|
|
340
|
+
export const spriteCheckpoint = (args: {
|
|
341
|
+
id: string;
|
|
342
|
+
comment?: string;
|
|
343
|
+
endpoint?: string;
|
|
344
|
+
token?: string;
|
|
345
|
+
profile?: ActivityStep["profile"];
|
|
346
|
+
}): ActivityStep => {
|
|
347
|
+
const { profile, ...rest } = args;
|
|
348
|
+
return activity("spriteCheckpoint", rest, profile ?? "longInfra");
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Restore a sprite — the checkpoint-as-compensation step (S5). Target an
|
|
353
|
+
* explicit `checkpoint` id, or the newest checkpoint carrying `comment`, or (with
|
|
354
|
+
* neither) the newest checkpoint overall. Defaults to the `longInfra` profile
|
|
355
|
+
* (override via `profile`).
|
|
356
|
+
*/
|
|
357
|
+
export const spriteRestore = (args: {
|
|
358
|
+
id: string;
|
|
359
|
+
checkpoint?: string;
|
|
360
|
+
comment?: string;
|
|
361
|
+
endpoint?: string;
|
|
362
|
+
token?: string;
|
|
363
|
+
profile?: ActivityStep["profile"];
|
|
364
|
+
}): ActivityStep => {
|
|
365
|
+
const { profile, ...rest } = args;
|
|
366
|
+
return activity("spriteRestore", rest, profile ?? "longInfra");
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/** List a sprite's checkpoints (`[{ id, comment, create_time, is_auto }]`). Defaults to the `fastIdempotent` profile (override via `profile`). */
|
|
370
|
+
export const listCheckpoints = (args: {
|
|
371
|
+
id: string;
|
|
372
|
+
endpoint?: string;
|
|
373
|
+
token?: string;
|
|
374
|
+
profile?: ActivityStep["profile"];
|
|
375
|
+
}): ActivityStep => {
|
|
376
|
+
const { profile, ...rest } = args;
|
|
377
|
+
return activity("listCheckpoints", rest, profile ?? "fastIdempotent");
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/** Destroy a sprite (idempotent). Defaults to the `fastIdempotent` profile (override via `profile`). */
|
|
381
|
+
export const spriteDestroy = (args: {
|
|
382
|
+
id: string;
|
|
383
|
+
endpoint?: string;
|
|
384
|
+
token?: string;
|
|
385
|
+
profile?: ActivityStep["profile"];
|
|
386
|
+
}): ActivityStep => {
|
|
387
|
+
const { profile, ...rest } = args;
|
|
388
|
+
return activity("spriteDestroy", rest, profile ?? "fastIdempotent");
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Boot a local spritzer (Fly Sprites API emulator) in Docker — the typed twin of
|
|
393
|
+
* `flociGcpUp` for Sprites. Resolves to the `spritesUp` activity. Defaults to the
|
|
394
|
+
* `longInfra` profile (the image may pull); override via `profile`.
|
|
395
|
+
*/
|
|
396
|
+
export const spritesUp = (args: {
|
|
397
|
+
name?: string;
|
|
398
|
+
port?: number;
|
|
399
|
+
image?: string;
|
|
400
|
+
profile?: ActivityStep["profile"];
|
|
401
|
+
} = {}): ActivityStep => {
|
|
402
|
+
const { profile, ...rest } = args;
|
|
403
|
+
return activity("spritesUp", rest, profile ?? "longInfra");
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/** Stop and remove the local spritzer container. Resolves to the `spritesDown` activity. Defaults to the `fastIdempotent` profile (override via `profile`). */
|
|
407
|
+
export const spritesDown = (args: {
|
|
408
|
+
name?: string;
|
|
409
|
+
profile?: ActivityStep["profile"];
|
|
410
|
+
} = {}): ActivityStep => {
|
|
411
|
+
const { profile, ...rest } = args;
|
|
412
|
+
return activity("spritesDown", rest, profile ?? "fastIdempotent");
|
|
413
|
+
};
|
|
414
|
+
|
|
280
415
|
/**
|
|
281
416
|
* Gate an apply on organizational policy: build the project and run its
|
|
282
417
|
* `lint.policies` over the resolved resources, blocking the workflow on any
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { emulatorLifecycle } from "./emulator-lifecycle";
|
|
3
|
+
|
|
4
|
+
describe("emulatorLifecycle command builders", () => {
|
|
5
|
+
const emu = emulatorLifecycle({
|
|
6
|
+
name: "chant-x",
|
|
7
|
+
image: "org/x:1.0",
|
|
8
|
+
containerPort: 4200,
|
|
9
|
+
healthPath: "/_x/health",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("runCommand uses defaults and maps host port to the container port", () => {
|
|
13
|
+
expect(emu.runCommand()).toBe("docker run -d --rm --name chant-x -p 4200:4200 org/x:1.0");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("runCommand honors name/port/image overrides (host port maps to containerPort)", () => {
|
|
17
|
+
expect(emu.runCommand({ name: "x2", port: 4599, image: "org/x:2.0" })).toBe(
|
|
18
|
+
"docker run -d --rm --name x2 -p 4599:4200 org/x:2.0",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("spec.runArgs and per-call extraArgs precede the image", () => {
|
|
23
|
+
const e = emulatorLifecycle({
|
|
24
|
+
name: "n",
|
|
25
|
+
image: "img:1",
|
|
26
|
+
containerPort: 80,
|
|
27
|
+
healthPath: "/h",
|
|
28
|
+
runArgs: ["--pull", "always"],
|
|
29
|
+
});
|
|
30
|
+
expect(e.runCommand({ extraArgs: ["-v", "/sock:/sock"] })).toBe(
|
|
31
|
+
"docker run -d --rm --name n -p 80:80 --pull always -v /sock:/sock img:1",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("exists / rm / health / endpoint", () => {
|
|
36
|
+
expect(emu.existsCommand("n")).toBe("docker ps -q -f name=n");
|
|
37
|
+
expect(emu.rmCommand("n")).toBe("docker rm -f n");
|
|
38
|
+
expect(emu.healthUrl(4599)).toBe("http://localhost:4599/_x/health");
|
|
39
|
+
expect(emu.endpoint(4599)).toBe("http://localhost:4599");
|
|
40
|
+
});
|
|
41
|
+
});
|