@intentius/chant-lexicon-aws 0.124.46 → 0.124.49

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.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Reading a cloudformation workspace member into the graph (#3179).
3
+ *
4
+ * `chant workspace graph` writes a reader project for a member of kind
5
+ * `cloudformation`, with `aws.member` in its config (see `workspace-kinds.json`).
6
+ * The plugin's `buildRoots` hook calls {@link readCloudFormationMember}: each
7
+ * CloudFormation template directly in the member's directory is parsed with the
8
+ * import parser's YAML schema (short-form tags included), and every resource
9
+ * becomes a node keyed `<label>/<LogicalId>`.
10
+ *
11
+ * Edges come from what the template says: `Ref`, `Fn::GetAtt` and the `${...}`
12
+ * references in `Fn::Sub`, wherever they sit in a resource's properties, plus
13
+ * `DependsOn`. Parameters, pseudo parameters and names that are not a resource
14
+ * of the same template (`AWS::Region`, an `Fn::ImportValue`) draw no edge.
15
+ * Nothing runs and no AWS call is made.
16
+ *
17
+ * A `cdk` member is read the same way from the cloud assembly its app already
18
+ * synthesized (`cdk.out`, or the `output` its cdk.json names): the assembly's
19
+ * `*.template.json` files are CloudFormation templates, and core's CDK
20
+ * assembly reader says whether the directory is one. chant does not run the
21
+ * app. With no assembly there is nothing to read, and the report says
22
+ * `synthesized: false` with the reason.
23
+ */
24
+ import { readdirSync, readFileSync } from "node:fs";
25
+ import { basename, extname, join } from "node:path";
26
+ import yaml from "js-yaml";
27
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
28
+ import { isCloudAssembly } from "@intentius/chant/cdk/assembly";
29
+ import { CF_SCHEMA } from "./import/parser.js";
30
+ function parseTemplate(text) {
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(text);
34
+ }
35
+ catch {
36
+ try {
37
+ parsed = yaml.load(text, { schema: CF_SCHEMA });
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
44
+ return undefined;
45
+ const t = parsed;
46
+ // The probe's two claims: a format version, or a Resources section.
47
+ const resources = t.Resources;
48
+ if (t.AWSTemplateFormatVersion === undefined && (!resources || typeof resources !== "object"))
49
+ return undefined;
50
+ return t;
51
+ }
52
+ /** Logical ids named by `${X}` and `${X.Attr}` in an `Fn::Sub` string, with the attribute when one is named. */
53
+ function subReferences(text) {
54
+ const out = [];
55
+ for (const m of text.matchAll(/\$\{([^}!][^}]*)\}/g)) {
56
+ const [id, ...rest] = m[1].trim().split(".");
57
+ if (id)
58
+ out.push({ id, ...(rest.length ? { attr: rest.join(".") } : {}) });
59
+ }
60
+ return out;
61
+ }
62
+ /** Every name a value reaches through Ref, Fn::GetAtt or Fn::Sub. */
63
+ function collectNames(value, out) {
64
+ if (Array.isArray(value)) {
65
+ for (const v of value)
66
+ collectNames(v, out);
67
+ return;
68
+ }
69
+ if (!value || typeof value !== "object")
70
+ return;
71
+ for (const [k, v] of Object.entries(value)) {
72
+ if (k === "Ref" && typeof v === "string") {
73
+ out.push({ id: v });
74
+ }
75
+ else if (k === "Fn::GetAtt") {
76
+ const [id, attr] = Array.isArray(v) ? v : typeof v === "string" ? [v.slice(0, v.indexOf(".")), v.slice(v.indexOf(".") + 1)] : [];
77
+ if (typeof id === "string" && id)
78
+ out.push({ id, ...(typeof attr === "string" && attr ? { attr } : {}) });
79
+ }
80
+ else if (k === "Fn::Sub") {
81
+ const text = Array.isArray(v) ? v[0] : v;
82
+ if (typeof text === "string")
83
+ out.push(...subReferences(text));
84
+ if (Array.isArray(v))
85
+ collectNames(v[1], out);
86
+ }
87
+ else {
88
+ collectNames(v, out);
89
+ }
90
+ }
91
+ }
92
+ /** The directory a CDK app synthesizes into: cdk.json's `output`, else `cdk.out`. */
93
+ function cdkOutput(dir) {
94
+ try {
95
+ const output = JSON.parse(readFileSync(join(dir, "cdk.json"), "utf-8")).output;
96
+ if (typeof output === "string" && output)
97
+ return output;
98
+ }
99
+ catch {
100
+ /* no readable cdk.json: the default */
101
+ }
102
+ return "cdk.out";
103
+ }
104
+ export function readCloudFormationMember(opts) {
105
+ const { member } = opts;
106
+ const label = member.label ?? basename(member.dir);
107
+ const warnings = [];
108
+ const templates = [];
109
+ let source = member.dir;
110
+ if (member.kind === "cdk") {
111
+ source = join(member.dir, cdkOutput(member.dir));
112
+ if (!isCloudAssembly(source)) {
113
+ const reason = `no synthesized cloud assembly at ${source}; run \`cdk synth\` in the app, and chant will read the templates it writes`;
114
+ warnings.push(`cdk member "${label}" was not read: ${reason}`);
115
+ return { entities: new Map(), warnings, report: { templates: [], objects: 0, synthesized: false, reason } };
116
+ }
117
+ }
118
+ const pattern = member.kind === "cdk" ? /\.template\.json$/ : /\.(ya?ml|json|template)$/;
119
+ for (const file of readdirSync(source).filter((f) => pattern.test(f)).sort()) {
120
+ const template = parseTemplate(readFileSync(join(source, file), "utf-8"));
121
+ if (template)
122
+ templates.push({ file, template });
123
+ }
124
+ const entities = new Map();
125
+ for (const { file, template } of templates) {
126
+ // One template per member is the usual case and keeps ids short; two share a directory, so the file name qualifies them.
127
+ const prefix = templates.length === 1 ? label : `${label}/${basename(file, extname(file)).replace(/\.template$/, "")}`;
128
+ const resources = Object.entries(template.Resources ?? {}).filter(([, r]) => typeof r?.Type === "string");
129
+ const keyOf = new Map(resources.map(([id]) => [id, `${prefix}/${id}`]));
130
+ for (const [id, resource] of resources) {
131
+ const references = new Map();
132
+ const add = (name, via) => {
133
+ const to = keyOf.get(name.id);
134
+ if (!to || to === keyOf.get(id))
135
+ return;
136
+ references.set(`${to}|${via}|${name.attr ?? ""}`, { to, viaAttr: via, ...(name.attr ? { toAttr: name.attr } : {}) });
137
+ };
138
+ for (const [prop, value] of Object.entries(resource.Properties ?? {})) {
139
+ const names = [];
140
+ collectNames(value, names);
141
+ for (const n of names)
142
+ add(n, prop);
143
+ }
144
+ const dependsOn = resource.DependsOn;
145
+ for (const d of Array.isArray(dependsOn) ? dependsOn : dependsOn === undefined ? [] : [dependsOn]) {
146
+ if (typeof d === "string")
147
+ add({ id: d }, "DependsOn");
148
+ }
149
+ const entity = {
150
+ lexicon: "aws",
151
+ entityType: resource.Type,
152
+ kind: "resource",
153
+ props: { ...(resource.Properties ?? {}) },
154
+ references: [...references.values()],
155
+ [DECLARABLE_MARKER]: true,
156
+ };
157
+ entities.set(keyOf.get(id), entity);
158
+ }
159
+ }
160
+ if (templates.length === 0)
161
+ warnings.push(`${member.kind ?? "cloudformation"} member "${label}" has no CloudFormation template directly in ${source}`);
162
+ return {
163
+ entities,
164
+ warnings,
165
+ report: { templates: templates.map((t) => t.file), objects: entities.size, ...(member.kind === "cdk" ? { synthesized: true } : {}) },
166
+ };
167
+ }
168
+ let lastReport;
169
+ /**
170
+ * The report of the read in this process. `graphMeta` receives only the
171
+ * entities, so the hook that read the member leaves its report here for the
172
+ * hook that publishes it; `chant graph` runs the two in order in one process.
173
+ */
174
+ export function setLastMemberReport(report) {
175
+ lastReport = report;
176
+ }
177
+ export function lastMemberReport() {
178
+ return lastReport;
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-aws",
3
- "version": "0.124.46",
3
+ "version": "0.124.49",
4
4
  "description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -23,12 +23,15 @@
23
23
  "type": "module",
24
24
  "files": [
25
25
  "src/",
26
- "dist/"
26
+ "dist/",
27
+ "schemas/",
28
+ "workspace-kinds.json"
27
29
  ],
28
30
  "publishConfig": {
29
31
  "access": "public"
30
32
  },
31
33
  "exports": {
34
+ "./workspace-kinds": "./workspace-kinds.json",
32
35
  ".": {
33
36
  "development": "./src/index.ts",
34
37
  "types": "./dist/index.d.ts",
@@ -80,6 +83,6 @@
80
83
  "typescript": "^5.9.3"
81
84
  },
82
85
  "peerDependencies": {
83
- "@intentius/chant": "^0.124.46"
86
+ "@intentius/chant": "^0.124.49"
84
87
  }
85
88
  }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "cdk.json",
4
+ "type": "object",
5
+ "required": [
6
+ "app"
7
+ ],
8
+ "properties": {
9
+ "app": {
10
+ "type": "string",
11
+ "minLength": 1
12
+ }
13
+ }
14
+ }
@@ -11,7 +11,7 @@
11
11
  import type { Capability } from "@intentius/chant/components/capability";
12
12
  import { ownPackageVersion, type CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
13
13
 
14
- import { publishImageCapability, loadImageOnHostCapability, publishArtifactCapability } from "./publish";
14
+ import { ecrRegistryBackend, s3StorageBackend, loadImageOnHostCapability } from "./publish";
15
15
  import { extractConfigBomCapability } from "./config-bom";
16
16
  import {
17
17
  cfnDeployCapability,
@@ -30,7 +30,9 @@ import { snapshotBeforeCapability, rollbackPreviousCapability } from "./safety";
30
30
  export const AWS_VERB_FAMILIES = {
31
31
  // `extract-config-bom` parses a synthesized CloudFormation template (#684).
32
32
  sbom: ["extract-config-bom"],
33
- publish: ["publish-image", "load-image-on-host", "publish-artifact"],
33
+ // `publish-image` is core's verb (#3142, #3143); aws contributes its ECR login as a registry backend.
34
+ // `publish-artifact` is core's verb (#4408); aws contributes S3 as a storage backend.
35
+ publish: ["load-image-on-host"],
34
36
  apply: ["cfn-deploy", "ecs-update-service", "lambda-deploy", "s3-sync", "cdn-invalidate", "run-migration"],
35
37
  jobSubmission: ["emr-start-job-run", "emr-submit-step"],
36
38
  hostDelivery: ["code-deploy", "copy-to-host", "remote-exec"],
@@ -42,9 +44,7 @@ export const AWS_VERB_FAMILIES = {
42
44
  function awsCapabilities(): Array<Capability<never, unknown>> {
43
45
  return [
44
46
  extractConfigBomCapability,
45
- publishImageCapability,
46
47
  loadImageOnHostCapability,
47
- publishArtifactCapability,
48
48
  cfnDeployCapability,
49
49
  ecsUpdateServiceCapability,
50
50
  lambdaDeployCapability,
@@ -75,5 +75,7 @@ export const awsCapabilityPlugin: CapabilityPlugin = {
75
75
  return ownPackageVersion(import.meta.url);
76
76
  },
77
77
  capabilities: awsCapabilities,
78
+ registryBackends: () => [ecrRegistryBackend],
79
+ storageBackends: () => [s3StorageBackend],
78
80
  families: () => AWS_VERB_FAMILIES,
79
81
  };
@@ -46,189 +46,64 @@
46
46
  */
47
47
 
48
48
  import type { Capability } from "@intentius/chant/components/capability";
49
- import { readFileSync } from "node:fs";
50
- import { createHash } from "node:crypto";
51
49
  import { defaultCloudExecutor, type CloudExecutor } from "./cloud-executor";
52
50
  import { archiveRelativePath } from "@intentius/chant/components/verbs/build-archive";
53
- import { defaultProcessRunner, q, type ProcessRunner } from "@intentius/chant/components/verbs/process-runner";
51
+ import { defaultProcessRunner, type ProcessRunner } from "@intentius/chant/components/verbs/process-runner";
52
+ import {
53
+ createPublishImageCapability as createCorePublishImage,
54
+ type PublishImageBackend,
55
+ type RegistryLoginBackend,
56
+ } from "@intentius/chant/components/verbs/publish-image";
57
+
58
+ import {
59
+ createPublishArtifactCapability as createCorePublishArtifact,
60
+ type ArtifactStorageBackend,
61
+ type PublishArtifactInput,
62
+ type PublishArtifactOutput,
63
+ } from "@intentius/chant/components/verbs/publish-artifact";
54
64
 
55
65
  // ── shared backend interface ─────────────────────────────────────────────────
56
66
 
57
- /**
58
- * Common input shape both image-publish backends accept: promote the image
59
- * bytes at archive path `from` to wherever the deploy target can consume
60
- * them. `to` (registry) and `host` (bare host) are each meaningful to only
61
- * one backend, so a component authors one step shape (see
62
- * docs/components/build-archive.mdx) and the env-selected backend (see
63
- * `selectPublishBackend` below) reads whichever field it needs — the
64
- * unselected backend's field is simply unused, never a pipeline fork.
65
- */
66
- export interface PublishImageInput {
67
- /** Path of the image tarball inside the build archive (as produced by `docker-build`; an `archive:`-prefixed reference is accepted and stripped). */
68
- from: string;
69
- /** Destination registry — required by `publish-image`, ignored by `load-image-on-host` (e.g. `$env.registry` resolved by the orchestrator). */
70
- to?: string;
71
- /** Target host — required by `load-image-on-host`, ignored by `publish-image` (SSM instance id, hostname, or host group). */
72
- host?: string;
73
- /** Destination path for the tarball on the host, used only by `load-image-on-host`. Default: `/tmp/chant-archive/<basename of from>`. */
74
- hostPath?: string;
75
- /** Additional tags to apply alongside the digest, used only by `publish-image`. */
76
- tags?: string[];
77
- /**
78
- * #610: the artifact's software SBOM (typically wired from a prior
79
- * `generate-sbom` step's `sbom` output — see ./sbom.ts's
80
- * `GenerateSbomOutput`) to attach as an OCI referrer on the pushed image
81
- * digest, via `oras attach`. Used only by `publish-image`; ignored by
82
- * `load-image-on-host` (registry-less — nothing to attach to). Omit to
83
- * skip the attach step entirely (the archive-carried copy remains the
84
- * SBOM's home either way).
85
- */
86
- sbom?: { bytes: string; mediaType: string };
87
- /**
88
- * #610: the component-level aggregate BOM (wired from
89
- * ./component-bom.ts's `aggregateComponentBom` output), attached as a
90
- * second OCI referrer alongside `sbom` when supplied. Optional and
91
- * independent of `sbom` — a caller may attach either, both, or neither.
92
- */
93
- componentBom?: { bytes: string; mediaType: string };
94
- }
95
-
96
- /** Result of `publish-image`'s best-effort OCI-referrer attach step (#610) — always present when `input.sbom`/`input.componentBom` was supplied, `undefined` when neither was (nothing to attach, attach step skipped entirely). */
97
- export interface ReferrerAttachResult {
98
- /** True once every requested attach (`sbom`, `componentBom`) succeeded. `false` if `oras` was unavailable or any `oras attach` invocation failed. */
99
- attached: boolean;
100
- /** Human-readable reason `attached` is `false` (e.g. "oras is not installed"), omitted when `attached` is `true`. */
101
- reason?: string;
102
- }
103
-
104
- export interface PublishImageOutput {
105
- /** Content-addressed digest of the promoted image (`sha256:...`) — what the apply step references. */
106
- digest: string;
107
- /** Image reference the apply step can pull/run: `registry/repo@sha256:...` for `publish-image`, a host-local reference for `load-image-on-host`. */
108
- uri: string;
109
- /** #610: outcome of the best-effort SBOM/component-BOM referrer attach, when `input.sbom`/`input.componentBom` was supplied. `undefined` for `load-image-on-host` (no attach concept — registry-less) and for `publish-image` calls that supplied neither. */
110
- referrerAttach?: ReferrerAttachResult;
111
- }
112
-
113
- /**
114
- * Common shape both image-publish backends satisfy: promote the image bytes
115
- * held in the build archive to wherever the deploy target can consume them,
116
- * and return the identity the apply step references. The backend is
117
- * selected per environment (`selectPublishBackend`), never per component.
118
- */
119
- export type PublishImageBackend = Capability<PublishImageInput, PublishImageOutput>;
67
+ export type {
68
+ PublishImageInput,
69
+ PublishImageOutput,
70
+ PublishImageBackend,
71
+ ReferrerAttachResult,
72
+ } from "@intentius/chant/components/verbs/publish-image";
120
73
 
121
- // ── publish-image (registry backend) ────────────────────────────────────────
74
+ // ── publish-image: the ECR backend (the verb itself is core's) ──────────────
122
75
 
123
76
  /**
124
- * Attempt to `oras attach` one referrer document (SBOM or component BOM) to
125
- * `imageRef`'s digest. Writes the document's bytes to a scratch file (`oras
126
- * attach` reads from a file path, not stdin) and shells out through
127
- * `runner`. Never throws — a failure here is reported back to the caller as
128
- * `{ ok: false, reason }` so a missing/erroring `oras` never fails the
129
- * publish itself (see `attachReferrers`'s doc comment for why).
77
+ * The aws lexicon's registry login for core's `publish-image` (#3142, #3143):
78
+ * `aws ecr get-login-password | docker login`. The aws plugin contributes it;
79
+ * core owns load, tag, push and the digest read-back.
130
80
  */
131
- async function attachOneReferrer(
132
- runner: ProcessRunner,
133
- imageRef: string,
134
- doc: { bytes: string; mediaType: string },
135
- label: string,
136
- ): Promise<{ ok: boolean; reason?: string }> {
137
- try {
138
- const scratchFile = `/tmp/chant-referrer-attach/${label}-${Date.now()}.json`;
139
- await runner.run(`mkdir -p ${q("/tmp/chant-referrer-attach")}`);
140
- await runner.run(`printf '%s' ${q(doc.bytes)} > ${q(scratchFile)}`);
141
- await runner.run(`oras attach --artifact-type ${q(doc.mediaType)} ${q(imageRef)} ${q(scratchFile)}:${q(doc.mediaType)}`);
142
- return { ok: true };
143
- } catch (err) {
144
- return { ok: false, reason: err instanceof Error ? err.message : String(err) };
145
- }
81
+ export function createEcrRegistryBackend(executor?: CloudExecutor): RegistryLoginBackend {
82
+ return {
83
+ name: "ecr",
84
+ matches: (registry) => /\.dkr\.ecr(-fips)?\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/.test(registry),
85
+ // The real executor is built on first login, so loading the plugin shells out to nothing.
86
+ login: (registry) => (executor ?? defaultCloudExecutor()).ecr.login(registry),
87
+ };
146
88
  }
147
89
 
148
- /**
149
- * Best-effort OCI-referrer attach for a just-pushed image (#610): attaches
150
- * `input.sbom` and/or `input.componentBom`, when supplied, to `imageRef` via
151
- * `oras attach`. Guarded on `oras` availability — a missing binary is
152
- * reported back as `{ attached: false, reason }` rather than thrown, since
153
- * the image itself is already successfully promoted by the time this step
154
- * runs (attach is additive registry-side convenience on top of the
155
- * archive-carried copy, per this module's doc comment, never a condition the
156
- * core promote-by-digest job depends on). Returns `undefined` when neither
157
- * `input.sbom` nor `input.componentBom` was supplied — nothing to attach,
158
- * skipped entirely, no `oras` call at all.
159
- */
160
- async function attachReferrers(
161
- runner: ProcessRunner,
162
- imageRef: string,
163
- input: Pick<PublishImageInput, "sbom" | "componentBom">,
164
- ): Promise<ReferrerAttachResult | undefined> {
165
- if (!input.sbom && !input.componentBom) return undefined;
166
-
167
- if (!(await runner.available("oras"))) {
168
- return { attached: false, reason: `"oras" is not installed or not on PATH — required to attach OCI referrers` };
169
- }
170
-
171
- const results: Array<{ ok: boolean; reason?: string }> = [];
172
- if (input.sbom) results.push(await attachOneReferrer(runner, imageRef, input.sbom, "sbom"));
173
- if (input.componentBom) results.push(await attachOneReferrer(runner, imageRef, input.componentBom, "component-bom"));
174
-
175
- const failed = results.find((r) => !r.ok);
176
- return failed ? { attached: false, reason: failed.reason } : { attached: true };
177
- }
90
+ /** The ECR backend the aws plugin contributes, over the real `CloudExecutor`. */
91
+ export const ecrRegistryBackend: RegistryLoginBackend = createEcrRegistryBackend();
178
92
 
179
93
  /**
180
- * Promote a built image from the archive into the environment's container
181
- * registry: `docker load` the archived tarball, tag it for `to` (the env
182
- * registry), `aws ecr get-login-password | docker login`, then `docker push`.
183
- * Returns the pushed image's registry digest — what `cfn-deploy`/
184
- * `ecs-update-service` reference via `imageRef: "@Publish.digest"`. No
185
- * rollback: an already-pushed, still-valid image in the registry is not
186
- * itself a problem to compensate (immutable, content-addressed, and simply
187
- * unreferenced if a later step fails) — the opt-out this capability takes.
188
- *
189
- * #610: when `input.sbom` and/or `input.componentBom` are supplied, also
190
- * attaches them as OCI referrers on the pushed digest via `oras attach` (see
191
- * `attachReferrers` above) — best-effort, guarded on `oras` availability,
192
- * surfaced on the output's `referrerAttach` rather than failing the publish.
94
+ * `publish-image` with ECR as its only login backend: core's verb, bound to
95
+ * this executor. Kept so aws-only callers and tests build it the way they
96
+ * always did; behaviour is unchanged (load, tag, ECR login, push, optional
97
+ * referrer attach).
193
98
  */
194
99
  export function createPublishImageCapability(
195
100
  executor: CloudExecutor = defaultCloudExecutor(),
196
101
  processRunner: ProcessRunner = defaultProcessRunner(),
197
102
  ): PublishImageBackend {
198
- return {
199
- kind: "publish-image",
200
- async run(_ctx, input) {
201
- if (!input.to) throw new Error(`publish-image "${input.from}": "to" (destination registry) is required`);
202
- // `docker load` reports the archived image's local reference — a name:tag
203
- // for a tagged build, or an image id. It's a valid `docker tag` source but
204
- // never a registry digest, so the promoted digest is read back from the
205
- // push below, not constructed from this.
206
- const { digest: localRef } = await executor.docker.load({ inFile: archiveRelativePath(input.from) });
207
- const repo = input.to.replace(/\/+$/, "");
208
- // Publish under a tag: docker can neither tag nor push a digest-qualified
209
- // reference (`repo@sha256:...`), so the transport is a tag and the stable
210
- // identifier is the digest the push returns.
211
- const pushRef = `${repo}:latest`;
212
- await executor.docker.tag({ source: localRef, target: pushRef });
213
- const registry = repo.split("/")[0]!;
214
- await executor.ecr.login(registry);
215
- const { digest: pushed } = await executor.docker.push({ image: pushRef });
216
- // Normalize to a bare `sha256:...`, whether the client returned that or a
217
- // full `repo@sha256:...` RepoDigest.
218
- const digest = pushed.includes("@") ? pushed.slice(pushed.indexOf("@") + 1) : pushed;
219
- for (const tag of input.tags ?? []) {
220
- const tagged = `${repo}:${tag}`;
221
- await executor.docker.tag({ source: localRef, target: tagged });
222
- await executor.docker.push({ image: tagged });
223
- }
224
-
225
- const referrerAttach = await attachReferrers(processRunner, `${repo}@${digest}`, input);
226
- return { digest, uri: `${repo}@${digest}`, ...(referrerAttach ? { referrerAttach } : {}) };
227
- },
228
- };
103
+ return createCorePublishImage({ docker: executor.docker, processRunner, backends: [createEcrRegistryBackend(executor)] });
229
104
  }
230
105
 
231
- /** Default `publish-image` capability, backed by the real `CloudExecutor`. */
106
+ /** `publish-image` bound to the real executor; the registered verb is core's, with this lexicon's ECR backend injected. */
232
107
  export const publishImageCapability: PublishImageBackend = createPublishImageCapability();
233
108
 
234
109
  // ── load-image-on-host (host backend) ───────────────────────────────────────
@@ -309,48 +184,41 @@ export function selectPublishBackend(
309
184
  return backend;
310
185
  }
311
186
 
312
- // ── publish-asset / publish-artifact ────────────────────────────────────────
187
+ // ── publish-asset / publish-artifact (the verb is core's, #4408) ───────────
313
188
 
314
- export interface PublishArtifactInput {
315
- /** Path of the artifact inside the build archive (e.g. a jar or zip). */
316
- from: string;
317
- /** Destination (e.g. `$env.s3` resolved by the orchestrator). */
318
- to: string;
319
- }
189
+ export type { PublishArtifactInput, PublishArtifactOutput } from "@intentius/chant/components/verbs/publish-artifact";
320
190
 
321
- export interface PublishArtifactOutput {
322
- /** Location the artifact was published to — referenced downstream as `@<component>.publish.uri`. */
323
- uri: string;
324
- /** Content hash of the published artifact. */
325
- digest: string;
191
+ /**
192
+ * The aws lexicon's storage backend for core's `publish-artifact`: `aws s3 cp`
193
+ * (endpoint-aware through the `CloudExecutor`). Core owns the digest and the
194
+ * object URI; this only uploads.
195
+ */
196
+ export function createS3StorageBackend(executor?: CloudExecutor): ArtifactStorageBackend {
197
+ return {
198
+ name: "s3",
199
+ matches: (uri) => uri.startsWith("s3://"),
200
+ // The real executor is built on first upload, so loading the plugin shells out to nothing.
201
+ upload: (args) => (executor ?? defaultCloudExecutor()).s3.cp(args),
202
+ };
326
203
  }
327
204
 
328
- /** Promote a non-image artifact (jar, zip, arbitrary asset) from the archive to S3/CodeArtifact. */
205
+ /** The S3 backend the aws plugin contributes, over the real `CloudExecutor`. */
206
+ export const s3StorageBackend: ArtifactStorageBackend = createS3StorageBackend();
207
+
329
208
  /**
330
- * Publish a single archive artifact (a jar, zip, or asset) to S3 via `aws s3 cp`
331
- * (endpoint-aware through the `CloudExecutor`). Returns the object's URI —
332
- * referenced downstream as `@<component>.publish.uri` — and a content digest of
333
- * the exact bytes uploaded.
209
+ * `publish-artifact` with S3 as its only storage backend: core's verb, bound
210
+ * to this executor. Kept so aws-only callers and tests build it the way they
211
+ * always did; behaviour is unchanged (upload, return uri + content digest).
334
212
  */
335
213
  export function createPublishArtifactCapability(
336
214
  executor: CloudExecutor = defaultCloudExecutor(),
337
215
  ): Capability<PublishArtifactInput, PublishArtifactOutput> {
338
- return {
339
- kind: "publish-artifact",
340
- async run(_ctx, input) {
341
- const localPath = archiveRelativePath(input.from);
342
- const digest = `sha256:${createHash("sha256").update(readFileSync(localPath)).digest("hex")}`;
343
- const basename = localPath.split("/").pop() ?? localPath;
344
- const uri = input.to.endsWith("/") ? `${input.to}${basename}` : input.to;
345
- await executor.s3.cp({ from: localPath, to: uri });
346
- return { uri, digest };
347
- },
348
- };
216
+ return createCorePublishArtifact({ backends: [createS3StorageBackend(executor)] });
349
217
  }
350
218
 
351
- /** Default `publish-artifact` capability, backed by the real `CloudExecutor`. */
219
+ /** Default `publish-artifact`, bound to the real executor; the registered verb is core's, with this lexicon's S3 backend injected. */
352
220
  export const publishArtifactCapability: Capability<PublishArtifactInput, PublishArtifactOutput> =
353
221
  createPublishArtifactCapability();
354
222
 
355
- /** Alias for `publish-artifact` — same capability, the docs/epic use both names for the same verb. */
223
+ /** Alias for `publish-artifact`: same capability, the docs/epic use both names for the same verb. */
356
224
  export const publishAssetCapability = publishArtifactCapability;
@@ -112,7 +112,7 @@ const cfnYamlTypes = [
112
112
  }),
113
113
  ];
114
114
 
115
- const CF_SCHEMA = yaml.DEFAULT_SCHEMA.extend(cfnYamlTypes);
115
+ export const CF_SCHEMA = yaml.DEFAULT_SCHEMA.extend(cfnYamlTypes);
116
116
 
117
117
  /**
118
118
  * CloudFormation template structure
package/src/plugin.ts CHANGED
@@ -36,6 +36,7 @@ import { canDescribe, describeOwnProperties, stampRegion } from "./properties";
36
36
  import { stampProviderDefaults } from "./defaults";
37
37
  import { resolveTemplateAttrs } from "./live-attrs";
38
38
  import { CFParser } from "./import/parser";
39
+ import { lastMemberReport, readCloudFormationMember, setLastMemberReport } from "./workspace-member";
39
40
  import { CFGenerator } from "./import/generator";
40
41
  import { parseStackTemplate } from "./import/live-export";
41
42
  import { awsCompletions } from "./lsp/completions";
@@ -65,6 +66,21 @@ const OWNERSHIP_UNRESOLVED_NOTE =
65
66
  export const awsPlugin: LexiconPlugin = {
66
67
  behaviourKinds: awsBehaviourKinds,
67
68
  name: "aws",
69
+ // A cloudformation workspace member's reader project (#3179): the template's
70
+ // resources as nodes, its Ref, GetAtt, Sub and DependsOn as edges. See
71
+ // ./workspace-member.ts.
72
+ async buildRoots(ctx) {
73
+ setLastMemberReport(undefined);
74
+ const member = (ctx.config as { aws?: { member?: { kind?: "cloudformation" | "cdk"; dir: string; label?: string } } }).aws?.member;
75
+ if (!member) return { entities: new Map() };
76
+ const read = readCloudFormationMember({ member });
77
+ setLastMemberReport(read.report);
78
+ return { entities: read.entities, warnings: read.warnings };
79
+ },
80
+ graphMeta() {
81
+ const report = lastMemberReport();
82
+ return report ? { awsMember: report } : undefined;
83
+ },
68
84
  // The thin read's marker channel is the STACK's own tags (#1998), read off
69
85
  // the DescribeStacks call it already makes for the outputs.
70
86
  // describe-stack-resources carries no per-resource tags, so the stack is the
@@ -0,0 +1 @@
1
+ {"app": "npx ts-node bin/app.ts"}
@@ -0,0 +1,20 @@
1
+ {
2
+ "Resources": {
3
+ "QueueABC123": {
4
+ "Type": "AWS::SQS::Queue",
5
+ "Properties": { "VisibilityTimeout": 30 }
6
+ },
7
+ "HandlerRoleDEF456": {
8
+ "Type": "AWS::IAM::Role",
9
+ "Properties": { "AssumeRolePolicyDocument": { "Statement": [] } }
10
+ },
11
+ "HandlerGHI789": {
12
+ "Type": "AWS::Lambda::Function",
13
+ "Properties": {
14
+ "Role": { "Fn::GetAtt": ["HandlerRoleDEF456", "Arn"] },
15
+ "Environment": { "Variables": { "QUEUE_URL": { "Ref": "QueueABC123" } } }
16
+ },
17
+ "DependsOn": ["HandlerRoleDEF456"]
18
+ }
19
+ }
20
+ }
@@ -0,0 +1 @@
1
+ {"version": "36.0.0", "artifacts": {}}
@@ -0,0 +1 @@
1
+ {"app": "npx ts-node bin/app.ts"}
@@ -0,0 +1 @@
1
+ not a template