@intentius/chant 0.1.15 → 0.1.17
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/package.json +1 -1
- package/src/cli/commands/describe.test.ts +75 -0
- package/src/cli/commands/describe.ts +141 -0
- package/src/cli/handlers/lifecycle.test.ts +43 -0
- package/src/cli/handlers/lifecycle.ts +20 -6
- package/src/cli/handlers/misc.ts +30 -0
- package/src/cli/main.ts +16 -4
- package/src/cli/registry.ts +2 -0
- package/src/config.ts +10 -0
- package/src/op/activity-registry.ts +8 -1
- package/src/op/builders.ts +53 -17
- package/src/op/local-executor.test.ts +16 -0
- package/src/op/op.test.ts +37 -0
- package/src/op/types.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { describeCommand } from "./describe";
|
|
5
|
+
|
|
6
|
+
// The #203 layered-config example: one WebApp instantiated for dev/staging/prod
|
|
7
|
+
// from layered (spread) config. describe v1 projects the effective, resolved
|
|
8
|
+
// config for one named component over it.
|
|
9
|
+
const exampleSrc = resolve(
|
|
10
|
+
fileURLToPath(import.meta.url),
|
|
11
|
+
"../../../../../../lexicons/k8s/examples/layered-config/src",
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
function opts(component: string, format: "text" | "json" = "json") {
|
|
15
|
+
return { component, path: exampleSrc, format };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("describeCommand (resolved view, v1)", () => {
|
|
19
|
+
test("describes a composite instance by its export name (grouped resources)", async () => {
|
|
20
|
+
const r = await describeCommand(opts("prodApp"));
|
|
21
|
+
expect(r.success).toBe(true);
|
|
22
|
+
expect(r.composite).toBe(true);
|
|
23
|
+
const names = r.resources.map((x) => x.name).sort();
|
|
24
|
+
expect(names).toEqual([
|
|
25
|
+
"prodAppDeployment",
|
|
26
|
+
"prodAppIngress",
|
|
27
|
+
"prodAppPdb",
|
|
28
|
+
"prodAppService",
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("shows the effective, fully-resolved props (post-merge)", async () => {
|
|
33
|
+
const r = await describeCommand(opts("prodApp"));
|
|
34
|
+
const dep = r.resources.find((x) => x.entityType.endsWith("Deployment"))!;
|
|
35
|
+
const props = dep.props as { spec: { replicas: number }; metadata: { labels: Record<string, string> } };
|
|
36
|
+
// prod overrode replicas; base labels merged with prod-only ones.
|
|
37
|
+
expect(props.spec.replicas).toBe(6);
|
|
38
|
+
expect(props.metadata.labels["app.kubernetes.io/part-of"]).toBe("acme-web"); // base layer
|
|
39
|
+
expect(props.metadata.labels["acme.io/env"]).toBe("prod"); // env layer
|
|
40
|
+
expect(props.metadata.labels["acme.io/tier"]).toBe("critical"); // prod-only
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("environments resolve to different effective configs", async () => {
|
|
44
|
+
const dev = await describeCommand(opts("devApp"));
|
|
45
|
+
const devDep = dev.resources.find((x) => x.entityType.endsWith("Deployment"))!;
|
|
46
|
+
const devProps = devDep.props as { spec: { replicas: number }; metadata: { labels: Record<string, string> } };
|
|
47
|
+
expect(devProps.spec.replicas).toBe(2); // base default, not overridden in dev
|
|
48
|
+
expect(devProps.metadata.labels["acme.io/env"]).toBe("dev");
|
|
49
|
+
expect(devProps.metadata.labels["acme.io/tier"]).toBeUndefined(); // prod-only key absent
|
|
50
|
+
// dev declares no ingress; prod does.
|
|
51
|
+
expect(dev.resources.some((x) => x.entityType.endsWith("Ingress"))).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("describes a single declarable by exact name (not a composite)", async () => {
|
|
55
|
+
const r = await describeCommand(opts("devAppService"));
|
|
56
|
+
expect(r.success).toBe(true);
|
|
57
|
+
expect(r.composite).toBe(false);
|
|
58
|
+
expect(r.resources).toHaveLength(1);
|
|
59
|
+
expect(r.resources[0].entityType).toBe("K8s::Core::Service");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("unknown component fails with the known-component list", async () => {
|
|
63
|
+
const r = await describeCommand(opts("doesNotExist"));
|
|
64
|
+
expect(r.success).toBe(false);
|
|
65
|
+
expect(r.output).toContain("No component");
|
|
66
|
+
expect(r.output).toContain("prodAppDeployment");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("text format renders the component header and props", async () => {
|
|
70
|
+
const r = await describeCommand(opts("prodApp", "text"));
|
|
71
|
+
expect(r.output).toContain("prodApp");
|
|
72
|
+
expect(r.output).toContain("K8s::Apps::Deployment");
|
|
73
|
+
expect(r.output).toContain("web-prod");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
import { discover } from "../../discovery/index";
|
|
3
|
+
import type { Declarable } from "../../declarable";
|
|
4
|
+
import { formatSuccess, formatBold } from "../format";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `chant describe <component>` — project the effective, fully-resolved
|
|
8
|
+
* configuration for one named component, offline (source only; no live calls).
|
|
9
|
+
*
|
|
10
|
+
* `chant list` shows inventory and `chant graph` shows edges; neither shows the
|
|
11
|
+
* resolved property bag for a single thing. `describe` does: for a layered
|
|
12
|
+
* config (see the layered-config guide) it answers "what is the effective config
|
|
13
|
+
* for this component, after every spread/override?" without merging the layers
|
|
14
|
+
* by hand.
|
|
15
|
+
*/
|
|
16
|
+
export interface DescribeOptions {
|
|
17
|
+
/** The component to describe — a named export (declarable or composite instance). */
|
|
18
|
+
component: string;
|
|
19
|
+
/** Path to the infrastructure directory. */
|
|
20
|
+
path: string;
|
|
21
|
+
/** Output format. */
|
|
22
|
+
format: "text" | "json";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One resolved resource produced by the component. */
|
|
26
|
+
export interface DescribedResource {
|
|
27
|
+
name: string;
|
|
28
|
+
lexicon: string;
|
|
29
|
+
entityType: string;
|
|
30
|
+
kind: string;
|
|
31
|
+
/** The resolved (post-merge, post-composite-expansion) property bag. */
|
|
32
|
+
props: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DescribeResult {
|
|
36
|
+
success: boolean;
|
|
37
|
+
component: string;
|
|
38
|
+
/** True when the component is a composite instance (≥1 produced resource by prefix). */
|
|
39
|
+
composite: boolean;
|
|
40
|
+
resources: DescribedResource[];
|
|
41
|
+
output: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read a discovered entity's resolved props (same accessor the build pipeline uses). */
|
|
45
|
+
function readProps(entity: Declarable): unknown {
|
|
46
|
+
return "props" in entity ? (entity as { props: unknown }).props : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Find the entities a component name refers to. A composite instance is
|
|
51
|
+
* flattened at discovery: `WebApp(x)` exported as `foo` becomes `fooDeployment`,
|
|
52
|
+
* `fooService`, … — so an exact-name miss falls back to grouping entities whose
|
|
53
|
+
* name is the export prefix followed by a capitalized result key.
|
|
54
|
+
*/
|
|
55
|
+
function matchComponent(
|
|
56
|
+
entities: Map<string, Declarable>,
|
|
57
|
+
component: string,
|
|
58
|
+
): { matched: Array<[string, Declarable]>; composite: boolean } {
|
|
59
|
+
const exact = entities.get(component);
|
|
60
|
+
if (exact) return { matched: [[component, exact]], composite: false };
|
|
61
|
+
|
|
62
|
+
const grouped = [...entities].filter(
|
|
63
|
+
([n]) =>
|
|
64
|
+
n.length > component.length &&
|
|
65
|
+
n.startsWith(component) &&
|
|
66
|
+
n.charAt(component.length) === n.charAt(component.length).toUpperCase() &&
|
|
67
|
+
n.charAt(component.length) !== n.charAt(component.length).toLowerCase(),
|
|
68
|
+
);
|
|
69
|
+
grouped.sort((a, b) => a[0].localeCompare(b[0]));
|
|
70
|
+
return { matched: grouped, composite: grouped.length > 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function describeCommand(options: DescribeOptions): Promise<DescribeResult> {
|
|
74
|
+
const infraPath = resolve(options.path);
|
|
75
|
+
const result = await discover(infraPath);
|
|
76
|
+
|
|
77
|
+
if (result.errors.length > 0) {
|
|
78
|
+
return {
|
|
79
|
+
success: false,
|
|
80
|
+
component: options.component,
|
|
81
|
+
composite: false,
|
|
82
|
+
resources: [],
|
|
83
|
+
output: result.errors.map((e) => e.message).join("\n"),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { matched, composite } = matchComponent(result.entities, options.component);
|
|
88
|
+
|
|
89
|
+
if (matched.length === 0) {
|
|
90
|
+
const known = [...result.entities.keys()].sort().join(", ");
|
|
91
|
+
return {
|
|
92
|
+
success: false,
|
|
93
|
+
component: options.component,
|
|
94
|
+
composite: false,
|
|
95
|
+
resources: [],
|
|
96
|
+
output: `No component "${options.component}" found.\nKnown components: ${known || "(none)"}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const resources: DescribedResource[] = matched.map(([name, decl]) => ({
|
|
101
|
+
name,
|
|
102
|
+
lexicon: decl.lexicon ?? "",
|
|
103
|
+
entityType: decl.entityType ?? "",
|
|
104
|
+
kind: decl.kind ?? "resource",
|
|
105
|
+
props: readProps(decl),
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
const output =
|
|
109
|
+
options.format === "json"
|
|
110
|
+
? JSON.stringify({ component: options.component, composite, resources }, null, 2)
|
|
111
|
+
: formatText(options.component, composite, resources);
|
|
112
|
+
|
|
113
|
+
return { success: true, component: options.component, composite, resources, output };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Human-readable effective-config view. */
|
|
117
|
+
function formatText(component: string, composite: boolean, resources: DescribedResource[]): string {
|
|
118
|
+
const lines: string[] = [];
|
|
119
|
+
const header = composite
|
|
120
|
+
? `${formatBold(component)} — composite instance, ${resources.length} resource(s)`
|
|
121
|
+
: `${formatBold(component)}`;
|
|
122
|
+
lines.push(header);
|
|
123
|
+
for (const r of resources) {
|
|
124
|
+
lines.push("");
|
|
125
|
+
lines.push(` ${formatBold(r.name)} (${r.entityType})`);
|
|
126
|
+
const propsJson = JSON.stringify(r.props ?? {}, null, 2)
|
|
127
|
+
.split("\n")
|
|
128
|
+
.map((l) => ` ${l}`)
|
|
129
|
+
.join("\n");
|
|
130
|
+
lines.push(propsJson);
|
|
131
|
+
}
|
|
132
|
+
return lines.join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Print a describe result to the console. */
|
|
136
|
+
export function printDescribeResult(result: DescribeResult): void {
|
|
137
|
+
if (result.output) console.log(result.output);
|
|
138
|
+
if (result.success) {
|
|
139
|
+
console.error(formatSuccess(`Described ${formatBold(result.component)}`));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { sep } from "node:path";
|
|
2
3
|
import { createMockPlugin, staticDescribeResources, staticListArtifacts } from "@intentius/chant-test-utils";
|
|
3
4
|
import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
|
|
4
5
|
import type { BuildResult } from "../../build";
|
|
@@ -85,6 +86,8 @@ describe("runLifecycleDiff --live", () => {
|
|
|
85
86
|
buildMock.mockReset();
|
|
86
87
|
fetchLifecycleMock.mockReset();
|
|
87
88
|
readSnapshotMock.mockReset();
|
|
89
|
+
loadChantConfigMock.mockReset();
|
|
90
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
88
91
|
});
|
|
89
92
|
|
|
90
93
|
test("surfaces drift between previous snapshot and live state", async () => {
|
|
@@ -124,6 +127,46 @@ describe("runLifecycleDiff --live", () => {
|
|
|
124
127
|
expect(output).toContain("UPDATE_COMPLETE");
|
|
125
128
|
});
|
|
126
129
|
|
|
130
|
+
test("builds from config.sourceDir on a mixed-layout project", async () => {
|
|
131
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
|
|
132
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
133
|
+
readSnapshotMock.mockResolvedValue(null);
|
|
134
|
+
loadChantConfigMock.mockResolvedValue({ config: { sourceDir: "src" } });
|
|
135
|
+
|
|
136
|
+
const plugins: LexiconPlugin[] = [
|
|
137
|
+
createMockPlugin({ name: "aws", describeResources: staticDescribeResources({}) }),
|
|
138
|
+
];
|
|
139
|
+
const exit = await runLifecycleDiff({
|
|
140
|
+
args: makeArgs({ path: "diff", extraPositional: "prod", extraPositional2: "aws", live: true }),
|
|
141
|
+
plugins,
|
|
142
|
+
serializers: plugins.map((p) => p.serializer),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(exit).toBe(0);
|
|
146
|
+
const builtPath = buildMock.mock.calls[0][0] as string;
|
|
147
|
+
expect(builtPath.endsWith(`${sep}src`)).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("--src overrides config.sourceDir for the build root", async () => {
|
|
151
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
|
|
152
|
+
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
153
|
+
readSnapshotMock.mockResolvedValue(null);
|
|
154
|
+
loadChantConfigMock.mockResolvedValue({ config: { sourceDir: "src" } });
|
|
155
|
+
|
|
156
|
+
const plugins: LexiconPlugin[] = [
|
|
157
|
+
createMockPlugin({ name: "aws", describeResources: staticDescribeResources({}) }),
|
|
158
|
+
];
|
|
159
|
+
const exit = await runLifecycleDiff({
|
|
160
|
+
args: makeArgs({ path: "diff", extraPositional: "prod", extraPositional2: "aws", live: true, src: "infra" }),
|
|
161
|
+
plugins,
|
|
162
|
+
serializers: plugins.map((p) => p.serializer),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(exit).toBe(0);
|
|
166
|
+
const builtPath = buildMock.mock.calls[0][0] as string;
|
|
167
|
+
expect(builtPath.endsWith(`${sep}infra`)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
127
170
|
test("warns and skips lexicons without describeResources", async () => {
|
|
128
171
|
buildMock.mockResolvedValue(makeBuildResult({ k8s: ["pod"] }));
|
|
129
172
|
fetchLifecycleMock.mockResolvedValue(undefined);
|
|
@@ -12,6 +12,20 @@ import type { LifecycleSnapshot } from "../../lifecycle/types";
|
|
|
12
12
|
import type { SerializerResult } from "../../serializer";
|
|
13
13
|
import type { ObservationLexicon, ResourceMetadata, ArtifactMetadata } from "../../lexicon";
|
|
14
14
|
import type { BuildResult } from "../../build";
|
|
15
|
+
import type { ParsedArgs } from "../registry";
|
|
16
|
+
import type { ChantConfig } from "../../config";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the build root for a lifecycle command. The project root (where
|
|
20
|
+
* chant.config.ts lives) is always ".", but the *build* can be scoped to a
|
|
21
|
+
* subdirectory so a mixed-layout project — chant `src/` next to app code with
|
|
22
|
+
* import side effects — only synthesizes its infra. Precedence: `--src` flag,
|
|
23
|
+
* then `config.sourceDir`, then "." (the root). Snapshot/diff/plan all use this
|
|
24
|
+
* so their build digests stay consistent.
|
|
25
|
+
*/
|
|
26
|
+
function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
|
|
27
|
+
return resolve(args.src ?? config.sourceDir ?? ".");
|
|
28
|
+
}
|
|
15
29
|
|
|
16
30
|
/**
|
|
17
31
|
* chant lifecycle snapshot <environment> [lexicon]
|
|
@@ -44,7 +58,7 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
|
|
|
44
58
|
const targetSerializers = targetPlugins.map((p) => p.serializer);
|
|
45
59
|
|
|
46
60
|
// Build first to get entity names and build output
|
|
47
|
-
const buildResult = await build(
|
|
61
|
+
const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
|
|
48
62
|
if (buildResult.errors.length > 0) {
|
|
49
63
|
console.error(formatError({ message: "Build failed — fix errors before taking a snapshot" }));
|
|
50
64
|
return 1;
|
|
@@ -149,9 +163,9 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
149
163
|
? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
|
|
150
164
|
: serializers;
|
|
151
165
|
|
|
152
|
-
// Build to get current state
|
|
153
|
-
const
|
|
154
|
-
const buildResult = await build(
|
|
166
|
+
// Build to get current state (from the configured source root, not necessarily ".")
|
|
167
|
+
const { config } = await loadChantConfig(resolve("."));
|
|
168
|
+
const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
|
|
155
169
|
if (buildResult.errors.length > 0) {
|
|
156
170
|
console.error(formatError({ message: "Build failed — fix errors before diffing" }));
|
|
157
171
|
return 1;
|
|
@@ -425,8 +439,8 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
425
439
|
? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
|
|
426
440
|
: serializers;
|
|
427
441
|
|
|
428
|
-
const
|
|
429
|
-
const buildResult = await build(
|
|
442
|
+
const { config } = await loadChantConfig(resolve("."));
|
|
443
|
+
const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
|
|
430
444
|
if (buildResult.errors.length > 0) {
|
|
431
445
|
console.error(formatError({ message: "Build failed — fix errors before planning" }));
|
|
432
446
|
return 1;
|
package/src/cli/handlers/misc.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { listCommand, printListResult } from "../commands/list";
|
|
2
|
+
import { describeCommand, printDescribeResult } from "../commands/describe";
|
|
2
3
|
import { importCommand, importFromLive, printImportResult } from "../commands/import";
|
|
3
4
|
import type { ResourceSelector } from "../../lexicon";
|
|
4
5
|
import { formatError, formatSuccess, formatWarning } from "../format";
|
|
@@ -21,6 +22,35 @@ export async function runList(ctx: CommandContext): Promise<number> {
|
|
|
21
22
|
return result.success ? 0 : 1;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
export async function runDescribe(ctx: CommandContext): Promise<number> {
|
|
26
|
+
const { args } = ctx;
|
|
27
|
+
// `chant describe <component> [path]` — component is the first positional
|
|
28
|
+
// (args.path), the optional project dir is the second (args.extraPositional).
|
|
29
|
+
const component = args.path;
|
|
30
|
+
if (!component || component === ".") {
|
|
31
|
+
console.error(formatError({
|
|
32
|
+
message: "Component is required: chant describe <component> [path]",
|
|
33
|
+
hint: "Run `chant list` to see component names.",
|
|
34
|
+
}));
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const describeFormat = (args.format || "text") as "text" | "json";
|
|
39
|
+
if (describeFormat !== "text" && describeFormat !== "json") {
|
|
40
|
+
console.error(formatError({ message: `Invalid format for describe: ${describeFormat}. Expected 'text' or 'json'.` }));
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = await describeCommand({
|
|
45
|
+
component,
|
|
46
|
+
path: args.extraPositional ?? ".",
|
|
47
|
+
format: describeFormat,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
printDescribeResult(result);
|
|
51
|
+
return result.success ? 0 : 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
export async function runImport(ctx: CommandContext): Promise<number> {
|
|
25
55
|
const { args } = ctx;
|
|
26
56
|
|
package/src/cli/main.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { runLint } from "./handlers/lint";
|
|
|
11
11
|
import { runDevGenerate, runDevPublish, runDevOnboard, runDevCheckLexicon, runDevUnknown } from "./handlers/dev";
|
|
12
12
|
import { runServeLsp, runServeMcp, runServeUnknown } from "./handlers/serve";
|
|
13
13
|
import { runInit, runInitLexicon } from "./handlers/init";
|
|
14
|
-
import { runList, runImport, runUpdate, runDoctor } from "./handlers/misc";
|
|
14
|
+
import { runList, runDescribe, runImport, runUpdate, runDoctor } from "./handlers/misc";
|
|
15
15
|
import { runMigrate } from "./handlers/migrate";
|
|
16
16
|
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecyclePlan, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
17
17
|
import { runGraph } from "./handlers/graph";
|
|
@@ -49,6 +49,7 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
49
49
|
useComposites: false,
|
|
50
50
|
reportFile: undefined,
|
|
51
51
|
skill: undefined,
|
|
52
|
+
src: undefined,
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
let i = 0;
|
|
@@ -111,6 +112,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
111
112
|
result.useComposites = true;
|
|
112
113
|
} else if (arg === "--skill") {
|
|
113
114
|
result.skill = args[++i];
|
|
115
|
+
} else if (arg === "--src") {
|
|
116
|
+
result.src = args[++i];
|
|
114
117
|
} else if (arg === "--local") {
|
|
115
118
|
result.local = true;
|
|
116
119
|
} else if (arg === "--temporal") {
|
|
@@ -151,6 +154,7 @@ Commands:
|
|
|
151
154
|
build Build infrastructure from specification files
|
|
152
155
|
lint Check specifications for issues
|
|
153
156
|
list List discovered entities
|
|
157
|
+
describe Show the effective config for one component
|
|
154
158
|
import Import external template into TypeScript
|
|
155
159
|
migrate <file> Translate a workflow between lexicons
|
|
156
160
|
(default: --from github --to gitlab)
|
|
@@ -227,6 +231,8 @@ Examples:
|
|
|
227
231
|
chant lint ./infra/ --watch
|
|
228
232
|
chant list ./infra/
|
|
229
233
|
chant list ./infra/ --format json
|
|
234
|
+
chant describe myComponent src/
|
|
235
|
+
chant describe myComponent src/ --format json
|
|
230
236
|
`);
|
|
231
237
|
}
|
|
232
238
|
|
|
@@ -262,6 +268,7 @@ const registry: CommandDef[] = [
|
|
|
262
268
|
{ name: "build", requiresPlugins: true, handler: runBuild },
|
|
263
269
|
{ name: "lint", handler: runLint },
|
|
264
270
|
{ name: "list", handler: runList },
|
|
271
|
+
{ name: "describe", handler: runDescribe },
|
|
265
272
|
{ name: "import", handler: runImport },
|
|
266
273
|
{ name: "migrate", handler: runMigrate },
|
|
267
274
|
{ name: "init", handler: runInit },
|
|
@@ -332,9 +339,14 @@ async function main(): Promise<void> {
|
|
|
332
339
|
process.exit(1);
|
|
333
340
|
}
|
|
334
341
|
|
|
335
|
-
// For compound commands (e.g. "run list"
|
|
336
|
-
//
|
|
337
|
-
|
|
342
|
+
// For compound commands (e.g. "run list", "lifecycle plan <env>"), the first
|
|
343
|
+
// positional is a subcommand argument — an environment, op, or lexicon name —
|
|
344
|
+
// not a project path. Plugins always load from the cwd; the handler reads its
|
|
345
|
+
// own positionals from args.extraPositional. Using extraPositional as the path
|
|
346
|
+
// here pointed plugin resolution at e.g. "./local" for `lifecycle plan local`,
|
|
347
|
+
// which then fell through to import-detection on an empty file set and failed
|
|
348
|
+
// with "No lexicon detected" even though chant.config.ts lists the lexicons.
|
|
349
|
+
const projectPath = match.compound ? "." : args.path;
|
|
338
350
|
const plugins = match.def.requiresPlugins
|
|
339
351
|
? await loadPluginsOrExit(projectPath)
|
|
340
352
|
: [];
|
package/src/cli/registry.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface ParsedArgs {
|
|
|
51
51
|
owned?: boolean;
|
|
52
52
|
/** `chant import --verbatim` — keep server-defaulted fields in live import */
|
|
53
53
|
verbatim?: boolean;
|
|
54
|
+
/** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
|
|
55
|
+
src?: string;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
package/src/config.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { OwnershipMarker } from "./ownership";
|
|
|
10
10
|
export const ChantConfigSchema = z.object({
|
|
11
11
|
lexicons: z.array(z.string().min(1)).optional(),
|
|
12
12
|
environments: z.array(z.string().min(1)).optional(),
|
|
13
|
+
sourceDir: z.string().min(1).optional(),
|
|
13
14
|
lint: z.record(z.string(), z.unknown()).optional(),
|
|
14
15
|
ownership: z.object({
|
|
15
16
|
stack: z.string().min(1).optional(),
|
|
@@ -30,6 +31,15 @@ export interface ChantConfig {
|
|
|
30
31
|
/** Environment names (e.g. ["staging", "prod"]) */
|
|
31
32
|
environments?: string[];
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Directory (relative to the project root) that holds the chant infrastructure
|
|
36
|
+
* source. Lifecycle commands (`snapshot`/`diff`/`plan`) build from here instead
|
|
37
|
+
* of the project root, so a mixed-layout project — chant `src/` alongside app
|
|
38
|
+
* code that has import side effects — can scope the build to just the infra.
|
|
39
|
+
* Defaults to "." (the project root). The `--src` flag overrides it.
|
|
40
|
+
*/
|
|
41
|
+
sourceDir?: string;
|
|
42
|
+
|
|
33
43
|
/** Lint configuration (rules, extends, overrides, plugins) */
|
|
34
44
|
lint?: LintConfig;
|
|
35
45
|
|
|
@@ -63,10 +63,17 @@ export interface ActivityProfile {
|
|
|
63
63
|
* Dynamically import the lexicon's `TEMPORAL_ACTIVITY_PROFILES` (pure data, no
|
|
64
64
|
* Temporal SDK). Returns an empty record if the lexicon is absent — the
|
|
65
65
|
* executor then falls back to built-in defaults per step.
|
|
66
|
+
*
|
|
67
|
+
* Imports the lexicon's `/config` entry, not its root index. `/config` is a
|
|
68
|
+
* side-effect-free data module; the root index pulls in the plugin, serializer,
|
|
69
|
+
* composites, and re-exported Op machinery, any of which could throw on load
|
|
70
|
+
* and make this silently return `{}` — which would drop every profiled step to
|
|
71
|
+
* the 5-minute default while the Temporal path (reading the same table) kept the
|
|
72
|
+
* declared timeout. Importing the narrow module keeps the two executors agreed.
|
|
66
73
|
*/
|
|
67
74
|
export async function loadProfiles(): Promise<Record<string, ActivityProfile>> {
|
|
68
75
|
try {
|
|
69
|
-
const spec = "@intentius/chant-lexicon-temporal";
|
|
76
|
+
const spec = "@intentius/chant-lexicon-temporal/config";
|
|
70
77
|
const mod = (await import(spec)) as { TEMPORAL_ACTIVITY_PROFILES?: Record<string, ActivityProfile> };
|
|
71
78
|
return mod.TEMPORAL_ACTIVITY_PROFILES ?? {};
|
|
72
79
|
} catch {
|
package/src/op/builders.ts
CHANGED
|
@@ -60,36 +60,72 @@ export function gate(
|
|
|
60
60
|
|
|
61
61
|
// ── Pre-built activity shortcuts ──────────────────────────────────────────────
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Pull an optional `profile` override out of an opts bag, returning the
|
|
65
|
+
* remaining keys (which become activity args) separately.
|
|
66
|
+
*
|
|
67
|
+
* Without this, a `profile` passed in opts would spread into the activity's
|
|
68
|
+
* **args** rather than set the step's `profile` — a silent no-op on the step's
|
|
69
|
+
* timeout. The activity then runs under the default profile, so a step the
|
|
70
|
+
* author tagged `longInfra` (20m) would still get the 5m default. Routing it
|
|
71
|
+
* here lets every shortcut accept a `profile` override that actually takes.
|
|
72
|
+
*/
|
|
73
|
+
function takeProfile(
|
|
74
|
+
opts: Record<string, unknown> | undefined,
|
|
75
|
+
): { args: Record<string, unknown>; profile?: ActivityStep["profile"] } {
|
|
76
|
+
if (!opts) return { args: {} };
|
|
77
|
+
const { profile, ...args } = opts as { profile?: ActivityStep["profile"] } & Record<string, unknown>;
|
|
78
|
+
return { args, profile };
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
/** Run `npm run build` (or `chant build`) in the given project directory. */
|
|
64
|
-
export const build = (path: string, opts?: Record<string, unknown>): ActivityStep =>
|
|
65
|
-
|
|
82
|
+
export const build = (path: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
83
|
+
const { args, profile } = takeProfile(opts);
|
|
84
|
+
return activity("chantBuild", { path, ...args }, profile);
|
|
85
|
+
};
|
|
66
86
|
|
|
67
|
-
/** Run `kubectl apply -f <manifest>`.
|
|
68
|
-
export const kubectlApply = (manifest: string, opts?: Record<string, unknown>): ActivityStep =>
|
|
69
|
-
|
|
87
|
+
/** Run `kubectl apply -f <manifest>`. Defaults to the `longInfra` profile (override via `opts.profile`). */
|
|
88
|
+
export const kubectlApply = (manifest: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
89
|
+
const { args, profile } = takeProfile(opts);
|
|
90
|
+
return activity("kubectlApply", { manifest, ...args }, profile ?? "longInfra");
|
|
91
|
+
};
|
|
70
92
|
|
|
71
|
-
/** Run `helm upgrade --install`.
|
|
93
|
+
/** Run `helm upgrade --install`. Defaults to the `longInfra` profile (override via `opts.profile`). */
|
|
72
94
|
export const helmInstall = (
|
|
73
95
|
name: string,
|
|
74
96
|
chart: string,
|
|
75
|
-
opts?: { values?: string; namespace?: string; [k: string]: unknown },
|
|
76
|
-
): ActivityStep =>
|
|
97
|
+
opts?: { values?: string; namespace?: string; profile?: ActivityStep["profile"]; [k: string]: unknown },
|
|
98
|
+
): ActivityStep => {
|
|
99
|
+
const { args, profile } = takeProfile(opts);
|
|
100
|
+
return activity("helmInstall", { name, chart, ...args }, profile ?? "longInfra");
|
|
101
|
+
};
|
|
77
102
|
|
|
78
|
-
/** Poll for stack readiness (kubectl rollout, CloudFormation complete, etc).
|
|
79
|
-
export const waitForStack = (name: string, opts?: Record<string, unknown>): ActivityStep =>
|
|
80
|
-
|
|
103
|
+
/** Poll for stack readiness (kubectl rollout, CloudFormation complete, etc). Defaults to the `k8sWait` profile (override via `opts.profile`). */
|
|
104
|
+
export const waitForStack = (name: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
105
|
+
const { args, profile } = takeProfile(opts);
|
|
106
|
+
return activity("waitForStack", { name, ...args }, profile ?? "k8sWait");
|
|
107
|
+
};
|
|
81
108
|
|
|
82
|
-
/** Trigger and wait for a GitLab CI pipeline to complete.
|
|
83
|
-
export const gitlabPipeline = (name: string, opts?: Record<string, unknown>): ActivityStep =>
|
|
84
|
-
|
|
109
|
+
/** Trigger and wait for a GitLab CI pipeline to complete. Defaults to the `longInfra` profile (override via `opts.profile`). */
|
|
110
|
+
export const gitlabPipeline = (name: string, opts?: Record<string, unknown>): ActivityStep => {
|
|
111
|
+
const { args, profile } = takeProfile(opts);
|
|
112
|
+
return activity("gitlabPipeline", { name, ...args }, profile ?? "longInfra");
|
|
113
|
+
};
|
|
85
114
|
|
|
86
115
|
/** Take a chant lifecycle snapshot for the given environment. */
|
|
87
116
|
export const lifecycleSnapshot = (env: string): ActivityStep =>
|
|
88
117
|
activity("lifecycleSnapshot", { env });
|
|
89
118
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Run an arbitrary shell command. Tag long-running commands with a `profile`
|
|
121
|
+
* (e.g. `longInfra` for a multi-GB image push) so they get the right
|
|
122
|
+
* start-to-close timeout under both the local executor and Temporal.
|
|
123
|
+
*/
|
|
124
|
+
export const shell = (
|
|
125
|
+
cmd: string,
|
|
126
|
+
opts?: { env?: Record<string, string>; profile?: ActivityStep["profile"] },
|
|
127
|
+
): ActivityStep =>
|
|
128
|
+
activity("shellCmd", { cmd, ...(opts?.env ? { env: opts.env } : {}) }, opts?.profile);
|
|
93
129
|
|
|
94
130
|
/** Run `chant teardown` in the given project directory. Uses `longInfra` profile. */
|
|
95
131
|
export const teardown = (path: string): ActivityStep =>
|
|
@@ -110,6 +110,22 @@ describe("runOpLocally — retry + timeout", () => {
|
|
|
110
110
|
const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "always" }] }] });
|
|
111
111
|
await expect(runOpLocally(config, new Map([["always", always]]), PROFILES)).rejects.toBeInstanceOf(OpRunFailure);
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
test("honors a step's non-default profile timeout (not the default)", async () => {
|
|
115
|
+
// The default profile would time out at 50ms; the step is tagged longInfra,
|
|
116
|
+
// which gives it room. Guards the bug where a profiled step silently got the
|
|
117
|
+
// default cap (local vs --temporal disagreement).
|
|
118
|
+
const slow: ActivityFn = async () => { await new Promise((r) => setTimeout(r, 150)); return "done"; };
|
|
119
|
+
const profiles = {
|
|
120
|
+
fastIdempotent: { startToCloseTimeout: "50ms", retry: { maximumAttempts: 1 } },
|
|
121
|
+
longInfra: { startToCloseTimeout: "5m", retry: { maximumAttempts: 1 } },
|
|
122
|
+
};
|
|
123
|
+
const config = op({
|
|
124
|
+
phases: [{ name: "P", steps: [{ kind: "activity", fn: "slow", profile: "longInfra" }] }],
|
|
125
|
+
});
|
|
126
|
+
const result = await runOpLocally(config, new Map([["slow", slow]]), profiles);
|
|
127
|
+
expect(result.records[0].status).toBe("ok");
|
|
128
|
+
});
|
|
113
129
|
});
|
|
114
130
|
|
|
115
131
|
describe("runOpLocally — cancellation", () => {
|
package/src/op/op.test.ts
CHANGED
|
@@ -197,3 +197,40 @@ describe("pre-built shortcuts", () => {
|
|
|
197
197
|
expect(a.profile).toBe("longInfra");
|
|
198
198
|
});
|
|
199
199
|
});
|
|
200
|
+
|
|
201
|
+
describe("profile routing (opts.profile sets the step profile, never leaks into args)", () => {
|
|
202
|
+
it("shell() routes profile to the step, keeps env in args", () => {
|
|
203
|
+
const a = shell("docker compose push", { env: { TAG: "v1" }, profile: "longInfra" });
|
|
204
|
+
expect(a.profile).toBe("longInfra");
|
|
205
|
+
expect(a.args?.cmd).toBe("docker compose push");
|
|
206
|
+
expect(a.args?.env).toEqual({ TAG: "v1" });
|
|
207
|
+
// The footgun this fixes: profile must NOT end up as an activity arg.
|
|
208
|
+
expect("profile" in (a.args ?? {})).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("shell() without profile leaves the step unprofiled (defaults apply downstream)", () => {
|
|
212
|
+
const a = shell("echo hi", { env: { A: "1" } });
|
|
213
|
+
expect("profile" in a).toBe(false);
|
|
214
|
+
expect(a.args?.env).toEqual({ A: "1" });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("build() accepts a profile override and does not leak it into args", () => {
|
|
218
|
+
const a = build("./p", { profile: "longInfra" });
|
|
219
|
+
expect(a.profile).toBe("longInfra");
|
|
220
|
+
expect(a.args?.path).toBe("./p");
|
|
221
|
+
expect("profile" in (a.args ?? {})).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("kubectlApply() lets opts.profile override the longInfra default", () => {
|
|
225
|
+
const a = kubectlApply("dist/infra.yaml", { profile: "k8sWait" });
|
|
226
|
+
expect(a.profile).toBe("k8sWait");
|
|
227
|
+
expect(a.args?.manifest).toBe("dist/infra.yaml");
|
|
228
|
+
expect("profile" in (a.args ?? {})).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("waitForStack() supports the argoSync profile", () => {
|
|
232
|
+
const a = waitForStack("argo-app", { profile: "argoSync" });
|
|
233
|
+
expect(a.profile).toBe("argoSync");
|
|
234
|
+
expect("profile" in (a.args ?? {})).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/op/types.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface ActivityStep {
|
|
|
45
45
|
* Key from TEMPORAL_ACTIVITY_PROFILES controlling timeout + retry.
|
|
46
46
|
* Default: "fastIdempotent"
|
|
47
47
|
*/
|
|
48
|
-
profile?: "fastIdempotent" | "longInfra" | "k8sWait" | "humanGate";
|
|
48
|
+
profile?: "fastIdempotent" | "longInfra" | "k8sWait" | "humanGate" | "argoSync";
|
|
49
49
|
/**
|
|
50
50
|
* Surface this activity's return value as a workflow search attribute.
|
|
51
51
|
*
|