hammerkit 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/build.schema.json +49 -2
- package/dist/cache/backends/local-cache-backend.js +58 -1
- package/dist/cache/backends/registry/registry-client.js +264 -0
- package/dist/cache/backends/registry/registry-credentials.js +81 -0
- package/dist/cache/backends/registry/registry-reference.js +36 -0
- package/dist/cache/backends/registry-cache-backend.js +145 -0
- package/dist/cache/backends/s3-cache-backend.js +99 -46
- package/dist/cache/cache-backend-registry.js +2 -0
- package/dist/cache/cache-inventory.js +84 -0
- package/dist/cache/cache-sync.js +77 -0
- package/dist/cache/explain.js +214 -0
- package/dist/cache/last-resolved.js +48 -0
- package/dist/cache/read-only.js +15 -0
- package/dist/cache/retention.js +61 -0
- package/dist/cli.js +43 -0
- package/dist/docker/using-container.js +5 -1
- package/dist/executer/archive-task-entry.js +21 -0
- package/dist/executer/await-completed-dependencies.js +32 -14
- package/dist/executer/cache-metadata.js +9 -2
- package/dist/executer/docker-service.js +2 -1
- package/dist/executer/docker-task.js +2 -1
- package/dist/executer/dry-run.js +73 -0
- package/dist/executer/event-cache.js +6 -5
- package/dist/executer/execute-command.js +21 -3
- package/dist/executer/execute-work-service.js +10 -1
- package/dist/executer/execute-work-task.js +95 -25
- package/dist/executer/execute-work-tree.js +26 -1
- package/dist/executer/get-scheduler-execute-result.js +4 -1
- package/dist/executer/lazy-resolver.js +15 -3
- package/dist/executer/reset-work-tree.js +2 -0
- package/dist/executer/run-summary.js +102 -0
- package/dist/executer/scheduler/enqueue-next.js +15 -60
- package/dist/executer/scheduler/state-key.js +78 -0
- package/dist/kubernetes/await-running-state.js +73 -19
- package/dist/log.js +16 -6
- package/dist/logging/live-logger.js +2 -1
- package/dist/optimizer/calculate-checksum.js +8 -2
- package/dist/optimizer/get-work-cache-stats.js +66 -16
- package/dist/optimizer/work-task-cache-description.js +34 -5
- package/dist/planner/project-root.js +26 -0
- package/dist/planner/utils/append-work-service.js +2 -1
- package/dist/planner/utils/append-work-task.js +7 -1
- package/dist/planner/utils/parse-work-source.js +35 -30
- package/dist/planner/utils/portable-path.js +30 -0
- package/dist/planner/work-cache-id.js +2 -2
- package/dist/planner/work-graph-serializer.js +103 -0
- package/dist/planner/work-instance-id.js +15 -0
- package/dist/planner/work-runtime-docker.js +53 -15
- package/dist/planner/work-runtime-kubernetes.js +35 -3
- package/dist/planner/work-runtime-local.js +9 -3
- package/dist/planner/work-service-id.js +16 -4
- package/dist/program.js +235 -1
- package/dist/schema/build-file-base-task-schema.js +4 -0
- package/dist/schema/cache-schema.js +21 -2
- package/dist/schema/duration-schema.js +18 -0
- package/dist/schema/reference-parser.js +3 -0
- package/dist/schema/size-schema.js +18 -0
- package/dist/schema/work-scope-parser.js +15 -2
- package/dist/utils/units.js +87 -0
- package/package.json +12 -1
- package/.agents/FEEDBACK.md +0 -74
- package/.agents/skills/before-commit/SKILL.md +0 -101
- package/.agents/skills/do-the-work/SKILL.md +0 -64
- package/.agents/skills/fix-ci/SKILL.md +0 -98
- package/.agents/skills/speckit-git-commit/SKILL.md +0 -53
- package/.agents/skills/speckit-git-feature/SKILL.md +0 -72
- package/.agents/skills/speckit-git-initialize/SKILL.md +0 -54
- package/.agents/skills/speckit-git-remote/SKILL.md +0 -50
- package/.agents/skills/speckit-git-validate/SKILL.md +0 -54
- package/.devcontainer/Dockerfile +0 -5
- package/.devcontainer/devcontainer.json +0 -40
- package/.gitbook.yaml +0 -5
- package/.specify/extensions/.registry +0 -37
- package/.specify/extensions/git/README.md +0 -100
- package/.specify/extensions/git/commands/speckit.git.commit.md +0 -48
- package/.specify/extensions/git/commands/speckit.git.feature.md +0 -67
- package/.specify/extensions/git/commands/speckit.git.initialize.md +0 -49
- package/.specify/extensions/git/commands/speckit.git.remote.md +0 -45
- package/.specify/extensions/git/commands/speckit.git.validate.md +0 -49
- package/.specify/extensions/git/config-template.yml +0 -62
- package/.specify/extensions/git/extension.yml +0 -140
- package/.specify/extensions/git/git-config.yml +0 -62
- package/.specify/extensions.yml +0 -149
- package/.specify/feature.json +0 -3
- package/.specify/init-options.json +0 -10
- package/.specify/integration.json +0 -15
- package/.specify/integrations/claude.manifest.json +0 -16
- package/.specify/integrations/speckit.manifest.json +0 -17
- package/.specify/memory/constitution.md +0 -162
- package/.specify/templates/checklist-template.md +0 -40
- package/.specify/templates/constitution-template.md +0 -50
- package/.specify/templates/plan-template.md +0 -113
- package/.specify/templates/spec-template.md +0 -131
- package/.specify/templates/tasks-template.md +0 -252
- package/.specify/workflows/speckit/workflow.yml +0 -77
- package/.specify/workflows/workflow-registry.json +0 -13
- package/AGENTS.md +0 -94
- package/Dockerfile +0 -32
- package/FOLLOWUPS.md +0 -34
- package/SUMMARY.md +0 -68
- package/VALUES.md +0 -53
- package/dist/cache/auto-pull.spec.js +0 -123
- package/dist/cache/backends/local-cache-backend.spec.js +0 -72
- package/dist/cache/backends/s3-cache-backend.spec.js +0 -118
- package/dist/cache/cache-backend-registry.spec.js +0 -22
- package/dist/cache/resolve-cache.spec.js +0 -52
- package/dist/cli.spec.js +0 -23
- package/dist/console/get-logger.spec.js +0 -23
- package/dist/docker/get-default-platform.spec.js +0 -29
- package/dist/docker/longest-common-prefix.spec.js +0 -26
- package/dist/docker/package.spec.js +0 -145
- package/dist/docker/remove-container.spec.js +0 -32
- package/dist/docker/using-container.spec.js +0 -90
- package/dist/environment/merge-environment-variables.spec.js +0 -24
- package/dist/environment/replace-env-variables.spec.js +0 -47
- package/dist/executer/abort.spec.js +0 -18
- package/dist/executer/environment-mock.spec.js +0 -22
- package/dist/executer/execute.spec.js +0 -51
- package/dist/executer/get-service-containers.spec.js +0 -59
- package/dist/executer/get-service-env-hints.spec.js +0 -65
- package/dist/executer/label-values.spec.js +0 -61
- package/dist/executer/lazy-resolver.spec.js +0 -20
- package/dist/executer/process-manager.spec.js +0 -54
- package/dist/executer/scheduler/enqueue-next.spec.js +0 -136
- package/dist/executer/state-resolver.spec.js +0 -37
- package/dist/executer/state.spec.js +0 -66
- package/dist/executer/states.spec.js +0 -14
- package/dist/file/get-file-context.spec.js +0 -178
- package/dist/file/move-files.spec.js +0 -48
- package/dist/kubernetes/ensure-http-route.spec.js +0 -70
- package/dist/kubernetes/ensure-namespace.spec.js +0 -39
- package/dist/kubernetes/resolve-pod-name.spec.js +0 -81
- package/dist/log.spec.js +0 -131
- package/dist/logging/grouped-logger.spec.js +0 -84
- package/dist/logging/interactive-logger.spec.js +0 -57
- package/dist/logging/live-logger.spec.js +0 -37
- package/dist/logging/loggers.spec.js +0 -154
- package/dist/optimizer/calculate-checksum.spec.js +0 -34
- package/dist/optimizer/get-cache-directory.spec.js +0 -59
- package/dist/optimizer/get-work-cache-stats.spec.js +0 -79
- package/dist/optimizer/work-task-cache-description.spec.js +0 -45
- package/dist/parser/default-build-file.spec.js +0 -48
- package/dist/parser/parse-label-arguments.spec.js +0 -27
- package/dist/parser/read-build-file.spec.js +0 -89
- package/dist/parser/read-env-file.spec.js +0 -42
- package/dist/planner/utils/append-work-service.spec.js +0 -71
- package/dist/planner/utils/get-container-user.spec.js +0 -30
- package/dist/planner/utils/get-default-kube-config.spec.js +0 -11
- package/dist/planner/utils/normalize-path.spec.js +0 -24
- package/dist/planner/utils/parse-work-command.spec.js +0 -21
- package/dist/planner/utils/parse-work-generate.spec.js +0 -45
- package/dist/planner/utils/parse-work-mount.spec.js +0 -62
- package/dist/planner/utils/parse-work-mounts.spec.js +0 -19
- package/dist/planner/utils/parse-work-port.spec.js +0 -24
- package/dist/planner/utils/parse-work-ports.spec.js +0 -24
- package/dist/planner/utils/parse-work-source.spec.js +0 -58
- package/dist/planner/utils/parse-work-volume.spec.js +0 -18
- package/dist/planner/utils/plan-work-tasks.spec.js +0 -30
- package/dist/planner/utils/plan-work-volume.spec.js +0 -33
- package/dist/planner/utils/split-name.spec.js +0 -16
- package/dist/planner/utils/template-value.spec.js +0 -14
- package/dist/planner/validate.spec.js +0 -251
- package/dist/planner/work-cache-id.spec.js +0 -173
- package/dist/planner/work-runtime-local.spec.js +0 -193
- package/dist/program-commands.spec.js +0 -84
- package/dist/program.spec.js +0 -43
- package/dist/schema/build-file-schema.spec.js +0 -61
- package/dist/schema/doc-build-files.spec.js +0 -223
- package/dist/schema/parse-error.spec.js +0 -18
- package/dist/schema/reference-parser.spec.js +0 -83
- package/dist/schema/schema-parser.spec.js +0 -80
- package/dist/schema/type-guards.spec.js +0 -23
- package/dist/schema/work-scope-parser.spec.js +0 -66
- package/dist/service/update-service-status.spec.js +0 -27
- package/dist/testing/ensure-local-registry.js +0 -134
- package/dist/testing/example-test-suite.js +0 -63
- package/dist/testing/expect.js +0 -82
- package/dist/testing/get-test-suite.js +0 -9
- package/dist/testing/http-get.js +0 -21
- package/dist/testing/integration/cache.spec.js +0 -49
- package/dist/testing/integration/cancellation.spec.js +0 -45
- package/dist/testing/integration/clean.spec.js +0 -43
- package/dist/testing/integration/cmd.spec.js +0 -15
- package/dist/testing/integration/concurrency.spec.js +0 -19
- package/dist/testing/integration/docker-package-registry.spec.js +0 -44
- package/dist/testing/integration/docker-service-crash.spec.js +0 -36
- package/dist/testing/integration/docker.spec.js +0 -18
- package/dist/testing/integration/env.spec.js +0 -48
- package/dist/testing/integration/environment.spec.js +0 -97
- package/dist/testing/integration/error.spec.js +0 -19
- package/dist/testing/integration/export.spec.js +0 -22
- package/dist/testing/integration/extend.spec.js +0 -29
- package/dist/testing/integration/glob.spec.js +0 -40
- package/dist/testing/integration/include.spec.js +0 -31
- package/dist/testing/integration/invalid.spec.js +0 -17
- package/dist/testing/integration/invalid_loop.spec.js +0 -56
- package/dist/testing/integration/k8s-state-cache.spec.js +0 -41
- package/dist/testing/integration/kubernetes-service.spec.js +0 -148
- package/dist/testing/integration/kubernetes.spec.js +0 -99
- package/dist/testing/integration/labels.spec.js +0 -42
- package/dist/testing/integration/local.spec.js +0 -14
- package/dist/testing/integration/monorepo.spec.js +0 -15
- package/dist/testing/integration/multi-line.spec.js +0 -17
- package/dist/testing/integration/reference.spec.js +0 -21
- package/dist/testing/integration/services.spec.js +0 -38
- package/dist/testing/integration/store.spec.js +0 -52
- package/dist/testing/integration/unknown.spec.js +0 -28
- package/dist/testing/integration/validate.spec.js +0 -160
- package/dist/testing/integration/watch-dependency.spec.js +0 -33
- package/dist/testing/integration/watch.spec.js +0 -45
- package/dist/testing/requires-kubernetes.js +0 -18
- package/dist/testing/requires-linux-containers.js +0 -18
- package/dist/testing/requires-windows-containers.js +0 -18
- package/dist/testing/test-case.js +0 -75
- package/dist/testing/test-streams.js +0 -28
- package/dist/testing/test-suite-setup.js +0 -3
- package/dist/testing/test-suite.js +0 -3
- package/dist/testing/testing-timeout.js +0 -32
- package/dist/utils/abort-event.spec.js +0 -46
- package/dist/utils/buffer-context.spec.js +0 -38
- package/dist/utils/ci.spec.js +0 -53
- package/dist/utils/debouncer.spec.js +0 -58
- package/dist/utils/emitter.spec.js +0 -34
- package/dist/utils/empty-writable.spec.js +0 -18
- package/dist/utils/environment-config.spec.js +0 -26
- package/dist/utils/fail-never.spec.js +0 -9
- package/dist/utils/sleep.spec.js +0 -22
- package/dist/version.spec.js +0 -12
- package/specs/build-file/caches/spec.md +0 -114
- package/specs/build-file/environment-variables/spec.md +0 -82
- package/specs/build-file/includes/spec.md +0 -60
- package/specs/build-file/references/spec.md +0 -56
- package/specs/build-file/spec.md +0 -62
- package/specs/cli/clean/spec.md +0 -85
- package/specs/cli/down/spec.md +0 -85
- package/specs/cli/execute/spec.md +0 -101
- package/specs/cli/init/spec.md +0 -64
- package/specs/cli/ls/spec.md +0 -66
- package/specs/cli/package/spec.md +0 -110
- package/specs/cli/spec.md +0 -73
- package/specs/cli/store-restore/spec.md +0 -74
- package/specs/cli/up/spec.md +0 -80
- package/specs/cli/validate/spec.md +0 -70
- package/specs/labels/spec.md +0 -91
- package/specs/service/container/spec.md +0 -120
- package/specs/service/kubernetes/spec.md +0 -97
- package/specs/service/spec.md +0 -105
- package/specs/task/caching/spec.md +0 -107
- package/specs/task/container/spec.md +0 -118
- package/specs/task/dependencies/spec.md +0 -70
- package/specs/task/extending/spec.md +0 -74
- package/specs/task/kubernetes/spec.md +0 -125
- package/specs/task/needs/spec.md +0 -59
- package/specs/task/spec.md +0 -87
- package/specs/task/watching/spec.md +0 -69
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRegistryCacheBackend = exports.entryTag = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const os_1 = require("os");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const tar_1 = require("tar");
|
|
8
|
+
const registry_reference_1 = require("./registry/registry-reference");
|
|
9
|
+
const registry_client_1 = require("./registry/registry-client");
|
|
10
|
+
const MAX_TAG_LENGTH = 128;
|
|
11
|
+
// The deterministic tag for one entry. A task id (sha1) plus a state key (md5)
|
|
12
|
+
// fits comfortably; anything longer is hashed rather than rejected.
|
|
13
|
+
function entryTag(taskId, stateKey) {
|
|
14
|
+
const tag = `${taskId}-${stateKey}`;
|
|
15
|
+
if (tag.length <= MAX_TAG_LENGTH && /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(tag)) {
|
|
16
|
+
return tag;
|
|
17
|
+
}
|
|
18
|
+
return `${taskId.substring(0, 40)}-${(0, crypto_1.createHash)('sha256').update(stateKey).digest('hex').substring(0, 64)}`;
|
|
19
|
+
}
|
|
20
|
+
exports.entryTag = entryTag;
|
|
21
|
+
async function withTempDir(environment, fn) {
|
|
22
|
+
const dir = (0, path_1.join)((0, os_1.tmpdir)(), `hammerkit-registry-${(0, crypto_1.randomUUID)()}`);
|
|
23
|
+
await environment.file.createDirectory(dir);
|
|
24
|
+
try {
|
|
25
|
+
return await fn(dir);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
await environment.file.remove(dir);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Stores each cache entry as a standard single-layer OCI image (ADR-0005): the
|
|
32
|
+
// layer is an uncompressed tar of the entry directory (whose archives are
|
|
33
|
+
// already gzipped), tagged `<taskId>-<stateKey>`. The manifest is written only
|
|
34
|
+
// after both blobs are committed, so a partial upload is never visible to
|
|
35
|
+
// has()/pull(); a re-push of the same content is an identical overwrite.
|
|
36
|
+
function createRegistryCacheBackend(spec) {
|
|
37
|
+
const reference = (0, registry_reference_1.parseRegistryReference)(spec.repository, spec.insecure);
|
|
38
|
+
const clients = new WeakMap();
|
|
39
|
+
const client = (environment) => {
|
|
40
|
+
let instance = clients.get(environment);
|
|
41
|
+
if (!instance) {
|
|
42
|
+
instance = new registry_client_1.RegistryClient(reference, environment);
|
|
43
|
+
clients.set(environment, instance);
|
|
44
|
+
}
|
|
45
|
+
return instance;
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
type: 'registry',
|
|
49
|
+
async has(taskId, stateKey, environment) {
|
|
50
|
+
return client(environment).manifestExists(entryTag(taskId, stateKey));
|
|
51
|
+
},
|
|
52
|
+
async pull(taskId, stateKey, into, environment) {
|
|
53
|
+
const registry = client(environment);
|
|
54
|
+
const manifest = await registry.getManifest(entryTag(taskId, stateKey));
|
|
55
|
+
if (!manifest) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (manifest.layers.length !== 1) {
|
|
59
|
+
throw new Error(`cache entry ${entryTag(taskId, stateKey)} is not a single-layer image`);
|
|
60
|
+
}
|
|
61
|
+
return withTempDir(environment, async (dir) => {
|
|
62
|
+
const layerFile = (0, path_1.join)(dir, 'layer.tar');
|
|
63
|
+
await registry.downloadBlob(manifest.layers[0], layerFile);
|
|
64
|
+
await environment.file.createDirectory(into);
|
|
65
|
+
// node-tar strips absolute paths and `..` segments by default, so a
|
|
66
|
+
// crafted layer cannot write outside `into`
|
|
67
|
+
await (0, tar_1.extract)({ file: layerFile, cwd: into });
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
async push(taskId, stateKey, from, environment) {
|
|
72
|
+
const registry = client(environment);
|
|
73
|
+
await withTempDir(environment, async (dir) => {
|
|
74
|
+
const layerFile = (0, path_1.join)(dir, 'layer.tar');
|
|
75
|
+
const files = (await environment.file.listFiles(from)).sort();
|
|
76
|
+
// portable + mtime-free so identical entries produce identical digests
|
|
77
|
+
// `noMtime` is supported by tar 6 but missing from its type definitions
|
|
78
|
+
await (0, tar_1.create)({ file: layerFile, cwd: from, portable: true, noMtime: true }, files);
|
|
79
|
+
const layer = await (0, registry_client_1.digestFile)(layerFile);
|
|
80
|
+
const config = Buffer.from(JSON.stringify({
|
|
81
|
+
architecture: 'amd64',
|
|
82
|
+
os: 'linux',
|
|
83
|
+
created: new Date().toISOString(),
|
|
84
|
+
rootfs: { type: 'layers', diff_ids: [layer.digest] },
|
|
85
|
+
config: { Labels: { 'dev.hammerkit.task-id': taskId, 'dev.hammerkit.state-key': stateKey } },
|
|
86
|
+
}));
|
|
87
|
+
const configDigest = (0, registry_client_1.digestBuffer)(config);
|
|
88
|
+
await registry.uploadBlob(layer.digest, layer.size, { file: layerFile });
|
|
89
|
+
await registry.uploadBlob(configDigest.digest, configDigest.size, { buffer: config });
|
|
90
|
+
const manifest = {
|
|
91
|
+
schemaVersion: 2,
|
|
92
|
+
mediaType: registry_client_1.OCI_MANIFEST,
|
|
93
|
+
config: { mediaType: registry_client_1.OCI_CONFIG, ...configDigest },
|
|
94
|
+
layers: [{ mediaType: registry_client_1.OCI_LAYER_TAR, ...layer }],
|
|
95
|
+
};
|
|
96
|
+
await registry.putManifest(entryTag(taskId, stateKey), manifest);
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
async clear(taskId, environment) {
|
|
100
|
+
const registry = client(environment);
|
|
101
|
+
const tags = (await registry.listTags()).filter((tag) => tag.startsWith(`${taskId}-`));
|
|
102
|
+
for (const tag of tags) {
|
|
103
|
+
const digest = await registry.manifestDigest(tag);
|
|
104
|
+
if (digest) {
|
|
105
|
+
await registry.deleteManifest(digest);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
// Entries from the repository's tags: size from the manifest, creation time
|
|
110
|
+
// from the image config. Registries keep no last-use marker, so retention
|
|
111
|
+
// works from age and size.
|
|
112
|
+
async list(environment) {
|
|
113
|
+
const registry = client(environment);
|
|
114
|
+
const entries = [];
|
|
115
|
+
for (const tag of await registry.listTags()) {
|
|
116
|
+
const match = /^([0-9a-f]{40})-(.+)$/.exec(tag);
|
|
117
|
+
if (!match) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const manifest = await registry.getManifest(tag);
|
|
121
|
+
if (!manifest) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const config = await registry.getBlobJson(manifest.config.digest);
|
|
125
|
+
entries.push({
|
|
126
|
+
taskId: match[1],
|
|
127
|
+
stateKey: match[2],
|
|
128
|
+
size: manifest.config.size + manifest.layers.reduce((sum, layer) => sum + layer.size, 0),
|
|
129
|
+
createdAt: config.created ? Date.parse(config.created) : null,
|
|
130
|
+
lastAccessedAt: null,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return entries;
|
|
134
|
+
},
|
|
135
|
+
async remove(taskId, stateKey, environment) {
|
|
136
|
+
const registry = client(environment);
|
|
137
|
+
const digest = await registry.manifestDigest(entryTag(taskId, stateKey));
|
|
138
|
+
if (digest) {
|
|
139
|
+
await registry.deleteManifest(digest);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
exports.createRegistryCacheBackend = createRegistryCacheBackend;
|
|
145
|
+
//# sourceMappingURL=registry-cache-backend.js.map
|
|
@@ -14,6 +14,14 @@ function isWithin(into, filename) {
|
|
|
14
14
|
const target = (0, path_1.resolve)(base, filename);
|
|
15
15
|
return target === base || target.startsWith(base + path_1.sep);
|
|
16
16
|
}
|
|
17
|
+
// Only a missing object is a cache miss. Anything else (unreachable endpoint,
|
|
18
|
+
// denied credentials) is rethrown: the inline cache lookup catches it and
|
|
19
|
+
// degrades to a miss with a warning, while an explicit `cache pull`/`push`
|
|
20
|
+
// reports it and fails.
|
|
21
|
+
function isNotFound(e) {
|
|
22
|
+
const err = e;
|
|
23
|
+
return err?.name === 'NotFound' || err?.name === 'NoSuchKey' || err?.$metadata?.httpStatusCode === 404;
|
|
24
|
+
}
|
|
17
25
|
function createS3CacheBackend(spec) {
|
|
18
26
|
const client = new client_s3_1.S3Client({
|
|
19
27
|
region: spec.region,
|
|
@@ -30,6 +38,28 @@ function createS3CacheBackend(spec) {
|
|
|
30
38
|
function taskPrefix(taskId) {
|
|
31
39
|
return [prefix, taskId].filter((p) => p.length > 0).join('/') + '/';
|
|
32
40
|
}
|
|
41
|
+
async function listObjects(listPrefix) {
|
|
42
|
+
const objects = [];
|
|
43
|
+
let token;
|
|
44
|
+
do {
|
|
45
|
+
const listed = await client.send(new client_s3_1.ListObjectsV2Command({ Bucket: spec.bucket, Prefix: listPrefix, ContinuationToken: token }));
|
|
46
|
+
for (const obj of listed.Contents ?? []) {
|
|
47
|
+
if (obj.Key) {
|
|
48
|
+
objects.push({ Key: obj.Key, Size: obj.Size ?? 0, LastModified: obj.LastModified });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
token = listed.IsTruncated ? listed.NextContinuationToken : undefined;
|
|
52
|
+
} while (token);
|
|
53
|
+
return objects;
|
|
54
|
+
}
|
|
55
|
+
async function deleteObjects(keys) {
|
|
56
|
+
for (let i = 0; i < keys.length; i += 1000) {
|
|
57
|
+
await client.send(new client_s3_1.DeleteObjectsCommand({
|
|
58
|
+
Bucket: spec.bucket,
|
|
59
|
+
Delete: { Objects: keys.slice(i, i + 1000).map((Key) => ({ Key })) },
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
33
63
|
return {
|
|
34
64
|
type: 's3',
|
|
35
65
|
async has(taskId, stateKey) {
|
|
@@ -40,49 +70,50 @@ function createS3CacheBackend(spec) {
|
|
|
40
70
|
}));
|
|
41
71
|
return true;
|
|
42
72
|
}
|
|
43
|
-
catch {
|
|
44
|
-
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (isNotFound(e)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
throw e;
|
|
45
78
|
}
|
|
46
79
|
},
|
|
47
80
|
async pull(taskId, stateKey, into, environment) {
|
|
48
81
|
try {
|
|
49
|
-
|
|
50
|
-
.send(new client_s3_1.HeadObjectCommand({
|
|
82
|
+
await client.send(new client_s3_1.HeadObjectCommand({
|
|
51
83
|
Bucket: spec.bucket,
|
|
52
84
|
Key: keyFor(taskId, stateKey, 'stats.json'),
|
|
53
|
-
}))
|
|
54
|
-
|
|
55
|
-
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (isNotFound(e)) {
|
|
56
89
|
return false;
|
|
57
90
|
}
|
|
58
|
-
|
|
59
|
-
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
await environment.file.createDirectory(into);
|
|
94
|
+
const listed = await client.send(new client_s3_1.ListObjectsV2Command({
|
|
95
|
+
Bucket: spec.bucket,
|
|
96
|
+
Prefix: dirPrefix(taskId, stateKey),
|
|
97
|
+
}));
|
|
98
|
+
for (const obj of listed.Contents ?? []) {
|
|
99
|
+
if (!obj.Key)
|
|
100
|
+
continue;
|
|
101
|
+
const filename = obj.Key.substring(dirPrefix(taskId, stateKey).length);
|
|
102
|
+
if (!filename)
|
|
103
|
+
continue;
|
|
104
|
+
if (!isWithin(into, filename)) {
|
|
105
|
+
environment.console.warn(`skipping cache object outside restore dir: ${obj.Key}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const body = await client.send(new client_s3_1.GetObjectCommand({
|
|
60
109
|
Bucket: spec.bucket,
|
|
61
|
-
|
|
110
|
+
Key: obj.Key,
|
|
62
111
|
}));
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
continue;
|
|
66
|
-
const filename = obj.Key.substring(dirPrefix(taskId, stateKey).length);
|
|
67
|
-
if (!filename)
|
|
68
|
-
continue;
|
|
69
|
-
if (!isWithin(into, filename)) {
|
|
70
|
-
environment.console.warn(`skipping cache object outside restore dir: ${obj.Key}`);
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
const body = await client.send(new client_s3_1.GetObjectCommand({
|
|
74
|
-
Bucket: spec.bucket,
|
|
75
|
-
Key: obj.Key,
|
|
76
|
-
}));
|
|
77
|
-
if (body.Body instanceof stream_1.Readable) {
|
|
78
|
-
await environment.file.writeStream((0, path_1.join)(into, filename), body.Body);
|
|
79
|
-
}
|
|
112
|
+
if (body.Body instanceof stream_1.Readable) {
|
|
113
|
+
await environment.file.writeStream((0, path_1.join)(into, filename), body.Body);
|
|
80
114
|
}
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
return false;
|
|
85
115
|
}
|
|
116
|
+
return true;
|
|
86
117
|
},
|
|
87
118
|
async push(taskId, stateKey, from, environment) {
|
|
88
119
|
const files = await environment.file.listFiles(from);
|
|
@@ -101,22 +132,44 @@ function createS3CacheBackend(spec) {
|
|
|
101
132
|
}
|
|
102
133
|
},
|
|
103
134
|
async clear(taskId) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
135
|
+
await deleteObjects((await listObjects(taskPrefix(taskId))).map((o) => o.Key));
|
|
136
|
+
},
|
|
137
|
+
// Entries from object metadata: size is the sum of the entry's objects,
|
|
138
|
+
// created is when stats.json (written last) landed. S3 keeps no last-use
|
|
139
|
+
// marker, so retention on S3 works from age and size.
|
|
140
|
+
async list() {
|
|
141
|
+
const root = prefix.length > 0 ? `${prefix}/` : '';
|
|
142
|
+
const entries = new Map();
|
|
143
|
+
for (const obj of await listObjects(root)) {
|
|
144
|
+
const [taskId, stateKey, ...rest] = obj.Key.substring(root.length).split('/');
|
|
145
|
+
if (!taskId || !stateKey || rest.length === 0) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const id = `${taskId}/${stateKey}`;
|
|
149
|
+
const entry = entries.get(id) ?? {
|
|
150
|
+
taskId,
|
|
151
|
+
stateKey,
|
|
152
|
+
size: 0,
|
|
153
|
+
createdAt: null,
|
|
154
|
+
lastAccessedAt: null,
|
|
155
|
+
complete: false,
|
|
156
|
+
};
|
|
157
|
+
entry.size = (entry.size ?? 0) + obj.Size;
|
|
158
|
+
if (rest.join('/') === 'stats.json') {
|
|
159
|
+
entry.complete = true;
|
|
160
|
+
entry.createdAt = obj.LastModified?.getTime() ?? null;
|
|
117
161
|
}
|
|
118
|
-
|
|
119
|
-
}
|
|
162
|
+
entries.set(id, entry);
|
|
163
|
+
}
|
|
164
|
+
return [...entries.values()].filter((entry) => entry.complete).map(({ complete, ...entry }) => entry); // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
165
|
+
},
|
|
166
|
+
async remove(taskId, stateKey) {
|
|
167
|
+
// stats.json first, so a concurrent reader sees the entry as missing
|
|
168
|
+
// rather than half-deleted
|
|
169
|
+
const objects = (await listObjects(dirPrefix(taskId, stateKey))).map((o) => o.Key);
|
|
170
|
+
const stats = objects.filter((key) => key.endsWith('/stats.json'));
|
|
171
|
+
await deleteObjects(stats);
|
|
172
|
+
await deleteObjects(objects.filter((key) => !stats.includes(key)));
|
|
120
173
|
},
|
|
121
174
|
};
|
|
122
175
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.createCacheBackend = exports.registerCacheBackend = void 0;
|
|
4
4
|
const local_cache_backend_1 = require("./backends/local-cache-backend");
|
|
5
5
|
const s3_cache_backend_1 = require("./backends/s3-cache-backend");
|
|
6
|
+
const registry_cache_backend_1 = require("./backends/registry-cache-backend");
|
|
6
7
|
const factories = new Map();
|
|
7
8
|
function registerCacheBackend(type, factory) {
|
|
8
9
|
factories.set(type, factory);
|
|
@@ -18,4 +19,5 @@ function createCacheBackend(spec) {
|
|
|
18
19
|
exports.createCacheBackend = createCacheBackend;
|
|
19
20
|
registerCacheBackend('local', (spec) => (0, local_cache_backend_1.createLocalCacheBackend)(spec));
|
|
20
21
|
registerCacheBackend('s3', (spec) => (0, s3_cache_backend_1.createS3CacheBackend)(spec));
|
|
22
|
+
registerCacheBackend('registry', (spec) => (0, registry_cache_backend_1.createRegistryCacheBackend)(spec));
|
|
21
23
|
//# sourceMappingURL=cache-backend-registry.js.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.autoPrune = exports.pruneCache = exports.hasPolicy = exports.retentionPolicyOf = exports.listCache = void 0;
|
|
4
|
+
const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
|
|
5
|
+
const cache_sync_1 = require("./cache-sync");
|
|
6
|
+
const retention_1 = require("./retention");
|
|
7
|
+
const resolve_cache_1 = require("./resolve-cache");
|
|
8
|
+
const units_1 = require("../utils/units");
|
|
9
|
+
function catalogOf(workTree) {
|
|
10
|
+
return workTree.caches ?? (0, resolve_cache_1.withBuiltinCaches)(undefined);
|
|
11
|
+
}
|
|
12
|
+
function taskNames(workTree) {
|
|
13
|
+
const names = new Map();
|
|
14
|
+
for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
|
|
15
|
+
names.set(task.id(), task.name);
|
|
16
|
+
}
|
|
17
|
+
return names;
|
|
18
|
+
}
|
|
19
|
+
// List the entries a named cache (default: the machine-local `default` cache)
|
|
20
|
+
// holds, labelled with the task names of this build file where they match.
|
|
21
|
+
async function listCache(workTree, cacheName, environment) {
|
|
22
|
+
const name = cacheName ?? resolve_cache_1.DEFAULT_CACHE_NAME;
|
|
23
|
+
const backend = (0, cache_sync_1.resolveRemoteBackend)(workTree, name);
|
|
24
|
+
if (!backend.list) {
|
|
25
|
+
throw new Error(`the ${backend.type} backend of cache "${name}" cannot list its entries`);
|
|
26
|
+
}
|
|
27
|
+
const names = taskNames(workTree);
|
|
28
|
+
return (await backend.list(environment)).map((entry) => ({ ...entry, taskName: names.get(entry.taskId) ?? null }));
|
|
29
|
+
}
|
|
30
|
+
exports.listCache = listCache;
|
|
31
|
+
// The retention policy of a cache: its declared `retention` block, with any
|
|
32
|
+
// command-line values taking precedence.
|
|
33
|
+
function retentionPolicyOf(workTree, cacheName, overrides) {
|
|
34
|
+
const declared = catalogOf(workTree)[cacheName ?? resolve_cache_1.DEFAULT_CACHE_NAME]?.retention;
|
|
35
|
+
return {
|
|
36
|
+
maxAge: overrides.maxAge ?? (declared?.maxAge ? (0, units_1.parseDuration)(declared.maxAge) : undefined),
|
|
37
|
+
maxSize: overrides.maxSize ?? (declared?.maxSize ? (0, units_1.parseSize)(declared.maxSize) : undefined),
|
|
38
|
+
keepPerTask: overrides.keepPerTask ?? declared?.keepPerTask,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
exports.retentionPolicyOf = retentionPolicyOf;
|
|
42
|
+
function hasPolicy(policy) {
|
|
43
|
+
return policy.maxAge !== undefined || policy.maxSize !== undefined || policy.keepPerTask !== undefined;
|
|
44
|
+
}
|
|
45
|
+
exports.hasPolicy = hasPolicy;
|
|
46
|
+
// Apply a retention policy to a cache. Only ever touches the cache it is given:
|
|
47
|
+
// a remote is pruned only when named explicitly (ADR-0001).
|
|
48
|
+
async function pruneCache(workTree, cacheName, policy, options, environment) {
|
|
49
|
+
const name = cacheName ?? resolve_cache_1.DEFAULT_CACHE_NAME;
|
|
50
|
+
const backend = (0, cache_sync_1.resolveRemoteBackend)(workTree, name);
|
|
51
|
+
const entries = await listCache(workTree, name, environment);
|
|
52
|
+
const plan = (0, retention_1.planRetention)(entries, policy, Date.now());
|
|
53
|
+
if (!options.dryRun && plan.evict.length > 0) {
|
|
54
|
+
if (!backend.remove) {
|
|
55
|
+
throw new Error(`the ${backend.type} backend of cache "${name}" cannot remove entries`);
|
|
56
|
+
}
|
|
57
|
+
for (const entry of plan.evict) {
|
|
58
|
+
await backend.remove(entry.taskId, entry.stateKey, environment);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return plan;
|
|
62
|
+
}
|
|
63
|
+
exports.pruneCache = pruneCache;
|
|
64
|
+
// After a successful run: prune every machine-local cache the run used that
|
|
65
|
+
// declares a retention policy. Remote caches are never pruned implicitly.
|
|
66
|
+
async function autoPrune(workTree, environment) {
|
|
67
|
+
const catalog = catalogOf(workTree);
|
|
68
|
+
const used = new Set();
|
|
69
|
+
for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
|
|
70
|
+
used.add(task.data.caching.name);
|
|
71
|
+
}
|
|
72
|
+
const results = [];
|
|
73
|
+
for (const name of used) {
|
|
74
|
+
const spec = catalog[name];
|
|
75
|
+
if (!spec?.retention || spec.backend.type !== 'local') {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const policy = retentionPolicyOf(workTree, name, {});
|
|
79
|
+
results.push({ cacheName: name, plan: await pruneCache(workTree, name, policy, { dryRun: false }, environment) });
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
83
|
+
exports.autoPrune = autoPrune;
|
|
84
|
+
//# sourceMappingURL=cache-inventory.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.syncCache = exports.resolveRemoteBackend = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const os_1 = require("os");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
|
|
8
|
+
const state_key_1 = require("../executer/scheduler/state-key");
|
|
9
|
+
const resolve_cache_1 = require("./resolve-cache");
|
|
10
|
+
const archive_task_entry_1 = require("../executer/archive-task-entry");
|
|
11
|
+
function resolveRemoteBackend(workTree, remote) {
|
|
12
|
+
// resolveCache throws a clear "cache ... is not defined" error for unknown names
|
|
13
|
+
return (0, resolve_cache_1.resolveCache)({ name: remote }, workTree.caches ?? (0, resolve_cache_1.withBuiltinCaches)(undefined), `--remote ${remote}`).backend;
|
|
14
|
+
}
|
|
15
|
+
exports.resolveRemoteBackend = resolveRemoteBackend;
|
|
16
|
+
// Move cache entries between each in-scope task's own cache and a named remote,
|
|
17
|
+
// executing nothing (ADR-0001). The entries moved are those for the tasks'
|
|
18
|
+
// *current* state keys, computed by the same engine the scheduler uses, so a
|
|
19
|
+
// pull pre-warms exactly what the next build looks up. The work tree already
|
|
20
|
+
// contains the transitive dependencies of the selected scope.
|
|
21
|
+
//
|
|
22
|
+
// Source absence is best-effort ('missing'); any backend error propagates, so
|
|
23
|
+
// an explicit pull/push reports an unreachable remote instead of degrading to a
|
|
24
|
+
// miss like the inline cache lookup does.
|
|
25
|
+
async function syncCache(workTree, options, environment) {
|
|
26
|
+
const remote = resolveRemoteBackend(workTree, options.remote);
|
|
27
|
+
const results = [];
|
|
28
|
+
for (const item of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
|
|
29
|
+
const { stateKey, resolved, provable } = await (0, state_key_1.computeStateKey)(item, options.cacheDefault, environment);
|
|
30
|
+
const local = resolved.backend;
|
|
31
|
+
const result = (status) => ({
|
|
32
|
+
taskId: item.id(),
|
|
33
|
+
taskName: item.name,
|
|
34
|
+
stateKey,
|
|
35
|
+
status,
|
|
36
|
+
});
|
|
37
|
+
if (resolved.method === 'none' || local === remote || !provable) {
|
|
38
|
+
results.push(result('skipped'));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const [source, destination] = options.direction === 'pull' ? [remote, local] : [local, remote];
|
|
42
|
+
if (await destination.has(item.id(), stateKey, environment)) {
|
|
43
|
+
results.push(result('present'));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
// Outputs that are up to date in this checkout but were never stored in a
|
|
47
|
+
// backend (e.g. restored by the runtime's own state) are packaged directly.
|
|
48
|
+
if (options.direction === 'push' && !(await local.has(item.id(), stateKey, environment))) {
|
|
49
|
+
if ((await item.runtime.currentStateKey(environment)) !== stateKey) {
|
|
50
|
+
results.push(result('missing'));
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
await remote.push(item.id(), stateKey, await (0, archive_task_entry_1.archiveTaskEntry)(item, environment), environment);
|
|
54
|
+
results.push(result('transferred'));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Stage through a private directory: both backends write their completion
|
|
58
|
+
// marker (stats.json) last, so an interrupted transfer is never visible as a
|
|
59
|
+
// complete entry on the destination.
|
|
60
|
+
const staging = (0, path_1.join)((0, os_1.tmpdir)(), `hammerkit-cache-sync-${(0, crypto_1.randomUUID)()}`);
|
|
61
|
+
try {
|
|
62
|
+
const pulled = await source.pull(item.id(), stateKey, staging, environment);
|
|
63
|
+
if (!pulled) {
|
|
64
|
+
results.push(result('missing'));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
await destination.push(item.id(), stateKey, staging, environment);
|
|
68
|
+
results.push(result('transferred'));
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await environment.file.remove(staging);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
exports.syncCache = syncCache;
|
|
77
|
+
//# sourceMappingURL=cache-sync.js.map
|