@intentius/chant-lexicon-terraform 0.124.54 → 0.124.56
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/README.md +7 -4
- package/dist/ci-setup.d.ts +11 -0
- package/dist/ci-setup.d.ts.map +1 -0
- package/dist/ci-setup.js +41 -0
- package/dist/components/capability-plugin.d.ts +40 -6
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/capability-plugin.js +174 -30
- package/dist/components/compose-group.d.ts +1 -1
- package/dist/components/compose-group.d.ts.map +1 -1
- package/dist/components/compose-group.js +1 -1
- package/dist/components/release-inputs.d.ts +29 -0
- package/dist/components/release-inputs.d.ts.map +1 -0
- package/dist/components/release-inputs.js +57 -0
- package/dist/components/trace-facts.d.ts +16 -0
- package/dist/components/trace-facts.d.ts.map +1 -0
- package/dist/components/trace-facts.js +19 -0
- package/dist/cone.d.ts +53 -0
- package/dist/cone.d.ts.map +1 -0
- package/dist/cone.js +119 -0
- package/dist/cost-hold.d.ts +27 -0
- package/dist/cost-hold.d.ts.map +1 -0
- package/dist/cost-hold.js +21 -0
- package/dist/drift-rows.d.ts +61 -0
- package/dist/drift-rows.d.ts.map +1 -0
- package/dist/drift-rows.js +108 -0
- package/dist/exit-codes.d.ts +55 -0
- package/dist/exit-codes.d.ts.map +1 -0
- package/dist/exit-codes.js +48 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/integrity.json +7 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-catalog.js +27 -3
- package/dist/lint/audit-lineage.d.ts +14 -2
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/index.js +4 -0
- package/dist/lint/post-synth/record-store.d.ts +54 -0
- package/dist/lint/post-synth/record-store.d.ts.map +1 -0
- package/dist/lint/post-synth/record-store.js +104 -0
- package/dist/lint/post-synth/tf001.d.ts +2 -2
- package/dist/lint/post-synth/tf001.js +2 -2
- package/dist/lint/post-synth/tf024.d.ts +35 -11
- package/dist/lint/post-synth/tf024.d.ts.map +1 -1
- package/dist/lint/post-synth/tf024.js +92 -20
- package/dist/lint/post-synth/tf046.d.ts +64 -0
- package/dist/lint/post-synth/tf046.d.ts.map +1 -0
- package/dist/lint/post-synth/tf046.js +175 -0
- package/dist/lint/post-synth/tf047.d.ts +36 -0
- package/dist/lint/post-synth/tf047.d.ts.map +1 -0
- package/dist/lint/post-synth/tf047.js +94 -0
- package/dist/lock-rows.d.ts +36 -0
- package/dist/lock-rows.d.ts.map +1 -0
- package/dist/lock-rows.js +60 -0
- package/dist/lock-timeout.d.ts +22 -0
- package/dist/lock-timeout.d.ts.map +1 -0
- package/dist/lock-timeout.js +32 -0
- package/dist/manifest.json +1 -1
- package/dist/module-catalog/index.d.ts +69 -0
- package/dist/module-catalog/index.d.ts.map +1 -0
- package/dist/module-catalog/index.js +110 -0
- package/dist/module-catalog/refresh-cli.d.ts +4 -0
- package/dist/module-catalog/refresh-cli.d.ts.map +1 -0
- package/dist/module-catalog/refresh-cli.js +64 -0
- package/dist/module-catalog/static-tier.d.ts +6 -0
- package/dist/module-catalog/static-tier.d.ts.map +1 -0
- package/dist/module-catalog/static-tier.js +1650 -0
- package/dist/module-catalog/types.d.ts +65 -0
- package/dist/module-catalog/types.d.ts.map +1 -0
- package/dist/module-catalog/types.js +33 -0
- package/dist/okf/index.md +3 -1
- package/dist/okf/rules/TF024.md +2 -2
- package/dist/okf/rules/TF046.md +11 -0
- package/dist/okf/rules/TF047.md +11 -0
- package/dist/op/activities/terraform.d.ts +29 -0
- package/dist/op/activities/terraform.d.ts.map +1 -1
- package/dist/op/activities/terraform.js +19 -4
- package/dist/pin/consumers.d.ts +35 -0
- package/dist/pin/consumers.d.ts.map +1 -0
- package/dist/pin/consumers.js +70 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +5 -0
- package/dist/registry/address.d.ts +63 -0
- package/dist/registry/address.d.ts.map +1 -0
- package/dist/registry/address.js +65 -0
- package/dist/registry/catalog-read.d.ts +14 -0
- package/dist/registry/catalog-read.d.ts.map +1 -0
- package/dist/registry/catalog-read.js +58 -0
- package/dist/registry/deprecated.d.ts +37 -0
- package/dist/registry/deprecated.d.ts.map +1 -0
- package/dist/registry/deprecated.js +43 -0
- package/dist/registry/docs.d.ts +96 -0
- package/dist/registry/docs.d.ts.map +1 -0
- package/dist/registry/docs.js +210 -0
- package/dist/registry/edge/edge.d.ts +46 -0
- package/dist/registry/edge/edge.d.ts.map +1 -0
- package/dist/registry/edge/edge.js +194 -0
- package/dist/registry/edge/index.d.ts +11 -0
- package/dist/registry/edge/index.d.ts.map +1 -0
- package/dist/registry/edge/index.js +10 -0
- package/dist/registry/edge/lambda-edge.d.ts +76 -0
- package/dist/registry/edge/lambda-edge.d.ts.map +1 -0
- package/dist/registry/edge/lambda-edge.js +95 -0
- package/dist/registry/edge/sigv4-web.d.ts +33 -0
- package/dist/registry/edge/sigv4-web.d.ts.map +1 -0
- package/dist/registry/edge/sigv4-web.js +56 -0
- package/dist/registry/edge/token-source.d.ts +73 -0
- package/dist/registry/edge/token-source.d.ts.map +1 -0
- package/dist/registry/edge/token-source.js +115 -0
- package/dist/registry/edge/worker.d.ts +45 -0
- package/dist/registry/edge/worker.d.ts.map +1 -0
- package/dist/registry/edge/worker.js +26 -0
- package/dist/registry/handler.d.ts +25 -0
- package/dist/registry/handler.d.ts.map +1 -0
- package/dist/registry/handler.js +75 -0
- package/dist/registry/headers-file.d.ts +21 -0
- package/dist/registry/headers-file.d.ts.map +1 -0
- package/dist/registry/headers-file.js +57 -0
- package/dist/registry/index.d.ts +31 -0
- package/dist/registry/index.d.ts.map +1 -0
- package/dist/registry/index.js +30 -0
- package/dist/registry/mirror-paths.d.ts +9 -0
- package/dist/registry/mirror-paths.d.ts.map +1 -0
- package/dist/registry/mirror-paths.js +8 -0
- package/dist/registry/mirror.d.ts +130 -0
- package/dist/registry/mirror.d.ts.map +1 -0
- package/dist/registry/mirror.js +319 -0
- package/dist/registry/proxies.d.ts +73 -0
- package/dist/registry/proxies.d.ts.map +1 -0
- package/dist/registry/proxies.js +111 -0
- package/dist/registry/reader.d.ts +57 -0
- package/dist/registry/reader.d.ts.map +1 -0
- package/dist/registry/reader.js +198 -0
- package/dist/registry/semver.d.ts +16 -0
- package/dist/registry/semver.d.ts.map +1 -0
- package/dist/registry/semver.js +24 -0
- package/dist/registry/serve.d.ts +24 -0
- package/dist/registry/serve.d.ts.map +1 -0
- package/dist/registry/serve.js +64 -0
- package/dist/registry/source-forms.d.ts +79 -0
- package/dist/registry/source-forms.d.ts.map +1 -0
- package/dist/registry/source-forms.js +202 -0
- package/dist/registry/targets/artifactory.d.ts +28 -0
- package/dist/registry/targets/artifactory.d.ts.map +1 -0
- package/dist/registry/targets/artifactory.js +206 -0
- package/dist/registry/targets/fake-artifactory.d.ts +30 -0
- package/dist/registry/targets/fake-artifactory.d.ts.map +1 -0
- package/dist/registry/targets/fake-artifactory.js +142 -0
- package/dist/registry/targets/fake-gitlab.d.ts +39 -0
- package/dist/registry/targets/fake-gitlab.d.ts.map +1 -0
- package/dist/registry/targets/fake-gitlab.js +136 -0
- package/dist/registry/targets/fake-hcp.d.ts +50 -0
- package/dist/registry/targets/fake-hcp.d.ts.map +1 -0
- package/dist/registry/targets/fake-hcp.js +202 -0
- package/dist/registry/targets/fake-http.d.ts +31 -0
- package/dist/registry/targets/fake-http.d.ts.map +1 -0
- package/dist/registry/targets/fake-http.js +87 -0
- package/dist/registry/targets/fake-nexus.d.ts +27 -0
- package/dist/registry/targets/fake-nexus.d.ts.map +1 -0
- package/dist/registry/targets/fake-nexus.js +137 -0
- package/dist/registry/targets/gitlab.d.ts +53 -0
- package/dist/registry/targets/gitlab.d.ts.map +1 -0
- package/dist/registry/targets/gitlab.js +187 -0
- package/dist/registry/targets/hcp.d.ts +29 -0
- package/dist/registry/targets/hcp.d.ts.map +1 -0
- package/dist/registry/targets/hcp.js +310 -0
- package/dist/registry/targets/hosted.d.ts +62 -0
- package/dist/registry/targets/hosted.d.ts.map +1 -0
- package/dist/registry/targets/hosted.js +74 -0
- package/dist/registry/targets/index.d.ts +18 -0
- package/dist/registry/targets/index.d.ts.map +1 -0
- package/dist/registry/targets/index.js +17 -0
- package/dist/registry/targets/module-target.d.ts +68 -0
- package/dist/registry/targets/module-target.d.ts.map +1 -0
- package/dist/registry/targets/module-target.js +63 -0
- package/dist/registry/targets/nexus.d.ts +43 -0
- package/dist/registry/targets/nexus.d.ts.map +1 -0
- package/dist/registry/targets/nexus.js +250 -0
- package/dist/registry/targets/zip.d.ts +55 -0
- package/dist/registry/targets/zip.d.ts.map +1 -0
- package/dist/registry/targets/zip.js +278 -0
- package/dist/registry/writer.d.ts +101 -0
- package/dist/registry/writer.d.ts.map +1 -0
- package/dist/registry/writer.js +109 -0
- package/dist/rules/record-store.ts +122 -0
- package/dist/rules/tf001.ts +2 -2
- package/dist/rules/tf024.ts +105 -22
- package/dist/rules/tf046.ts +204 -0
- package/dist/rules/tf047.ts +114 -0
- package/dist/state/azure-blob.d.ts +8 -67
- package/dist/state/azure-blob.d.ts.map +1 -1
- package/dist/state/azure-blob.js +6 -199
- package/dist/state/gcs.d.ts +7 -61
- package/dist/state/gcs.d.ts.map +1 -1
- package/dist/state/gcs.js +5 -222
- package/dist/state/object-store.d.ts +4 -33
- package/dist/state/object-store.d.ts.map +1 -1
- package/dist/state/object-store.js +4 -17
- package/dist/state/s3.d.ts +11 -58
- package/dist/state/s3.d.ts.map +1 -1
- package/dist/state/s3.js +10 -161
- package/dist/state/store.js +2 -2
- package/dist/state-ops.d.ts +255 -0
- package/dist/state-ops.d.ts.map +1 -0
- package/dist/state-ops.js +305 -0
- package/dist/state-reads.d.ts +57 -0
- package/dist/state-reads.d.ts.map +1 -0
- package/dist/state-reads.js +104 -0
- package/dist/used-sources.d.ts +54 -0
- package/dist/used-sources.d.ts.map +1 -0
- package/dist/used-sources.js +157 -0
- package/package.json +19 -3
- package/src/__fixtures__/drift-record/README.md +5 -0
- package/src/__fixtures__/drift-rows/live-root-plan.json +70 -0
- package/src/__fixtures__/drift-rows/refresh-plan.json +61 -0
- package/src/__fixtures__/used-sources/root/extra.tofu +1 -0
- package/src/__fixtures__/used-sources/root/main.tf +31 -0
- package/src/__fixtures__/used-sources/root/modules/net/main.tf +14 -0
- package/src/__fixtures__/used-sources/root/modules/net/versions.tf.json +7 -0
- package/src/__fixtures__/used-sources/unit/terragrunt.hcl +3 -0
- package/src/ci-setup.ts +42 -0
- package/src/components/capability-plugin.test.ts +26 -14
- package/src/components/capability-plugin.ts +185 -31
- package/src/components/compose-group.ts +1 -1
- package/src/components/pr-loop-compose.tofu.e2e.test.ts +1 -1
- package/src/components/pr-loop.tofu.test.ts +1 -1
- package/src/components/release-inputs.ts +69 -0
- package/src/components/trace-facts.test.ts +21 -0
- package/src/components/trace-facts.ts +27 -0
- package/src/cone.ts +138 -0
- package/src/cost-hold.ts +29 -0
- package/src/describe-resources.engine-facts.test.ts +2 -3
- package/src/drift-rows.test.ts +135 -0
- package/src/drift-rows.ts +137 -0
- package/src/exit-codes.test.ts +23 -0
- package/src/exit-codes.ts +59 -0
- package/src/index.ts +3 -0
- package/src/lint/audit-catalog.ts +29 -3
- package/src/lint/audit-lineage.ts +14 -2
- package/src/lint/post-synth/fixtures/TF024/live-with-record-store/main.tf +18 -0
- package/src/lint/post-synth/fixtures/TF024/sidecar-with-backend/estate.chdf.hcl +1 -0
- package/src/lint/post-synth/fixtures/TF024/sidecar-with-backend/main.tf +10 -0
- package/src/lint/post-synth/fixtures/TF046/azurerm-no-resource-group/main.tf +15 -0
- package/src/lint/post-synth/fixtures/TF046/backend-config-only/main.tf +14 -0
- package/src/lint/post-synth/fixtures/TF046/derived-s3/backend.tf +7 -0
- package/src/lint/post-synth/fixtures/TF046/derived-s3/main.tf +9 -0
- package/src/lint/post-synth/fixtures/TF046/explicit-local/main.tf +16 -0
- package/src/lint/post-synth/fixtures/TF046/explicit-s3-beside-local-backend/main.tf +15 -0
- package/src/lint/post-synth/fixtures/TF046/implied-local/main.tf +9 -0
- package/src/lint/post-synth/fixtures/TF046/sidecar-implied/estate.chdf.hcl +1 -0
- package/src/lint/post-synth/fixtures/TF046/sidecar-implied/main.tf +5 -0
- package/src/lint/post-synth/fixtures/TF047/clear/main.tf +12 -0
- package/src/lint/post-synth/fixtures/TF047/refused/main.tf +12 -0
- package/src/lint/post-synth/fixtures/TF047/unknown-module/main.tf +12 -0
- package/src/lint/post-synth/fixtures/TF047/unknown-version/main.tf +12 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/record-store.ts +122 -0
- package/src/lint/post-synth/tf001.ts +2 -2
- package/src/lint/post-synth/tf024.test.ts +26 -5
- package/src/lint/post-synth/tf024.ts +105 -22
- package/src/lint/post-synth/tf046.test.ts +137 -0
- package/src/lint/post-synth/tf046.ts +204 -0
- package/src/lint/post-synth/tf047.test.ts +56 -0
- package/src/lint/post-synth/tf047.ts +114 -0
- package/src/lock-rows.test.ts +70 -0
- package/src/lock-rows.ts +82 -0
- package/src/lock-timeout.ts +34 -0
- package/src/module-catalog/index.test.ts +93 -0
- package/src/module-catalog/index.ts +159 -0
- package/src/module-catalog/refresh-cli.ts +68 -0
- package/src/module-catalog/static-tier.ts +1659 -0
- package/src/module-catalog/types.ts +88 -0
- package/src/op/activities/terraform.ts +46 -5
- package/src/op-wave-step.test.ts +109 -0
- package/src/pin/__fixtures__/consumers/live/dev/main.tf +4 -0
- package/src/pin/__fixtures__/consumers/live/prod/main.tf +21 -0
- package/src/pin/__fixtures__/consumers/units/vpc/terragrunt.hcl +7 -0
- package/src/pin/consumers.test.ts +57 -0
- package/src/pin/consumers.ts +95 -0
- package/src/plugin.ts +6 -0
- package/src/registry/__fixtures__/docs/documented/.terraform-docs.yml +3 -0
- package/src/registry/__fixtures__/docs/documented/README.md +1 -0
- package/src/registry/__fixtures__/docs/documented/main.tf +8 -0
- package/src/registry/__fixtures__/docs/json/main.tf.json +14 -0
- package/src/registry/__fixtures__/docs/vpc/README.md +3 -0
- package/src/registry/__fixtures__/docs/vpc/examples/complete/main.tf +5 -0
- package/src/registry/__fixtures__/docs/vpc/examples/simple/README.md +1 -0
- package/src/registry/__fixtures__/docs/vpc/examples/simple/main.tf +4 -0
- package/src/registry/__fixtures__/docs/vpc/main.tf +14 -0
- package/src/registry/__fixtures__/docs/vpc/outputs.tf +9 -0
- package/src/registry/__fixtures__/docs/vpc/variables.tf +16 -0
- package/src/registry/__fixtures__/proxied-root/main.tf +12 -0
- package/src/registry/address.ts +100 -0
- package/src/registry/catalog-read.ts +56 -0
- package/src/registry/deprecated.test.ts +139 -0
- package/src/registry/deprecated.ts +76 -0
- package/src/registry/docs.test.ts +140 -0
- package/src/registry/docs.ts +299 -0
- package/src/registry/edge/bundle.test.ts +50 -0
- package/src/registry/edge/edge.test.ts +176 -0
- package/src/registry/edge/edge.ts +211 -0
- package/src/registry/edge/index.ts +10 -0
- package/src/registry/edge/lambda-edge.ts +127 -0
- package/src/registry/edge/sigv4-web.ts +79 -0
- package/src/registry/edge/token-source.test.ts +258 -0
- package/src/registry/edge/token-source.ts +169 -0
- package/src/registry/edge/worker.ts +61 -0
- package/src/registry/handler.ts +89 -0
- package/src/registry/headers-file.ts +61 -0
- package/src/registry/index.ts +31 -0
- package/src/registry/mirror-paths.ts +10 -0
- package/src/registry/mirror.test.ts +198 -0
- package/src/registry/mirror.ts +395 -0
- package/src/registry/proxies.test.ts +153 -0
- package/src/registry/proxies.ts +132 -0
- package/src/registry/reader.ts +225 -0
- package/src/registry/registry.test.ts +262 -0
- package/src/registry/semver.ts +34 -0
- package/src/registry/serve.ts +82 -0
- package/src/registry/source-forms.test.ts +87 -0
- package/src/registry/source-forms.ts +216 -0
- package/src/registry/targets/artifactory.test.ts +248 -0
- package/src/registry/targets/artifactory.ts +257 -0
- package/src/registry/targets/fake-artifactory.ts +158 -0
- package/src/registry/targets/fake-gitlab.ts +161 -0
- package/src/registry/targets/fake-hcp.ts +233 -0
- package/src/registry/targets/fake-http.ts +97 -0
- package/src/registry/targets/fake-nexus.ts +152 -0
- package/src/registry/targets/gitlab.test.ts +208 -0
- package/src/registry/targets/gitlab.ts +223 -0
- package/src/registry/targets/hcp.test.ts +230 -0
- package/src/registry/targets/hcp.ts +360 -0
- package/src/registry/targets/hosted.ts +117 -0
- package/src/registry/targets/index.ts +17 -0
- package/src/registry/targets/module-target.ts +131 -0
- package/src/registry/targets/nexus.test.ts +220 -0
- package/src/registry/targets/nexus.ts +314 -0
- package/src/registry/targets/zip.test.ts +65 -0
- package/src/registry/targets/zip.ts +287 -0
- package/src/registry/writer.ts +155 -0
- package/src/state/azure-blob.ts +6 -221
- package/src/state/gcs.ts +5 -252
- package/src/state/object-store.ts +4 -32
- package/src/state/s3.ts +10 -182
- package/src/state/store.ts +2 -2
- package/src/state-ops.test.ts +182 -0
- package/src/state-ops.ts +462 -0
- package/src/state-reads.ts +116 -0
- package/src/used-sources.test.ts +75 -0
- package/src/used-sources.ts +204 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The zip of an attested module archive (chant#4605, terragucci#1299 ruling
|
|
3
|
+
* A). Artifactory takes a module only as a zip and Nexus recommends one, so
|
|
4
|
+
* their targets upload the same files the bucket registry's `.tar.gz`
|
|
5
|
+
* carries, at the same paths with the same modes, repacked as a zip. Nothing
|
|
6
|
+
* is added to or dropped from the module.
|
|
7
|
+
*
|
|
8
|
+
* The repack is deterministic: entries keep the tarball's order and mtimes,
|
|
9
|
+
* so one archive always gives the same zip bytes. The zip's archive comment
|
|
10
|
+
* carries the attested content digest and the revision
|
|
11
|
+
* (`chant-module content=sha256:<hex> revision=<sha>`), which is how a
|
|
12
|
+
* target's `held` tells, from the zip a host gives back, which attested
|
|
13
|
+
* archive it was made from. A zip with no such comment (one uploaded by
|
|
14
|
+
* hand) is judged by its own digest, so it never passes for the release's.
|
|
15
|
+
*
|
|
16
|
+
* No zip64: an archive past 4 GiB or 65535 entries is refused, far above
|
|
17
|
+
* either host's limit (Nexus takes 100 MB per module).
|
|
18
|
+
*/
|
|
19
|
+
import { deflateRawSync, gunzipSync, inflateRawSync } from "node:zlib";
|
|
20
|
+
import { PublishTargetError } from "@intentius/chant/lifecycle/release-publish";
|
|
21
|
+
|
|
22
|
+
/** One entry of a module tarball. */
|
|
23
|
+
export interface ModuleArchiveEntry {
|
|
24
|
+
path: string;
|
|
25
|
+
type: "file" | "dir";
|
|
26
|
+
mode: number;
|
|
27
|
+
/** Seconds since the epoch. */
|
|
28
|
+
mtime: number;
|
|
29
|
+
data: Buffer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const field = (b: Buffer, at: number, len: number): string => {
|
|
33
|
+
const raw = b.subarray(at, at + len);
|
|
34
|
+
const end = raw.indexOf(0);
|
|
35
|
+
return raw.subarray(0, end < 0 ? raw.length : end).toString("utf-8");
|
|
36
|
+
};
|
|
37
|
+
const octal = (b: Buffer, at: number, len: number): number => parseInt(field(b, at, len).trim() || "0", 8);
|
|
38
|
+
|
|
39
|
+
function paxRecords(data: Buffer): Record<string, string> {
|
|
40
|
+
const out: Record<string, string> = {};
|
|
41
|
+
let at = 0;
|
|
42
|
+
while (at < data.length) {
|
|
43
|
+
const space = data.indexOf(0x20, at);
|
|
44
|
+
if (space < 0) break;
|
|
45
|
+
const len = parseInt(data.subarray(at, space).toString("utf-8"), 10);
|
|
46
|
+
if (!Number.isFinite(len) || len <= 0) break;
|
|
47
|
+
const record = data.subarray(space + 1, at + len - 1).toString("utf-8");
|
|
48
|
+
const eq = record.indexOf("=");
|
|
49
|
+
if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
|
|
50
|
+
at += len;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const invalid = (message: string) => new PublishTargetError("publish-archive-invalid", message, "Publish the .tar.gz the bucket registry serves, with the module's files at its root.");
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The entries of a module's gzip tarball (ustar, pax and GNU long names), with
|
|
59
|
+
* `./` cut from each path. A link or a device in the archive is refused: a
|
|
60
|
+
* zip of the module could not carry it as the tarball does.
|
|
61
|
+
*/
|
|
62
|
+
export function moduleArchiveEntries(archive: Uint8Array): ModuleArchiveEntry[] {
|
|
63
|
+
let tar: Buffer;
|
|
64
|
+
try {
|
|
65
|
+
tar = gunzipSync(archive);
|
|
66
|
+
} catch {
|
|
67
|
+
throw invalid("the module archive is not a gzip tarball");
|
|
68
|
+
}
|
|
69
|
+
const out: ModuleArchiveEntry[] = [];
|
|
70
|
+
let at = 0;
|
|
71
|
+
let pax: Record<string, string> = {};
|
|
72
|
+
let longName: string | undefined;
|
|
73
|
+
while (at + 512 <= tar.length) {
|
|
74
|
+
const header = tar.subarray(at, at + 512);
|
|
75
|
+
if (header.every((b) => b === 0)) break;
|
|
76
|
+
const size = octal(header, 124, 12);
|
|
77
|
+
const flag = String.fromCharCode(header[156] || 0x30);
|
|
78
|
+
const data = tar.subarray(at + 512, at + 512 + size);
|
|
79
|
+
at += 512 + Math.ceil(size / 512) * 512;
|
|
80
|
+
if (flag === "x") {
|
|
81
|
+
pax = paxRecords(data);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (flag === "L") {
|
|
85
|
+
longName = field(data, 0, data.length);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (flag === "g") continue;
|
|
89
|
+
const prefix = field(header, 345, 155);
|
|
90
|
+
const raw = pax.path ?? longName ?? (prefix ? `${prefix}/${field(header, 0, 100)}` : field(header, 0, 100));
|
|
91
|
+
const mtime = pax.mtime ? Math.floor(Number(pax.mtime)) : octal(header, 136, 12);
|
|
92
|
+
pax = {};
|
|
93
|
+
longName = undefined;
|
|
94
|
+
const path = raw.replace(/^(\.\/)+/, "").replace(/\/+$/, "");
|
|
95
|
+
if (!path || path === ".") continue;
|
|
96
|
+
if (path.startsWith("/") || path.split("/").includes("..")) throw invalid(`the module archive holds ${raw}, outside its root`);
|
|
97
|
+
const type = flag === "0" || flag === "\0" || flag === "7" ? "file" : flag === "5" ? "dir" : undefined;
|
|
98
|
+
if (!type) throw invalid(`the module archive holds ${path}, which is not a regular file or a directory; a zip of the module cannot carry it`);
|
|
99
|
+
out.push({ path, type, mode: octal(header, 100, 8) & 0o7777, mtime, data: type === "file" ? Buffer.from(data) : Buffer.alloc(0) });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const CRC_TABLE = (() => {
|
|
105
|
+
const t = new Uint32Array(256);
|
|
106
|
+
for (let n = 0; n < 256; n++) {
|
|
107
|
+
let c = n;
|
|
108
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
109
|
+
t[n] = c >>> 0;
|
|
110
|
+
}
|
|
111
|
+
return t;
|
|
112
|
+
})();
|
|
113
|
+
|
|
114
|
+
/** The CRC-32 a zip entry carries. */
|
|
115
|
+
export function crc32(data: Uint8Array): number {
|
|
116
|
+
let c = 0xffffffff;
|
|
117
|
+
for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]!) & 0xff]! ^ (c >>> 8);
|
|
118
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** MS-DOS date and time, as a zip entry keeps them, in UTC and clamped to 1980. */
|
|
122
|
+
function dosTime(seconds: number): { time: number; date: number } {
|
|
123
|
+
const d = new Date(Math.max(seconds * 1000, Date.UTC(1980, 0, 1)));
|
|
124
|
+
return {
|
|
125
|
+
time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | Math.floor(d.getUTCSeconds() / 2),
|
|
126
|
+
date: ((d.getUTCFullYear() - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate(),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The archive comment that names the attested archive a zip was made from. */
|
|
131
|
+
export const ZIP_COMMENT_PREFIX = "chant-module";
|
|
132
|
+
|
|
133
|
+
export const moduleZipComment = (content: string, revision?: string): string => `${ZIP_COMMENT_PREFIX} content=${content}${revision ? ` revision=${revision}` : ""}`;
|
|
134
|
+
|
|
135
|
+
/** The content and revision a zip's archive comment names, when it is one {@link moduleZipComment} wrote. */
|
|
136
|
+
export function readModuleZipComment(comment: string): { content: string; revision?: string } | undefined {
|
|
137
|
+
if (!comment.startsWith(`${ZIP_COMMENT_PREFIX} `)) return undefined;
|
|
138
|
+
const content = /(?:^| )content=(sha256:[0-9a-f]{64})(?: |$)/.exec(comment)?.[1];
|
|
139
|
+
if (!content) return undefined;
|
|
140
|
+
const revision = /(?:^| )revision=([^ ]+)(?: |$)/.exec(comment)?.[1];
|
|
141
|
+
return { content, ...(revision ? { revision } : {}) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A zip of the given entries, with `comment` as its archive comment. Files are deflated. */
|
|
145
|
+
export function zipOf(entries: readonly ModuleArchiveEntry[], comment = ""): Buffer {
|
|
146
|
+
if (entries.length > 0xffff) throw invalid(`the module archive holds ${entries.length} entries; a zip without zip64 holds 65535`);
|
|
147
|
+
const locals: Buffer[] = [];
|
|
148
|
+
const centrals: Buffer[] = [];
|
|
149
|
+
let offset = 0;
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
const name = Buffer.from(e.type === "dir" ? `${e.path}/` : e.path, "utf-8");
|
|
152
|
+
const data = e.type === "file" ? e.data : Buffer.alloc(0);
|
|
153
|
+
const packed = e.type === "file" ? deflateRawSync(data, { level: 9 }) : Buffer.alloc(0);
|
|
154
|
+
const method = e.type === "file" ? 8 : 0;
|
|
155
|
+
const crc = crc32(data);
|
|
156
|
+
const { time, date } = dosTime(e.mtime);
|
|
157
|
+
if (packed.length > 0xfffffffe || data.length > 0xfffffffe || offset > 0xfffffffe) throw invalid("the module archive is past 4 GiB; a zip without zip64 holds less");
|
|
158
|
+
|
|
159
|
+
const local = Buffer.alloc(30);
|
|
160
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
161
|
+
local.writeUInt16LE(20, 4);
|
|
162
|
+
local.writeUInt16LE(0x0800, 6);
|
|
163
|
+
local.writeUInt16LE(method, 8);
|
|
164
|
+
local.writeUInt16LE(time, 10);
|
|
165
|
+
local.writeUInt16LE(date, 12);
|
|
166
|
+
local.writeUInt32LE(crc, 14);
|
|
167
|
+
local.writeUInt32LE(packed.length, 18);
|
|
168
|
+
local.writeUInt32LE(data.length, 22);
|
|
169
|
+
local.writeUInt16LE(name.length, 26);
|
|
170
|
+
local.writeUInt16LE(0, 28);
|
|
171
|
+
locals.push(local, name, packed);
|
|
172
|
+
|
|
173
|
+
const unixMode = (e.type === "dir" ? 0o040000 : 0o100000) | (e.mode & 0o7777 || (e.type === "dir" ? 0o755 : 0o644));
|
|
174
|
+
const central = Buffer.alloc(46);
|
|
175
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
176
|
+
central.writeUInt16LE((3 << 8) | 20, 4);
|
|
177
|
+
central.writeUInt16LE(20, 6);
|
|
178
|
+
central.writeUInt16LE(0x0800, 8);
|
|
179
|
+
central.writeUInt16LE(method, 10);
|
|
180
|
+
central.writeUInt16LE(time, 12);
|
|
181
|
+
central.writeUInt16LE(date, 14);
|
|
182
|
+
central.writeUInt32LE(crc, 16);
|
|
183
|
+
central.writeUInt32LE(packed.length, 20);
|
|
184
|
+
central.writeUInt32LE(data.length, 24);
|
|
185
|
+
central.writeUInt16LE(name.length, 28);
|
|
186
|
+
central.writeUInt16LE(0, 30);
|
|
187
|
+
central.writeUInt16LE(0, 32);
|
|
188
|
+
central.writeUInt16LE(0, 34);
|
|
189
|
+
central.writeUInt16LE(0, 36);
|
|
190
|
+
central.writeUInt32LE(((unixMode << 16) | (e.type === "dir" ? 0x10 : 0)) >>> 0, 38);
|
|
191
|
+
central.writeUInt32LE(offset, 42);
|
|
192
|
+
centrals.push(central, name);
|
|
193
|
+
offset += local.length + name.length + packed.length;
|
|
194
|
+
}
|
|
195
|
+
const cd = Buffer.concat(centrals);
|
|
196
|
+
const note = Buffer.from(comment, "utf-8");
|
|
197
|
+
if (note.length > 0xffff) throw invalid("the zip comment is longer than a zip carries");
|
|
198
|
+
const end = Buffer.alloc(22);
|
|
199
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
200
|
+
end.writeUInt16LE(0, 4);
|
|
201
|
+
end.writeUInt16LE(0, 6);
|
|
202
|
+
end.writeUInt16LE(entries.length, 8);
|
|
203
|
+
end.writeUInt16LE(entries.length, 10);
|
|
204
|
+
end.writeUInt32LE(cd.length, 12);
|
|
205
|
+
end.writeUInt32LE(offset, 16);
|
|
206
|
+
end.writeUInt16LE(note.length, 20);
|
|
207
|
+
return Buffer.concat([...locals, cd, end, note]);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The zip a hosted target uploads for an attested archive: its entries,
|
|
212
|
+
* repacked, with the content digest and revision in the archive comment.
|
|
213
|
+
*/
|
|
214
|
+
export function moduleZip(archive: Uint8Array, content: string, revision?: string): Buffer {
|
|
215
|
+
return zipOf(moduleArchiveEntries(archive), moduleZipComment(content, revision));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A zip read back: its archive comment and its entries' paths, modes and bytes. */
|
|
219
|
+
export interface ReadZip {
|
|
220
|
+
comment: string;
|
|
221
|
+
entries: { path: string; dir: boolean; mode: number; data: Buffer }[];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Reads a zip's central directory and entries. Throws on bytes that are not a zip. */
|
|
225
|
+
export function readZip(bytes: Uint8Array): ReadZip {
|
|
226
|
+
const b = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
227
|
+
let end = -1;
|
|
228
|
+
for (let i = b.length - 22; i >= Math.max(0, b.length - 22 - 0xffff); i--) {
|
|
229
|
+
if (b.readUInt32LE(i) === 0x06054b50) {
|
|
230
|
+
end = i;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (end < 0) throw new Error("not a zip archive: no end of central directory");
|
|
235
|
+
const count = b.readUInt16LE(end + 10);
|
|
236
|
+
const cdOffset = b.readUInt32LE(end + 16);
|
|
237
|
+
const comment = b.subarray(end + 22, end + 22 + b.readUInt16LE(end + 20)).toString("utf-8");
|
|
238
|
+
const entries: ReadZip["entries"] = [];
|
|
239
|
+
let at = cdOffset;
|
|
240
|
+
for (let n = 0; n < count; n++) {
|
|
241
|
+
if (b.readUInt32LE(at) !== 0x02014b50) throw new Error("not a zip archive: a bad central directory entry");
|
|
242
|
+
const method = b.readUInt16LE(at + 10);
|
|
243
|
+
const csize = b.readUInt32LE(at + 20);
|
|
244
|
+
const nameLen = b.readUInt16LE(at + 28);
|
|
245
|
+
const extraLen = b.readUInt16LE(at + 30);
|
|
246
|
+
const commentLen = b.readUInt16LE(at + 32);
|
|
247
|
+
const external = b.readUInt32LE(at + 38);
|
|
248
|
+
const local = b.readUInt32LE(at + 42);
|
|
249
|
+
const name = b.subarray(at + 46, at + 46 + nameLen).toString("utf-8");
|
|
250
|
+
at += 46 + nameLen + extraLen + commentLen;
|
|
251
|
+
if (b.readUInt32LE(local) !== 0x04034b50) throw new Error(`not a zip archive: no local header for ${name}`);
|
|
252
|
+
const start = local + 30 + b.readUInt16LE(local + 26) + b.readUInt16LE(local + 28);
|
|
253
|
+
const packed = b.subarray(start, start + csize);
|
|
254
|
+
const data = method === 0 ? Buffer.from(packed) : method === 8 ? inflateRawSync(packed) : undefined;
|
|
255
|
+
if (!data) throw new Error(`${name}: zip method ${method} is not read here`);
|
|
256
|
+
const dir = name.endsWith("/");
|
|
257
|
+
entries.push({ path: dir ? name.slice(0, -1) : name, dir, mode: (external >>> 16) & 0o7777, data });
|
|
258
|
+
}
|
|
259
|
+
return { comment, entries };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const isGzip = (b: Uint8Array) => b.length >= 2 && b[0] === 0x1f && b[1] === 0x8b;
|
|
263
|
+
const isZip = (b: Uint8Array) => b.length >= 4 && b[0] === 0x50 && b[1] === 0x4b;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The content a module archive a host gave back carries, as `held` reports
|
|
267
|
+
* it: the attested digest a chant zip's comment names; for a gzip tarball,
|
|
268
|
+
* the digest of its uncompressed bytes, as the bucket registry keeps it; for
|
|
269
|
+
* any other bytes, their own digest.
|
|
270
|
+
*/
|
|
271
|
+
export function heldContentOf(bytes: Uint8Array, digest: (b: Uint8Array) => string): { content: string; revision?: string } {
|
|
272
|
+
if (isZip(bytes)) {
|
|
273
|
+
try {
|
|
274
|
+
const named = readModuleZipComment(readZip(bytes).comment);
|
|
275
|
+
if (named) return named;
|
|
276
|
+
} catch {
|
|
277
|
+
// Not a zip after all: judged by its own digest below.
|
|
278
|
+
}
|
|
279
|
+
} else if (isGzip(bytes)) {
|
|
280
|
+
try {
|
|
281
|
+
return { content: digest(gunzipSync(bytes)) };
|
|
282
|
+
} catch {
|
|
283
|
+
// A broken gzip: judged by its own digest below.
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { content: digest(bytes) };
|
|
287
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module registry protocol, written as files a bucket, a directory or a
|
|
3
|
+
* Pages site holds (https://opentofu.org/docs/internals/module-registry-protocol/):
|
|
4
|
+
*
|
|
5
|
+
* .well-known/terraform.json {"modules.v1": "/v1/modules/"}
|
|
6
|
+
* v1/modules/<ns>/<name>/<system>/versions every version, oldest first
|
|
7
|
+
* v1/modules/<ns>/<name>/<system>/<v>/download {"location": ...}
|
|
8
|
+
* v1/modules/<ns>/<name>/<system>/<v>/<name>-<v>.tar.gz a tarball download
|
|
9
|
+
* v1/modules/<ns>/<name>/<system>/<v>/release.json the commit and content digest
|
|
10
|
+
*
|
|
11
|
+
* The download answer the two CLIs need is a 200 with the JSON body and an
|
|
12
|
+
* `X-Terraform-Get` header naming the same location (terragucci#1287).
|
|
13
|
+
* Terraform reads only the header (hashicorp/terraform
|
|
14
|
+
* internal/registry/client.go:237-250 at v1.14.9 and v1.15.8). OpenTofu reads
|
|
15
|
+
* the body's `location` first and the header when it is empty
|
|
16
|
+
* (opentofu/opentofu internal/registry/client.go:205-237 at v1.12.5 and
|
|
17
|
+
* v1.13.1). A stored file carries no header, so the stored `download` file is
|
|
18
|
+
* the body, and whatever serves it (`downloadAnswer`, the loopback handler in
|
|
19
|
+
* ./serve, an edge function) adds the header from that body.
|
|
20
|
+
*
|
|
21
|
+
* The writer takes the store it writes to, so the bucket clients stay with
|
|
22
|
+
* whoever owns them (chant#4554 moves terragucci's into core).
|
|
23
|
+
*/
|
|
24
|
+
// The errors module alone, never the object-store index: its clients import Node built-ins, and the
|
|
25
|
+
// edge function (./edge) reaches this file for the download answer (chant#4589).
|
|
26
|
+
import { isStoreConflict, type StoreCondition } from "@intentius/chant/object-store/errors";
|
|
27
|
+
import { DISCOVERY, MODULES_V1, keyFor, type RegistryAddress } from "./address";
|
|
28
|
+
import { compareSemver, formatSemver, parseSemver, type Semver } from "./semver";
|
|
29
|
+
|
|
30
|
+
/** The header Terraform takes a module's location from. */
|
|
31
|
+
export const DOWNLOAD_HEADER = "X-Terraform-Get";
|
|
32
|
+
|
|
33
|
+
/** The files a registry is written to: a bucket client, a directory, or a test's map. */
|
|
34
|
+
export interface RegistryWriteStore {
|
|
35
|
+
/** Writes one object. With `when`, the write must fail with a conflict when the condition no longer holds. */
|
|
36
|
+
put(key: string, body: string | Buffer, contentType?: string, when?: StoreCondition): Promise<unknown>;
|
|
37
|
+
/** One object's body and version tag, or `{}` when there is none. */
|
|
38
|
+
read(key: string): Promise<{ body?: string; etag?: string }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The service discovery document. */
|
|
42
|
+
export const discoveryDoc = (): string => `${JSON.stringify({ "modules.v1": MODULES_V1 })}\n`;
|
|
43
|
+
|
|
44
|
+
/** The protocol's versions answer, oldest first, duplicates and non-versions dropped. */
|
|
45
|
+
export function versionsDoc(versions: string[]): string {
|
|
46
|
+
// Dedupe after formatting, so "v1.0.0" and "1.0.0" are one version.
|
|
47
|
+
const parsed = versions.map((v) => parseSemver(v)).filter((v): v is Semver => !!v);
|
|
48
|
+
const sorted = [...new Set(parsed.sort(compareSemver).map(formatSemver))];
|
|
49
|
+
return `${JSON.stringify({ modules: [{ versions: sorted.map((version) => ({ version })) }] }, null, 2)}\n`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The versions a versions answer lists, or none when it is missing or not the protocol's JSON. */
|
|
53
|
+
export function readVersions(text: string | undefined): string[] {
|
|
54
|
+
if (!text) return [];
|
|
55
|
+
try {
|
|
56
|
+
const doc = JSON.parse(text) as { modules?: { versions?: { version?: unknown }[] }[] };
|
|
57
|
+
return (doc.modules?.[0]?.versions ?? []).map((v) => v.version).filter((v): v is string => typeof v === "string");
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A version's download answer body: the JSON OpenTofu reads. */
|
|
64
|
+
export function downloadDoc(location: string): string {
|
|
65
|
+
return `${JSON.stringify({ location })}\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The location a download body names, or undefined when it names none. */
|
|
69
|
+
export function downloadLocation(body: string | undefined): string | undefined {
|
|
70
|
+
if (!body) return undefined;
|
|
71
|
+
try {
|
|
72
|
+
const location = (JSON.parse(body) as { location?: unknown }).location;
|
|
73
|
+
return typeof location === "string" && location ? location : undefined;
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A download answer as a server sends it. */
|
|
80
|
+
export interface DownloadAnswer {
|
|
81
|
+
status: 200;
|
|
82
|
+
headers: Record<string, string>;
|
|
83
|
+
body: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The download answer both CLIs install from: status 200, the JSON body with
|
|
88
|
+
* `location`, and `X-Terraform-Get` naming the same location.
|
|
89
|
+
*/
|
|
90
|
+
export function downloadAnswer(location: string): DownloadAnswer {
|
|
91
|
+
return { status: 200, headers: { "content-type": "application/json", [DOWNLOAD_HEADER]: location }, body: downloadDoc(location) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The headers a stored download body is served with, or undefined when the body names no location. */
|
|
95
|
+
export function downloadHeaders(body: string | undefined): Record<string, string> | undefined {
|
|
96
|
+
const location = downloadLocation(body);
|
|
97
|
+
return location ? downloadAnswer(location).headers : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True for a `.../v1/modules/<ns>/<name>/<system>/<version>/download` path, with or without a leading prefix. */
|
|
101
|
+
export const isDownloadPath = (path: string): boolean => /(^|\/)v1\/modules\/[^/]+\/[^/]+\/[^/]+\/[^/]+\/download$/.test(path.split("?")[0]!);
|
|
102
|
+
|
|
103
|
+
/** What the registry keeps beside a version, so the next publish knows what it holds. */
|
|
104
|
+
export interface ReleaseMeta {
|
|
105
|
+
module: string;
|
|
106
|
+
version: string;
|
|
107
|
+
revision: string;
|
|
108
|
+
content: string;
|
|
109
|
+
location: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The registry's files for one version, written in this order: the version is listed last, once all it points at is there. */
|
|
113
|
+
export interface VersionFiles {
|
|
114
|
+
tarball?: { key: string; body: Buffer };
|
|
115
|
+
download: { key: string; body: string };
|
|
116
|
+
meta: { key: string; body: string };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface WriteVersionOptions {
|
|
120
|
+
/** The registry's prefix in its store. */
|
|
121
|
+
prefix?: string;
|
|
122
|
+
/**
|
|
123
|
+
* Whether an error from `put` is a lost conditional write, to be retried.
|
|
124
|
+
* By default chant's `StoreConflict`, or any error named `StoreConflict`
|
|
125
|
+
* (a store from another package with a class of its own).
|
|
126
|
+
*/
|
|
127
|
+
isConflict?: (e: unknown) => boolean;
|
|
128
|
+
/** Conditional retries on the versions file before the conflict is thrown. Default 3. */
|
|
129
|
+
retries?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Writes one version: discovery when it differs, the tarball, the download answer, release.json, then the versions list. */
|
|
133
|
+
export async function writeVersion(store: RegistryWriteStore, addr: RegistryAddress, version: Semver, files: VersionFiles, options: WriteVersionOptions = {}): Promise<void> {
|
|
134
|
+
const discovery = discoveryDoc();
|
|
135
|
+
const discoKey = keyFor(options.prefix, DISCOVERY);
|
|
136
|
+
const disco = await store.read(discoKey);
|
|
137
|
+
if (disco.body !== discovery) await store.put(discoKey, discovery, "application/json");
|
|
138
|
+
if (files.tarball) await store.put(files.tarball.key, files.tarball.body, "application/gzip");
|
|
139
|
+
await store.put(files.download.key, files.download.body, "application/json");
|
|
140
|
+
await store.put(files.meta.key, files.meta.body, "application/json");
|
|
141
|
+
const key = keyFor(options.prefix, `${addr.path}/versions`);
|
|
142
|
+
const isConflict = options.isConflict ?? isStoreConflict;
|
|
143
|
+
const retries = options.retries ?? 3;
|
|
144
|
+
// Read, add, write only over the copy read: two publishes at once cannot drop each other's version.
|
|
145
|
+
for (let attempt = 0; ; attempt++) {
|
|
146
|
+
const held = await store.read(key);
|
|
147
|
+
const when: StoreCondition | undefined = held.body === undefined ? { ifNoneMatch: "*" } : held.etag ? { ifMatch: held.etag } : undefined;
|
|
148
|
+
try {
|
|
149
|
+
await store.put(key, versionsDoc([...readVersions(held.body), formatSemver(version)]), "application/json", when);
|
|
150
|
+
return;
|
|
151
|
+
} catch (e) {
|
|
152
|
+
if (!isConflict(e) || attempt >= retries) throw e;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/state/azure-blob.ts
CHANGED
|
@@ -1,223 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Azure Blob Storage
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Requests are signed with Shared Key for an account key, carry a SAS as it
|
|
8
|
-
* is, or carry a storage token the job's OIDC identity is traded for at Entra
|
|
9
|
-
* ID (a client assertion, the way the azurerm backend does it).
|
|
10
|
-
*
|
|
11
|
-
* Lifted from terragucci's `report/azure-blob.ts` (c493e0e), without SAS
|
|
12
|
-
* links, version reads and version listing.
|
|
2
|
+
* The Azure Blob Storage client an azurerm backend's state uses: chant
|
|
3
|
+
* core's (#4554, `@intentius/chant/object-store`): a blob's properties and
|
|
4
|
+
* metadata, a snapshot, a block blob put, and the blob lease the backend
|
|
5
|
+
* locks a state with, signed with Shared Key, a SAS, or an Entra token for
|
|
6
|
+
* the job's OIDC identity.
|
|
13
7
|
*/
|
|
14
|
-
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
|
-
import { defaultFetch, encodePath, StoreError, xmlText, type StoreFetch } from "./object-store";
|
|
17
|
-
|
|
18
|
-
/** The REST version every request names. */
|
|
19
|
-
export const AZURE_VERSION = "2021-08-06";
|
|
20
|
-
|
|
21
|
-
/** Entra ID's public cloud. */
|
|
22
|
-
export const AZURE_AUTHORITY = "https://login.microsoftonline.com";
|
|
23
|
-
|
|
24
|
-
const STORAGE_SCOPE = "https://storage.azure.com/.default";
|
|
25
|
-
|
|
26
|
-
export interface AzureLocation {
|
|
27
|
-
account: string;
|
|
28
|
-
container: string;
|
|
29
|
-
/** The account's blob endpoint, no trailing slash: https://<account>.blob.core.windows.net, or an emulator's http://host:10000/<account>. */
|
|
30
|
-
endpoint: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** The job's federated identity: an Entra app or managed identity that trusts the forge's token. */
|
|
34
|
-
export interface AzureOidc {
|
|
35
|
-
tenantId: string;
|
|
36
|
-
clientId: string;
|
|
37
|
-
tokenFile: string;
|
|
38
|
-
/** Entra ID's address, https://host, no path. */
|
|
39
|
-
authority: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export type AzureTarget = AzureLocation & ({ accountKey: string } | { oidc: AzureOidc } | { sas: string });
|
|
43
|
-
|
|
44
|
-
/** A blob's properties, as a HEAD answers them: never its body. */
|
|
45
|
-
export interface BlobHead {
|
|
46
|
-
exists: boolean;
|
|
47
|
-
etag?: string;
|
|
48
|
-
/** x-ms-lease-state: `leased` while a lease holds it. */
|
|
49
|
-
leaseState?: string;
|
|
50
|
-
/** Its metadata, by name without the x-ms-meta- prefix. */
|
|
51
|
-
metadata: Record<string, string>;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const hmac64 = (key: string, s: string): string => createHmac("sha256", Buffer.from(key, "base64")).update(s, "utf8").digest("base64");
|
|
55
|
-
|
|
56
|
-
/** The headers whose values Shared Key signs by position, in order. */
|
|
57
|
-
const SIGNED = ["content-encoding", "content-language", "content-length", "content-md5", "content-type", "date", "if-modified-since", "if-match", "if-none-match", "if-unmodified-since", "range"];
|
|
58
|
-
|
|
59
|
-
/** The Shared Key Authorization header for a request. */
|
|
60
|
-
export function sharedKey(account: string, accountKey: string, method: string, rawUrl: string, headers: Record<string, string>): string {
|
|
61
|
-
const url = new URL(rawUrl);
|
|
62
|
-
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
63
|
-
const fixed = SIGNED.map((n) => (n === "content-length" && h[n] === "0" ? "" : (h[n] ?? "")));
|
|
64
|
-
const ms = Object.keys(h).filter((n) => n.startsWith("x-ms-")).sort().map((n) => `${n}:${h[n]!.trim()}\n`).join("");
|
|
65
|
-
const params = new Map<string, string[]>();
|
|
66
|
-
for (const [k, v] of url.searchParams) params.set(k.toLowerCase(), [...(params.get(k.toLowerCase()) ?? []), v]);
|
|
67
|
-
const query = [...params.keys()].sort().map((k) => `\n${k}:${params.get(k)!.sort().join(",")}`).join("");
|
|
68
|
-
const toSign = [method, ...fixed].join("\n") + "\n" + ms + `/${account}${url.pathname}` + query;
|
|
69
|
-
return `SharedKey ${account}:${hmac64(accountKey, toSign)}`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Entra ID's error, or the start of the body. */
|
|
73
|
-
const why = (text: string): string => {
|
|
74
|
-
try {
|
|
75
|
-
const j = JSON.parse(text) as { error?: string; error_description?: string };
|
|
76
|
-
if (j.error) return [j.error, j.error_description?.split("\r\n")[0]].filter(Boolean).join(": ");
|
|
77
|
-
} catch {
|
|
78
|
-
// Not JSON: Blob Storage answers in XML.
|
|
79
|
-
}
|
|
80
|
-
return [xmlText(text, "Code"), xmlText(text, "Message")?.split("\n")[0]].filter(Boolean).join(": ") || text.slice(0, 300);
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
export class AzureBlobClient {
|
|
84
|
-
private token?: Promise<{ token: string; expires: number }>;
|
|
85
|
-
|
|
86
|
-
constructor(readonly target: AzureTarget, private readonly fetchFn: StoreFetch = defaultFetch, private readonly readToken: (file: string) => string = (f) => readFileSync(f, "utf-8")) {}
|
|
87
|
-
|
|
88
|
-
private where(key: string): string {
|
|
89
|
-
return `az://${this.target.account}/${this.target.container}/${key}`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private blobUrl(key: string, query = ""): string {
|
|
93
|
-
const t = this.target;
|
|
94
|
-
const q = [query, "sas" in t ? t.sas.replace(/^\?/, "") : ""].filter(Boolean).join("&");
|
|
95
|
-
return `${t.endpoint}/${t.container}/${encodePath(key)}${q ? `?${q}` : ""}`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** The storage token for the OIDC identity: once per client, again a minute before it expires. */
|
|
99
|
-
private async bearer(o: AzureOidc): Promise<string> {
|
|
100
|
-
const fetchToken = async (): Promise<{ token: string; expires: number }> => {
|
|
101
|
-
let assertion: string;
|
|
102
|
-
try {
|
|
103
|
-
assertion = this.readToken(o.tokenFile).trim();
|
|
104
|
-
} catch (e) {
|
|
105
|
-
throw new StoreError(`cannot read the OIDC token in ${o.tokenFile}: ${(e as Error).message}`);
|
|
106
|
-
}
|
|
107
|
-
if (!assertion) throw new StoreError(`the OIDC token file ${o.tokenFile} is empty`);
|
|
108
|
-
const body = new URLSearchParams({
|
|
109
|
-
client_id: o.clientId,
|
|
110
|
-
scope: STORAGE_SCOPE,
|
|
111
|
-
grant_type: "client_credentials",
|
|
112
|
-
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
113
|
-
client_assertion: assertion,
|
|
114
|
-
}).toString();
|
|
115
|
-
const res = await this.fetchFn(`${o.authority}/${o.tenantId}/oauth2/v2.0/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body });
|
|
116
|
-
const text = await res.text();
|
|
117
|
-
if (!res.ok) throw new StoreError(`Entra ID refused the OIDC token for client ${o.clientId}: ${res.status} ${why(text)}`);
|
|
118
|
-
let j: { access_token?: string; expires_in?: number | string } = {};
|
|
119
|
-
try {
|
|
120
|
-
j = JSON.parse(text) as typeof j;
|
|
121
|
-
} catch {
|
|
122
|
-
// Reported below as no token.
|
|
123
|
-
}
|
|
124
|
-
if (!j.access_token) throw new StoreError(`Entra ID answered client ${o.clientId} without a token`);
|
|
125
|
-
return { token: j.access_token, expires: Date.now() + Number(j.expires_in ?? 3600) * 1000 };
|
|
126
|
-
};
|
|
127
|
-
const again = (): Promise<{ token: string; expires: number }> =>
|
|
128
|
-
(this.token = fetchToken().catch((e: unknown) => {
|
|
129
|
-
this.token = undefined;
|
|
130
|
-
throw e;
|
|
131
|
-
}));
|
|
132
|
-
let held = await (this.token ?? again());
|
|
133
|
-
if (held.expires - Date.now() < 60_000) held = await again();
|
|
134
|
-
return held.token;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** The headers for one request, signed or with the bearer token. */
|
|
138
|
-
private async headers(method: string, url: string, extra: Record<string, string>): Promise<Record<string, string>> {
|
|
139
|
-
const h: Record<string, string> = { "x-ms-date": new Date().toUTCString(), "x-ms-version": AZURE_VERSION, ...extra };
|
|
140
|
-
const t = this.target;
|
|
141
|
-
if ("accountKey" in t) return { ...h, authorization: sharedKey(t.account, t.accountKey, method, url, h) };
|
|
142
|
-
if ("sas" in t) return h;
|
|
143
|
-
return { ...h, authorization: `Bearer ${await this.bearer(t.oidc)}` };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async get(key: string): Promise<string | undefined> {
|
|
147
|
-
const url = this.blobUrl(key);
|
|
148
|
-
const res = await this.fetchFn(url, { method: "GET", headers: await this.headers("GET", url, {}) });
|
|
149
|
-
if (res.status === 404) return undefined;
|
|
150
|
-
if (!res.ok) throw new StoreError(`GET ${this.where(key)}: ${res.status} ${why(await res.text())}`);
|
|
151
|
-
return res.text();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** A blob's properties and metadata, from a HEAD. */
|
|
155
|
-
async head(key: string): Promise<BlobHead> {
|
|
156
|
-
const url = this.blobUrl(key);
|
|
157
|
-
const res = await this.fetchFn(url, { method: "HEAD", headers: await this.headers("HEAD", url, {}) });
|
|
158
|
-
if (res.status === 404) return { exists: false, metadata: {} };
|
|
159
|
-
if (!res.ok) throw new StoreError(`HEAD ${this.where(key)}: ${res.status}`);
|
|
160
|
-
const get = (n: string): string | undefined => res.headers?.get(n) ?? undefined;
|
|
161
|
-
const metadata: Record<string, string> = {};
|
|
162
|
-
const all = res.headers as unknown as { forEach?: (fn: (v: string, k: string) => void) => void } | undefined;
|
|
163
|
-
all?.forEach?.((v, k) => {
|
|
164
|
-
if (k.toLowerCase().startsWith("x-ms-meta-")) metadata[k.toLowerCase().slice("x-ms-meta-".length)] = v;
|
|
165
|
-
});
|
|
166
|
-
const etag = get("etag");
|
|
167
|
-
const leaseState = get("x-ms-lease-state");
|
|
168
|
-
return { exists: true, ...(etag ? { etag } : {}), ...(leaseState ? { leaseState } : {}), metadata };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Take a snapshot of a blob: its id, the time Azure gives it. */
|
|
172
|
-
async snapshot(key: string): Promise<string> {
|
|
173
|
-
const url = this.blobUrl(key, "comp=snapshot");
|
|
174
|
-
const res = await this.fetchFn(url, { method: "PUT", headers: await this.headers("PUT", url, { "content-length": "0" }) });
|
|
175
|
-
if (!res.ok) throw new StoreError(`snapshot of ${this.where(key)}: ${res.status} ${why(await res.text())}`);
|
|
176
|
-
const id = res.headers?.get("x-ms-snapshot");
|
|
177
|
-
if (!id) throw new StoreError(`snapshot of ${this.where(key)}: Azure answered without x-ms-snapshot`);
|
|
178
|
-
return id;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Write a block blob with metadata, under a lease when one holds it; with `ifNoneMatch`, only when there is none (false when one was there). */
|
|
182
|
-
async putBlob(key: string, body: string, contentType: string, options: { leaseId?: string; metadata?: Record<string, string>; ifNoneMatch?: boolean } = {}): Promise<boolean> {
|
|
183
|
-
const url = this.blobUrl(key);
|
|
184
|
-
const meta = Object.fromEntries(Object.entries(options.metadata ?? {}).map(([k, v]) => [`x-ms-meta-${k}`, v]));
|
|
185
|
-
const extra: Record<string, string> = {
|
|
186
|
-
"x-ms-blob-type": "BlockBlob",
|
|
187
|
-
"content-type": contentType,
|
|
188
|
-
"content-length": String(Buffer.byteLength(body)),
|
|
189
|
-
...meta,
|
|
190
|
-
...(options.leaseId ? { "x-ms-lease-id": options.leaseId } : {}),
|
|
191
|
-
...(options.ifNoneMatch ? { "if-none-match": "*" } : {}),
|
|
192
|
-
};
|
|
193
|
-
const res = await this.fetchFn(url, { method: "PUT", headers: await this.headers("PUT", url, extra), body });
|
|
194
|
-
if (options.ifNoneMatch && (res.status === 409 || res.status === 412)) return false;
|
|
195
|
-
if (!res.ok) throw new StoreError(`PUT ${this.where(key)}: ${res.status} ${why(await res.text())}`);
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Delete a blob. One that is not there is deleted already. */
|
|
200
|
-
async remove(key: string): Promise<void> {
|
|
201
|
-
const url = this.blobUrl(key);
|
|
202
|
-
const res = await this.fetchFn(url, { method: "DELETE", headers: await this.headers("DELETE", url, {}) });
|
|
203
|
-
if (!res.ok && res.status !== 404) throw new StoreError(`DELETE ${this.where(key)}: ${res.status} ${why(await res.text())}`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** A blob lease: acquire one that never expires with the id given (false when one holds it), or release it. */
|
|
207
|
-
async lease(key: string, action: "acquire" | "release", id: string): Promise<boolean> {
|
|
208
|
-
const url = this.blobUrl(key, "comp=lease");
|
|
209
|
-
const extra: Record<string, string> = { "x-ms-lease-action": action, "content-length": "0", ...(action === "acquire" ? { "x-ms-lease-duration": "-1", "x-ms-proposed-lease-id": id } : { "x-ms-lease-id": id }) };
|
|
210
|
-
const res = await this.fetchFn(url, { method: "PUT", headers: await this.headers("PUT", url, extra) });
|
|
211
|
-
if (action === "acquire" && res.status === 409) return false;
|
|
212
|
-
if (!res.ok) throw new StoreError(`${action} the lease of ${this.where(key)}: ${res.status} ${why(await res.text())}`);
|
|
213
|
-
return true;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** Set a blob's metadata, all of it, under its lease when one holds it. */
|
|
217
|
-
async setMetadata(key: string, metadata: Record<string, string>, leaseId?: string): Promise<void> {
|
|
218
|
-
const url = this.blobUrl(key, "comp=metadata");
|
|
219
|
-
const extra = { "content-length": "0", ...Object.fromEntries(Object.entries(metadata).map(([k, v]) => [`x-ms-meta-${k}`, v])), ...(leaseId ? { "x-ms-lease-id": leaseId } : {}) };
|
|
220
|
-
const res = await this.fetchFn(url, { method: "PUT", headers: await this.headers("PUT", url, extra) });
|
|
221
|
-
if (!res.ok) throw new StoreError(`set the metadata of ${this.where(key)}: ${res.status} ${why(await res.text())}`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
8
|
+
export { AZURE_AUTHORITY, AZURE_VERSION, AzureBlobClient, sharedKey, type AzureLocation, type AzureOidc, type AzureTarget, type BlobHead } from "@intentius/chant/object-store";
|