hammerkit 1.22.0 → 1.24.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/build.schema.json +92 -2
- package/dist/cli.js +25 -0
- package/dist/executer/environment-mock.js +4 -1
- package/dist/executer/execute-command.js +7 -1
- package/dist/index.js +4 -1
- package/dist/planner/service-definition.js +3 -1
- package/dist/planner/utils/append-work-service.js +1 -1
- package/dist/planner/utils/append-work-task.js +2 -1
- package/dist/planner/work-item-status.js +3 -2
- package/dist/planner/work-secret.js +87 -10
- package/dist/program.js +4 -0
- package/dist/remote/bundle.js +59 -0
- package/dist/remote/collect-bundle.js +41 -0
- package/dist/remote/credentials.js +93 -0
- package/dist/remote/device-login.js +177 -0
- package/dist/remote/host-config.js +170 -0
- package/dist/remote/location.js +46 -0
- package/dist/remote/remote-commands.js +154 -0
- package/dist/remote/remote-work-commands.js +152 -0
- package/dist/remote/server-client.js +88 -0
- package/dist/remote/source-manifest.js +79 -0
- package/dist/remote/text-diff.js +89 -0
- package/dist/schema/build-file-base-task-schema.js +6 -0
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/build-file-schema.js +6 -0
- package/dist/schema/build-file-secret-provider-schema.js +36 -0
- package/dist/schema/build-file-secret-schema.js +6 -3
- package/dist/schema/reference-parser.js +4 -0
- package/dist/schema/runner-schema.js +13 -0
- package/dist/secrets/command-secret-provider.js +108 -0
- package/dist/secrets/prefetch-secrets.js +27 -0
- package/dist/secrets/provider-values.js +47 -0
- package/dist/secrets/secret-catalog.js +78 -0
- package/dist/secrets/secret-provider.js +19 -0
- package/dist/server/approvals-api.js +99 -0
- package/dist/server/definitions-api.js +119 -0
- package/dist/server/definitions.js +248 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +238 -0
- package/dist/server/git-checkout.js +63 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/policy.js +90 -0
- package/dist/server/proposals-api.js +123 -0
- package/dist/server/proposals.js +126 -0
- package/dist/server/run-request.js +67 -0
- package/dist/server/runs.js +279 -0
- package/dist/server/server-command.js +43 -0
- package/dist/server/server-config.js +233 -0
- package/dist/server/server.js +338 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/dist/utils/redact.js +75 -19
- package/package.json +1 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DefinitionStore = exports.inspectBundle = exports.DefinitionError = void 0;
|
|
4
|
+
const promises_1 = require("fs/promises");
|
|
5
|
+
const os_1 = require("os");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
8
|
+
const yaml_1 = require("yaml");
|
|
9
|
+
const get_file_context_1 = require("../file/get-file-context");
|
|
10
|
+
const resolve_git_source_1 = require("../includes/resolve-git-source");
|
|
11
|
+
const schema_parser_1 = require("../schema/schema-parser");
|
|
12
|
+
const reference_parser_1 = require("../schema/reference-parser");
|
|
13
|
+
const work_scope_parser_1 = require("../schema/work-scope-parser");
|
|
14
|
+
const work_task_1 = require("../planner/work-task");
|
|
15
|
+
const bundle_1 = require("../remote/bundle");
|
|
16
|
+
const log_1 = require("../log");
|
|
17
|
+
class DefinitionError extends Error {
|
|
18
|
+
}
|
|
19
|
+
exports.DefinitionError = DefinitionError;
|
|
20
|
+
// 40 (sha-1) or 64 (sha-256) hex digits
|
|
21
|
+
const COMMIT_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
22
|
+
// A hash alone can only name what is inside the bundle, so everything a build
|
|
23
|
+
// file pulls in from elsewhere has to be pinned: a git include needs a commit
|
|
24
|
+
// and a remote that is not a path on the server.
|
|
25
|
+
function findLooseIncludes(bundle) {
|
|
26
|
+
const problems = [];
|
|
27
|
+
for (const [path, content] of Object.entries(bundle.files)) {
|
|
28
|
+
if (!/\.ya?ml$/i.test(path)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
let document;
|
|
32
|
+
try {
|
|
33
|
+
document = (0, yaml_1.parse)(content);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue; // a file that isn't valid yaml is reported when the bundle is parsed
|
|
37
|
+
}
|
|
38
|
+
for (const block of ['includes', 'references']) {
|
|
39
|
+
for (const [name, include] of Object.entries(document?.[block] ?? {})) {
|
|
40
|
+
const source = include;
|
|
41
|
+
if (!source || typeof source !== 'object' || source.git === undefined) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (typeof source.git !== 'string' || !/^(https:\/\/|ssh:\/\/|git@[\w.-]+:)/.test(source.git)) {
|
|
45
|
+
problems.push(`${path}: ${block}.${name}: git has to be an https:// or ssh:// address`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof source.ref !== 'string' || !COMMIT_PATTERN.test(source.ref)) {
|
|
48
|
+
problems.push(`${path}: ${block}.${name}: pin ref to a commit, a branch or tag can move`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return problems;
|
|
54
|
+
}
|
|
55
|
+
// What a build file may read while it is inspected: its own files. The rest of
|
|
56
|
+
// the server is not its business (a missing file is "not there", not an error).
|
|
57
|
+
function confined(files, root) {
|
|
58
|
+
const check = (path) => {
|
|
59
|
+
if (!(0, resolve_git_source_1.isWithin)(root, (0, path_1.resolve)(root, path))) {
|
|
60
|
+
throw new DefinitionError(`${path} is outside of the bundle`);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const guarded = (fn) => (path) => {
|
|
64
|
+
check(path);
|
|
65
|
+
return fn.call(files, path);
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
...files,
|
|
69
|
+
read: guarded(files.read),
|
|
70
|
+
readStream: guarded(files.readStream),
|
|
71
|
+
listFiles: guarded(files.listFiles),
|
|
72
|
+
stats: guarded(files.stats),
|
|
73
|
+
exists: async (path) => (0, resolve_git_source_1.isWithin)(root, (0, path_1.resolve)(root, path)) && files.exists(path),
|
|
74
|
+
watch: files.watch,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// A path of a task, written relative to the root of the bundle with forward
|
|
78
|
+
// slashes. A leading `!` (an exclusion) is kept. Nothing is returned for a path
|
|
79
|
+
// outside of the bundle.
|
|
80
|
+
function insideBundle(root, cwd, path) {
|
|
81
|
+
const negated = path.startsWith('!');
|
|
82
|
+
const absolute = (0, path_1.resolve)(cwd, negated ? path.slice(1) : path);
|
|
83
|
+
const inside = (0, path_1.relative)(root, absolute);
|
|
84
|
+
if (inside === '..' || inside.startsWith(`..${path_1.sep}`) || (0, path_1.isAbsolute)(inside)) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const portable = inside.split(path_1.sep).join(path_1.posix.sep);
|
|
88
|
+
// a pattern keeps its glob characters, `resolve` only normalizes separators
|
|
89
|
+
return [`${negated ? '!' : ''}${portable === '' ? '.' : portable}`];
|
|
90
|
+
}
|
|
91
|
+
// Writes the bundle into a directory of its own and reads it the way a run
|
|
92
|
+
// would, so a build file that doesn't parse is refused when it is published, not
|
|
93
|
+
// when somebody wants to run it.
|
|
94
|
+
async function inspectBundle(bundle, environment) {
|
|
95
|
+
const problems = findLooseIncludes(bundle);
|
|
96
|
+
if (problems.length > 0) {
|
|
97
|
+
throw new DefinitionError(problems.join('\n'));
|
|
98
|
+
}
|
|
99
|
+
const directory = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), 'hammerkit-definition-'));
|
|
100
|
+
try {
|
|
101
|
+
for (const [path, content] of Object.entries(bundle.files)) {
|
|
102
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)((0, path_1.join)(directory, path)), { recursive: true });
|
|
103
|
+
await (0, promises_1.writeFile)((0, path_1.join)(directory, path), content);
|
|
104
|
+
}
|
|
105
|
+
const inspected = {
|
|
106
|
+
...environment,
|
|
107
|
+
cwd: directory,
|
|
108
|
+
file: confined((0, get_file_context_1.getFileContext)(directory), directory),
|
|
109
|
+
};
|
|
110
|
+
try {
|
|
111
|
+
const { ctx, scope } = await (0, schema_parser_1.createParseContext)((0, path_1.join)(directory, bundle.entry), inspected);
|
|
112
|
+
const context = await (0, reference_parser_1.parseReferences)(ctx, scope, inspected);
|
|
113
|
+
const workTree = (0, work_scope_parser_1.getWorkContext)(context, { environmentName: null }, inspected);
|
|
114
|
+
const inputs = {};
|
|
115
|
+
for (const item of Object.values(workTree.tasks)) {
|
|
116
|
+
inputs[item.name] = {
|
|
117
|
+
// the sources a task inherits from its dependencies belong to those
|
|
118
|
+
src: item.data.src
|
|
119
|
+
.filter((source) => source.inherited === null)
|
|
120
|
+
.flatMap((source) => insideBundle(directory, item.data.cwd, source.source)),
|
|
121
|
+
mounts: (0, work_task_1.isContainerWorkTask)(item.data)
|
|
122
|
+
? item.data.mounts.flatMap((mount) => insideBundle(directory, item.data.cwd, mount.localPath))
|
|
123
|
+
: [],
|
|
124
|
+
deps: item.deps.map((dep) => dep.name).sort(),
|
|
125
|
+
needs: item.needs.map((need) => need.name).sort(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
tasks: Object.keys(context.tasks).sort(),
|
|
130
|
+
services: Object.keys(context.services).sort(),
|
|
131
|
+
inputs: Object.fromEntries(Object.entries(inputs).sort(([a], [b]) => (a < b ? -1 : 1))),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
throw new DefinitionError((0, log_1.getErrorMessage)(e).split(directory).join('.'));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await (0, promises_1.rm)(directory, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
exports.inspectBundle = inspectBundle;
|
|
143
|
+
// The published build files: immutable versions, each named by its hash. They
|
|
144
|
+
// are kept in files, so a restart doesn't forget what runs were started from.
|
|
145
|
+
class DefinitionStore {
|
|
146
|
+
constructor(directory, environment, now = Date.now) {
|
|
147
|
+
this.directory = directory;
|
|
148
|
+
this.environment = environment;
|
|
149
|
+
this.now = now;
|
|
150
|
+
this.versions = new Map();
|
|
151
|
+
}
|
|
152
|
+
async load() {
|
|
153
|
+
let names = [];
|
|
154
|
+
try {
|
|
155
|
+
names = await (0, promises_1.readdir)(this.directory);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return; // nothing was published yet
|
|
159
|
+
}
|
|
160
|
+
for (const name of names) {
|
|
161
|
+
for (const file of await (0, promises_1.readdir)((0, path_1.join)(this.directory, name))) {
|
|
162
|
+
if (!file.endsWith('.json')) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const stored = JSON.parse(await (0, promises_1.readFile)((0, path_1.join)(this.directory, name, file), 'utf8'));
|
|
166
|
+
this.add(stored.version);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
names() {
|
|
171
|
+
return [...this.versions.keys()].sort();
|
|
172
|
+
}
|
|
173
|
+
// newest first
|
|
174
|
+
list(name) {
|
|
175
|
+
return this.has(name) ? [...this.versions.get(name)] : [];
|
|
176
|
+
}
|
|
177
|
+
// a hash, or `latest` for the version published last
|
|
178
|
+
find(name, hash) {
|
|
179
|
+
const versions = this.list(name);
|
|
180
|
+
return hash === 'latest' ? versions[0] : versions.find((v) => v.hash === hash);
|
|
181
|
+
}
|
|
182
|
+
async files(version) {
|
|
183
|
+
try {
|
|
184
|
+
return JSON.parse(await (0, promises_1.readFile)(this.file(version.name, version.hash), 'utf8')).bundle;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Reads a bundle the way a run would and says what it would be as a version;
|
|
191
|
+
// `existing` is the published version with the same hash, if there is one.
|
|
192
|
+
async prepare(name, bundle, publisher) {
|
|
193
|
+
const hash = (0, bundle_1.hashBundle)(bundle);
|
|
194
|
+
const existing = this.find(name, hash);
|
|
195
|
+
if (existing) {
|
|
196
|
+
return { version: existing, existing };
|
|
197
|
+
}
|
|
198
|
+
const { tasks, services, inputs } = await inspectBundle(bundle, this.environment);
|
|
199
|
+
return {
|
|
200
|
+
version: {
|
|
201
|
+
name,
|
|
202
|
+
hash,
|
|
203
|
+
entry: bundle.entry,
|
|
204
|
+
publishedAt: new Date(this.now()).toISOString(),
|
|
205
|
+
publishedBy: { iss: publisher.iss, sub: publisher.sub },
|
|
206
|
+
tasks,
|
|
207
|
+
services,
|
|
208
|
+
inputs,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// Publishing what already is there changes nothing and says so.
|
|
213
|
+
async publish(name, bundle, publisher, approvedBy) {
|
|
214
|
+
const prepared = await this.prepare(name, bundle, publisher);
|
|
215
|
+
if (prepared.existing) {
|
|
216
|
+
return { version: prepared.existing, created: false };
|
|
217
|
+
}
|
|
218
|
+
const version = {
|
|
219
|
+
...prepared.version,
|
|
220
|
+
publishedAt: new Date(this.now()).toISOString(),
|
|
221
|
+
...(approvedBy ? { approvedBy: { iss: approvedBy.iss, sub: approvedBy.sub } } : {}),
|
|
222
|
+
};
|
|
223
|
+
const file = this.file(name, version.hash);
|
|
224
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)(file), { recursive: true });
|
|
225
|
+
const temporary = `${file}.${(0, crypto_1.randomBytes)(8).toString('hex')}.tmp`;
|
|
226
|
+
await (0, promises_1.writeFile)(temporary, JSON.stringify({ version, bundle }), { flag: 'wx' });
|
|
227
|
+
await (0, promises_1.rename)(temporary, file);
|
|
228
|
+
this.add(version);
|
|
229
|
+
return { version, created: true };
|
|
230
|
+
}
|
|
231
|
+
has(name) {
|
|
232
|
+
return this.versions.has(name);
|
|
233
|
+
}
|
|
234
|
+
add(version) {
|
|
235
|
+
const versions = this.versions.get(version.name) ?? [];
|
|
236
|
+
versions.unshift(version);
|
|
237
|
+
versions.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
|
238
|
+
this.versions.set(version.name, versions);
|
|
239
|
+
}
|
|
240
|
+
file(name, hash) {
|
|
241
|
+
if (!bundle_1.HASH_PATTERN.test(hash)) {
|
|
242
|
+
throw new DefinitionError('invalid hash');
|
|
243
|
+
}
|
|
244
|
+
return (0, path_1.join)(this.directory, name, `${hash.slice('sha256:'.length)}.json`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
exports.DefinitionStore = DefinitionStore;
|
|
248
|
+
//# sourceMappingURL=definitions.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DISCOVERY_PATH = exports.SERVER_PROTOCOL = void 0;
|
|
4
|
+
// What a client needs to know about a server before it signs in. Kept apart from
|
|
5
|
+
// the server so the client does not load the http server and token code.
|
|
6
|
+
// Bumped when a client could no longer talk to an older or newer server.
|
|
7
|
+
exports.SERVER_PROTOCOL = 1;
|
|
8
|
+
exports.DISCOVERY_PATH = '/.well-known/hammerkit';
|
|
9
|
+
//# sourceMappingURL=discovery.js.map
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HostExecutor = exports.passedEnvironment = void 0;
|
|
4
|
+
const promises_1 = require("fs/promises");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const cli_1 = require("../cli");
|
|
7
|
+
const get_file_context_1 = require("../file/get-file-context");
|
|
8
|
+
const log_1 = require("../log");
|
|
9
|
+
const resolve_git_source_1 = require("../includes/resolve-git-source");
|
|
10
|
+
const parse_label_arguments_1 = require("../parser/parse-label-arguments");
|
|
11
|
+
const schema_parser_1 = require("../schema/schema-parser");
|
|
12
|
+
const reference_parser_1 = require("../schema/reference-parser");
|
|
13
|
+
const work_scope_parser_1 = require("../schema/work-scope-parser");
|
|
14
|
+
const work_item_status_1 = require("../planner/work-item-status");
|
|
15
|
+
const plan_work_tasks_1 = require("../planner/utils/plan-work-tasks");
|
|
16
|
+
const redact_1 = require("../utils/redact");
|
|
17
|
+
const empty_writable_1 = require("../utils/empty-writable");
|
|
18
|
+
const units_1 = require("../utils/units");
|
|
19
|
+
const git_checkout_1 = require("./git-checkout");
|
|
20
|
+
// What a task on the server sees of the server's own environment: the tools it
|
|
21
|
+
// needs to find and reach docker, nothing the operator keeps there for the server.
|
|
22
|
+
const PASSED_ENVS = [
|
|
23
|
+
'PATH',
|
|
24
|
+
'HOME',
|
|
25
|
+
'LANG',
|
|
26
|
+
'LC_ALL',
|
|
27
|
+
'TMPDIR',
|
|
28
|
+
'DOCKER_HOST',
|
|
29
|
+
'DOCKER_CONFIG',
|
|
30
|
+
'SSL_CERT_FILE',
|
|
31
|
+
'HTTP_PROXY',
|
|
32
|
+
'HTTPS_PROXY',
|
|
33
|
+
'NO_PROXY',
|
|
34
|
+
'http_proxy',
|
|
35
|
+
'https_proxy',
|
|
36
|
+
'no_proxy',
|
|
37
|
+
'SSH_AUTH_SOCK',
|
|
38
|
+
// Windows: without these powershell and git can not find their profile
|
|
39
|
+
'SystemRoot',
|
|
40
|
+
'SystemDrive',
|
|
41
|
+
'windir',
|
|
42
|
+
'ComSpec',
|
|
43
|
+
'PATHEXT',
|
|
44
|
+
'USERPROFILE',
|
|
45
|
+
'APPDATA',
|
|
46
|
+
'LOCALAPPDATA',
|
|
47
|
+
'TEMP',
|
|
48
|
+
'TMP',
|
|
49
|
+
'ProgramData',
|
|
50
|
+
'ProgramFiles',
|
|
51
|
+
'ProgramFiles(x86)',
|
|
52
|
+
// powershell is very slow to start without it
|
|
53
|
+
'PSModulePath',
|
|
54
|
+
];
|
|
55
|
+
// one log line of a task is cut here, the registry caps how many events a run keeps
|
|
56
|
+
const MAX_LOG_TEXT = 8 * 1024;
|
|
57
|
+
function passedEnvironment(envs) {
|
|
58
|
+
const passed = {};
|
|
59
|
+
for (const name of PASSED_ENVS) {
|
|
60
|
+
if (envs[name] !== undefined) {
|
|
61
|
+
passed[name] = envs[name];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return passed;
|
|
65
|
+
}
|
|
66
|
+
exports.passedEnvironment = passedEnvironment;
|
|
67
|
+
/**
|
|
68
|
+
* Runs the tasks of a run on this machine with the engine of `hammerkit run`.
|
|
69
|
+
*
|
|
70
|
+
* The working directory is made for the run and removed afterwards. Its build
|
|
71
|
+
* file is the published version the run is pinned to, written over the code of the
|
|
72
|
+
* source, so the commands are the published ones whatever the source says. The
|
|
73
|
+
* local cache is shared by all runs, a task is keyed by its definition and
|
|
74
|
+
* sources and not by where they are.
|
|
75
|
+
*/
|
|
76
|
+
class HostExecutor {
|
|
77
|
+
constructor(options) {
|
|
78
|
+
this.options = options;
|
|
79
|
+
}
|
|
80
|
+
async execute(run, context) {
|
|
81
|
+
const { definitions, config, environment } = this.options;
|
|
82
|
+
const version = definitions.find(run.definition.name, run.definition.hash);
|
|
83
|
+
const bundle = version ? await definitions.files(version) : undefined;
|
|
84
|
+
if (!version || !bundle) {
|
|
85
|
+
throw new Error(`the build file ${run.definition.name} at ${run.definition.hash} is no longer on this server`);
|
|
86
|
+
}
|
|
87
|
+
if (run.source.type !== 'ref') {
|
|
88
|
+
throw new Error('runs from an upload are not supported by this server yet, use a git ref');
|
|
89
|
+
}
|
|
90
|
+
const repository = config.sources[run.definition.name]?.git;
|
|
91
|
+
if (!repository) {
|
|
92
|
+
throw new Error(`this server has no repository for ${run.definition.name} to run ${run.source.ref} from, ` +
|
|
93
|
+
`the operator sets sources.${run.definition.name}.git`);
|
|
94
|
+
}
|
|
95
|
+
const abortCtrl = new AbortController();
|
|
96
|
+
const abort = () => abortCtrl.abort();
|
|
97
|
+
if (context.signal.aborted) {
|
|
98
|
+
abort();
|
|
99
|
+
}
|
|
100
|
+
context.signal.addEventListener('abort', abort, { once: true });
|
|
101
|
+
const root = (0, path_1.join)(this.options.directory, run.id);
|
|
102
|
+
const checkout = (0, path_1.join)(root, 'source');
|
|
103
|
+
try {
|
|
104
|
+
const passed = passedEnvironment(environment.processEnvs);
|
|
105
|
+
const commit = await (0, git_checkout_1.checkoutRef)(repository, run.source.ref, checkout, passed, abortCtrl.signal);
|
|
106
|
+
context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `checked out ${commit}` });
|
|
107
|
+
await this.writeBundle(checkout, bundle.files);
|
|
108
|
+
await this.runTasks(run, context, (0, path_1.join)(checkout, version.entry), checkout, passed, abortCtrl);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
context.signal.removeEventListener('abort', abort);
|
|
112
|
+
await (0, promises_1.rm)(root, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// The published files go over the checkout; nothing may land outside of it or in
|
|
116
|
+
// its git directory.
|
|
117
|
+
async writeBundle(directory, files) {
|
|
118
|
+
for (const [path, content] of Object.entries(files)) {
|
|
119
|
+
const target = (0, path_1.resolve)(directory, path);
|
|
120
|
+
if (!(0, resolve_git_source_1.isWithin)(directory, target) || /^\.git(\/|$)/.test(path)) {
|
|
121
|
+
throw new Error(`the published file ${JSON.stringify(path)} can not be written into the source`);
|
|
122
|
+
}
|
|
123
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)(target), { recursive: true });
|
|
124
|
+
await (0, promises_1.writeFile)(target, content);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async runTasks(run, context, entry, cwd, passed, abortCtrl) {
|
|
128
|
+
const secrets = (0, redact_1.createSecretRegistry)();
|
|
129
|
+
const status = (0, work_item_status_1.statusConsole)((0, empty_writable_1.emptyWritable)(), secrets);
|
|
130
|
+
// what the engine would print to a terminal is not wanted, its status console
|
|
131
|
+
// is what is streamed: it is redacted with the secrets of this run
|
|
132
|
+
const environment = {
|
|
133
|
+
processEnvs: passed,
|
|
134
|
+
abortCtrl,
|
|
135
|
+
cwd,
|
|
136
|
+
file: (0, get_file_context_1.getFileContext)(cwd),
|
|
137
|
+
console: (0, log_1.consoleContext)((0, empty_writable_1.emptyWritable)()),
|
|
138
|
+
status,
|
|
139
|
+
secrets,
|
|
140
|
+
stdout: (0, empty_writable_1.emptyWritable)(),
|
|
141
|
+
stderr: (0, empty_writable_1.emptyWritable)(),
|
|
142
|
+
stdoutColumns: 120,
|
|
143
|
+
};
|
|
144
|
+
const listener = status.on((message) => {
|
|
145
|
+
if (message.type === 'console') {
|
|
146
|
+
context.emit({
|
|
147
|
+
type: 'log',
|
|
148
|
+
task: message.context.name,
|
|
149
|
+
stream: message.console,
|
|
150
|
+
text: message.message.slice(0, MAX_LOG_TEXT),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (message.level === 'error' || message.level === 'warn') {
|
|
154
|
+
context.emit({
|
|
155
|
+
type: 'log',
|
|
156
|
+
task: message.context.name,
|
|
157
|
+
stream: 'stderr',
|
|
158
|
+
text: message.message.slice(0, MAX_LOG_TEXT),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
const options = run.options;
|
|
164
|
+
const execOptions = {
|
|
165
|
+
logMode: 'live',
|
|
166
|
+
cacheDefault: options.cache ?? 'checksum',
|
|
167
|
+
cacheReadOnly: options.cacheReadOnly ?? false,
|
|
168
|
+
timeout: options.timeout ? (0, units_1.parseDuration)(options.timeout) : null,
|
|
169
|
+
skipDeps: options.skipDeps !== false,
|
|
170
|
+
};
|
|
171
|
+
// the tasks in the order they were named; by labels when none is
|
|
172
|
+
const scopes = run.tasks.length > 0
|
|
173
|
+
? run.tasks.map((task) => ({
|
|
174
|
+
name: task,
|
|
175
|
+
scope: { taskName: task, environmentName: options.env ?? null },
|
|
176
|
+
}))
|
|
177
|
+
: [
|
|
178
|
+
{
|
|
179
|
+
name: 'labels',
|
|
180
|
+
scope: {
|
|
181
|
+
filterLabels: labels(options.filter),
|
|
182
|
+
excludeLabels: labels(options.exclude),
|
|
183
|
+
environmentName: options.env ?? null,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
for (const { name, scope } of scopes) {
|
|
188
|
+
const { ctx, scope: parsed } = await (0, schema_parser_1.createParseContext)(entry, environment);
|
|
189
|
+
const workTree = (0, work_scope_parser_1.getWorkContext)(await (0, reference_parser_1.parseReferences)(ctx, parsed, environment), scope, environment);
|
|
190
|
+
const cli = (0, cli_1.getCli)(workTree, environment);
|
|
191
|
+
if (cli.tasks().length === 0) {
|
|
192
|
+
throw new Error(`no tasks found for ${name}`);
|
|
193
|
+
}
|
|
194
|
+
const execution = cli.exec({ ...execOptions, workers: cli.defaultWorkers() });
|
|
195
|
+
const seen = new Map();
|
|
196
|
+
const watch = execution.state.on('run-events', () => reportTasks(context, execution.state.current, seen));
|
|
197
|
+
try {
|
|
198
|
+
const result = await execution.start();
|
|
199
|
+
reportTasks(context, execution.state.current, seen);
|
|
200
|
+
if (!result.success) {
|
|
201
|
+
throw new Error(context.signal.aborted ? 'the run was cancelled' : `${name} failed`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
watch.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
listener.close();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
exports.HostExecutor = HostExecutor;
|
|
215
|
+
function labels(values) {
|
|
216
|
+
return values && values.length > 0 ? (0, parse_label_arguments_1.parseLabelArguments)(values) : undefined;
|
|
217
|
+
}
|
|
218
|
+
// A task event when a task has moved on since the last look.
|
|
219
|
+
function reportTasks(context, workTree, seen) {
|
|
220
|
+
for (const task of (0, plan_work_tasks_1.iterateWorkTasks)(workTree)) {
|
|
221
|
+
const state = task.state.current;
|
|
222
|
+
let event = null;
|
|
223
|
+
if (state.type === 'running') {
|
|
224
|
+
event = 'running';
|
|
225
|
+
}
|
|
226
|
+
else if (state.type === 'completed') {
|
|
227
|
+
event = state.skipped ? null : state.cached ? 'cached' : 'succeeded';
|
|
228
|
+
}
|
|
229
|
+
else if (state.type === 'error' || state.type === 'crash') {
|
|
230
|
+
event = 'failed';
|
|
231
|
+
}
|
|
232
|
+
if (event && seen.get(task.name) !== event) {
|
|
233
|
+
seen.set(task.name, event);
|
|
234
|
+
context.emit({ type: 'task', task: task.name, state: event });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=executor.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkoutRef = exports.isValidRef = void 0;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const promises_1 = require("fs/promises");
|
|
6
|
+
const util_1 = require("util");
|
|
7
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
8
|
+
// Anything but plain network and local transports is refused: `ext::` runs a command.
|
|
9
|
+
const ALLOWED_PROTOCOLS = 'file:git:http:https:ssh';
|
|
10
|
+
// A branch, a tag or a commit. It is passed after `--` as well, but a ref that
|
|
11
|
+
// looks like an option, has a `..` or odd characters is not one of the three.
|
|
12
|
+
const REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
|
|
13
|
+
function isValidRef(ref) {
|
|
14
|
+
return (REF_PATTERN.test(ref) && !ref.includes('..') && !ref.includes('//') && !ref.endsWith('/') && !ref.endsWith('.lock'));
|
|
15
|
+
}
|
|
16
|
+
exports.isValidRef = isValidRef;
|
|
17
|
+
/**
|
|
18
|
+
* Fetches one commit of a repository into `directory` and checks it out, and
|
|
19
|
+
* answers which commit that is. The repository is the operator's, the ref is the
|
|
20
|
+
* caller's. Credentials come from the git configuration of the server, there is no
|
|
21
|
+
* prompt a run could hang on.
|
|
22
|
+
*/
|
|
23
|
+
async function checkoutRef(repository, ref, directory, env, signal) {
|
|
24
|
+
if (!isValidRef(ref)) {
|
|
25
|
+
throw new Error(`${JSON.stringify(ref.slice(0, 80))} is not a branch, tag or commit`);
|
|
26
|
+
}
|
|
27
|
+
await (0, promises_1.mkdir)(directory, { recursive: true });
|
|
28
|
+
const run = (args) => git(args, env, signal, `${repository} at ${ref}`);
|
|
29
|
+
await run(['init', '--quiet', directory]);
|
|
30
|
+
await run(['-C', directory, 'fetch', '--quiet', '--depth', '1', '--', repository, ref]);
|
|
31
|
+
await run(['-C', directory, '-c', 'advice.detachedHead=false', 'checkout', '--quiet', 'FETCH_HEAD']);
|
|
32
|
+
return (await run(['-C', directory, 'rev-parse', 'HEAD'])).trim();
|
|
33
|
+
}
|
|
34
|
+
exports.checkoutRef = checkoutRef;
|
|
35
|
+
async function git(args, baseEnv, signal, what) {
|
|
36
|
+
const env = {};
|
|
37
|
+
for (const [key, value] of Object.entries(baseEnv)) {
|
|
38
|
+
if (value !== undefined) {
|
|
39
|
+
env[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
env.GIT_TERMINAL_PROMPT = '0';
|
|
43
|
+
env.GIT_ALLOW_PROTOCOL = ALLOWED_PROTOCOLS;
|
|
44
|
+
try {
|
|
45
|
+
return (await execFileAsync('git', args, { env, signal })).stdout;
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
const error = e;
|
|
49
|
+
if (error.code === 'ABORT_ERR') {
|
|
50
|
+
throw new Error(`fetching ${what} was cancelled`);
|
|
51
|
+
}
|
|
52
|
+
if (error.code === 'ENOENT') {
|
|
53
|
+
throw new Error(`unable to fetch ${what}: git is not installed`);
|
|
54
|
+
}
|
|
55
|
+
const reason = (error.stderr ?? '')
|
|
56
|
+
.split('\n')
|
|
57
|
+
.map((line) => line.trim())
|
|
58
|
+
.filter((line) => line.length > 0)
|
|
59
|
+
.pop() ?? error.message;
|
|
60
|
+
throw new Error(`unable to fetch ${what}: ${reason}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=git-checkout.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readJson = exports.MAX_BODY_BYTES = exports.HttpError = exports.send = void 0;
|
|
4
|
+
function send(res, status, body, headers = {}) {
|
|
5
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers });
|
|
6
|
+
res.end(JSON.stringify(body));
|
|
7
|
+
}
|
|
8
|
+
exports.send = send;
|
|
9
|
+
// An answer to the caller, thrown from wherever the request turns out to be wrong.
|
|
10
|
+
class HttpError extends Error {
|
|
11
|
+
constructor(status, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.status = status;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.HttpError = HttpError;
|
|
17
|
+
exports.MAX_BODY_BYTES = 64 * 1024;
|
|
18
|
+
async function readJson(req, limit = exports.MAX_BODY_BYTES) {
|
|
19
|
+
if (!/^application\/json(;|$)/i.test(req.headers['content-type'] ?? '')) {
|
|
20
|
+
throw new HttpError(415, 'send application/json');
|
|
21
|
+
}
|
|
22
|
+
const chunks = [];
|
|
23
|
+
let size = 0;
|
|
24
|
+
for await (const chunk of req) {
|
|
25
|
+
size += chunk.length;
|
|
26
|
+
if (size > limit) {
|
|
27
|
+
throw new HttpError(413, `the request is larger than ${limit} bytes`);
|
|
28
|
+
}
|
|
29
|
+
chunks.push(chunk);
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new HttpError(400, 'the body is not valid JSON');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.readJson = readJson;
|
|
39
|
+
//# sourceMappingURL=http.js.map
|