hammerkit 1.23.0 → 1.25.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 +10 -0
- package/dist/cli.js +6 -0
- package/dist/executer/execute-command.js +7 -1
- package/dist/optimizer/get-cache-directory.js +5 -0
- package/dist/planner/utils/append-work-task.js +1 -0
- package/dist/planner/work-secret.js +6 -2
- package/dist/program.js +6 -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 +158 -0
- package/dist/remote/remote-work-commands.js +224 -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 +4 -0
- package/dist/schema/build-file-schema.js +3 -0
- package/dist/schema/reference-parser.js +9 -2
- package/dist/schema/runner-schema.js +13 -0
- package/dist/secrets/provider-values.js +4 -0
- package/dist/server/accounts-api.js +42 -0
- package/dist/server/approvals-api.js +104 -0
- package/dist/server/definitions-api.js +163 -0
- package/dist/server/definitions.js +160 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +237 -0
- package/dist/server/git-checkout.js +115 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/plan.js +296 -0
- package/dist/server/policy.js +138 -0
- package/dist/server/proposals-api.js +145 -0
- package/dist/server/proposals.js +173 -0
- package/dist/server/repo-access.js +127 -0
- package/dist/server/repo.js +94 -0
- package/dist/server/run-environment.js +20 -0
- package/dist/server/run-plan.js +44 -0
- package/dist/server/run-request.js +85 -0
- package/dist/server/run-store.js +61 -0
- package/dist/server/run-worker-command.js +17 -0
- package/dist/server/run-worker-main.js +7 -0
- package/dist/server/run-worker.js +179 -0
- package/dist/server/runs.js +432 -0
- package/dist/server/server-command.js +61 -0
- package/dist/server/server-config.js +394 -0
- package/dist/server/server.js +434 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/package.json +1 -1
|
@@ -0,0 +1,160 @@
|
|
|
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 path_1 = require("path");
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
7
|
+
const bundle_1 = require("../remote/bundle");
|
|
8
|
+
const plan_1 = require("./plan");
|
|
9
|
+
var plan_2 = require("./plan");
|
|
10
|
+
Object.defineProperty(exports, "DefinitionError", { enumerable: true, get: function () { return plan_2.DefinitionError; } });
|
|
11
|
+
Object.defineProperty(exports, "inspectBundle", { enumerable: true, get: function () { return plan_2.inspectBundle; } });
|
|
12
|
+
// The published build files: immutable versions, each named by its hash. They
|
|
13
|
+
// are kept in files, so a restart doesn't forget what runs were started from.
|
|
14
|
+
class DefinitionStore {
|
|
15
|
+
constructor(directory,
|
|
16
|
+
// the environment a build file is read with: on a server, the hosted one
|
|
17
|
+
environment, options = {}) {
|
|
18
|
+
this.directory = directory;
|
|
19
|
+
this.environment = environment;
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.versions = new Map();
|
|
22
|
+
this.now = options.now ?? Date.now;
|
|
23
|
+
}
|
|
24
|
+
async load() {
|
|
25
|
+
let names = [];
|
|
26
|
+
try {
|
|
27
|
+
names = await (0, promises_1.readdir)(this.directory);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return; // nothing was published yet
|
|
31
|
+
}
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
for (const file of await (0, promises_1.readdir)((0, path_1.join)(this.directory, name))) {
|
|
34
|
+
if (!file.endsWith('.json')) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const stored = JSON.parse(await (0, promises_1.readFile)((0, path_1.join)(this.directory, name, file), 'utf8'));
|
|
38
|
+
this.add(stored.version);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
names() {
|
|
43
|
+
return [...this.versions.keys()].sort();
|
|
44
|
+
}
|
|
45
|
+
// newest first
|
|
46
|
+
list(name) {
|
|
47
|
+
return this.has(name) ? [...this.versions.get(name)] : [];
|
|
48
|
+
}
|
|
49
|
+
// a hash, or `latest` for the version published last that is not yanked
|
|
50
|
+
find(name, hash) {
|
|
51
|
+
const versions = this.list(name);
|
|
52
|
+
return hash === 'latest' ? versions.find((v) => !v.yanked) : versions.find((v) => v.hash === hash);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Pulls a version back (or, with `undo`, lets it go on). The version is rewritten in
|
|
56
|
+
* place, its files stay: a run that was started from it has them.
|
|
57
|
+
*/
|
|
58
|
+
async yank(name, hash, by, options = {}) {
|
|
59
|
+
const version = this.versions.get(name)?.find((v) => v.hash === hash);
|
|
60
|
+
if (!version || Boolean(version.yanked) === !options.undo) {
|
|
61
|
+
return version;
|
|
62
|
+
}
|
|
63
|
+
const bundle = await this.files(version);
|
|
64
|
+
if (!bundle) {
|
|
65
|
+
return version;
|
|
66
|
+
}
|
|
67
|
+
const next = { ...version };
|
|
68
|
+
if (options.undo) {
|
|
69
|
+
delete next.yanked;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
next.yanked = {
|
|
73
|
+
at: new Date(this.now()).toISOString(),
|
|
74
|
+
by: { iss: by.iss, sub: by.sub },
|
|
75
|
+
...(options.reason ? { reason: options.reason } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const file = this.file(name, hash);
|
|
79
|
+
const temporary = `${file}.${(0, crypto_1.randomBytes)(8).toString('hex')}.tmp`;
|
|
80
|
+
await (0, promises_1.writeFile)(temporary, JSON.stringify({ version: next, bundle }), { flag: 'wx' });
|
|
81
|
+
await (0, promises_1.rename)(temporary, file);
|
|
82
|
+
// the same object, so whoever holds the version sees it
|
|
83
|
+
Object.keys(version).forEach((key) => delete version[key]);
|
|
84
|
+
Object.assign(version, next);
|
|
85
|
+
return version;
|
|
86
|
+
}
|
|
87
|
+
async files(version) {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(await (0, promises_1.readFile)(this.file(version.name, version.hash), 'utf8')).bundle;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Reads a bundle the way a run would and says what it would be as a version;
|
|
96
|
+
// `existing` is the published version with the same hash, if there is one.
|
|
97
|
+
async prepare(name, bundle, publisher,
|
|
98
|
+
// a proposal is read before anybody trusts it: it may not make the server fetch
|
|
99
|
+
options = {}) {
|
|
100
|
+
const hash = (0, bundle_1.hashBundle)(bundle);
|
|
101
|
+
const existing = this.find(name, hash);
|
|
102
|
+
if (existing) {
|
|
103
|
+
return { version: existing, existing };
|
|
104
|
+
}
|
|
105
|
+
const { tasks, services, inputs, plan, accounts } = await (0, plan_1.planBundle)(bundle, this.environment, {
|
|
106
|
+
policy: this.options.policy,
|
|
107
|
+
noRemote: options.noRemote,
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
version: {
|
|
111
|
+
name,
|
|
112
|
+
hash,
|
|
113
|
+
entry: bundle.entry,
|
|
114
|
+
publishedAt: new Date(this.now()).toISOString(),
|
|
115
|
+
publishedBy: { iss: publisher.iss, sub: publisher.sub },
|
|
116
|
+
tasks,
|
|
117
|
+
services,
|
|
118
|
+
inputs,
|
|
119
|
+
plan,
|
|
120
|
+
accounts,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Publishing what already is there changes nothing and says so.
|
|
125
|
+
async publish(name, bundle, publisher, approvedBy) {
|
|
126
|
+
const prepared = await this.prepare(name, bundle, publisher);
|
|
127
|
+
if (prepared.existing) {
|
|
128
|
+
return { version: prepared.existing, created: false };
|
|
129
|
+
}
|
|
130
|
+
const version = {
|
|
131
|
+
...prepared.version,
|
|
132
|
+
publishedAt: new Date(this.now()).toISOString(),
|
|
133
|
+
...(approvedBy ? { approvedBy: { iss: approvedBy.iss, sub: approvedBy.sub } } : {}),
|
|
134
|
+
};
|
|
135
|
+
const file = this.file(name, version.hash);
|
|
136
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)(file), { recursive: true });
|
|
137
|
+
const temporary = `${file}.${(0, crypto_1.randomBytes)(8).toString('hex')}.tmp`;
|
|
138
|
+
await (0, promises_1.writeFile)(temporary, JSON.stringify({ version, bundle }), { flag: 'wx' });
|
|
139
|
+
await (0, promises_1.rename)(temporary, file);
|
|
140
|
+
this.add(version);
|
|
141
|
+
return { version, created: true };
|
|
142
|
+
}
|
|
143
|
+
has(name) {
|
|
144
|
+
return this.versions.has(name);
|
|
145
|
+
}
|
|
146
|
+
add(version) {
|
|
147
|
+
const versions = this.versions.get(version.name) ?? [];
|
|
148
|
+
versions.unshift(version);
|
|
149
|
+
versions.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
|
150
|
+
this.versions.set(version.name, versions);
|
|
151
|
+
}
|
|
152
|
+
file(name, hash) {
|
|
153
|
+
if (!bundle_1.HASH_PATTERN.test(hash)) {
|
|
154
|
+
throw new plan_1.DefinitionError('invalid hash');
|
|
155
|
+
}
|
|
156
|
+
return (0, path_1.join)(this.directory, name, `${hash.slice('sha256:'.length)}.json`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
exports.DefinitionStore = DefinitionStore;
|
|
160
|
+
//# 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,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HostExecutor = exports.defaultWorker = void 0;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const promises_1 = require("fs/promises");
|
|
8
|
+
const path_1 = require("path");
|
|
9
|
+
const readline_1 = require("readline");
|
|
10
|
+
const resolve_git_source_1 = require("../includes/resolve-git-source");
|
|
11
|
+
const git_checkout_1 = require("./git-checkout");
|
|
12
|
+
const run_plan_1 = require("./run-plan");
|
|
13
|
+
// `.git` as a file system may read it: any case, and a name Windows reads without its
|
|
14
|
+
// trailing dots and spaces, or by its short name
|
|
15
|
+
function isGitDirectory(segment) {
|
|
16
|
+
const name = segment.toLowerCase().replace(/[. ]+$/, '');
|
|
17
|
+
return name === '.git' || name === 'git~1';
|
|
18
|
+
}
|
|
19
|
+
// A cancelled run gets this long to stop its tasks and remove its containers
|
|
20
|
+
const STOP_GRACE_MS = 15000;
|
|
21
|
+
// what is kept of the error output of a process that died without saying why
|
|
22
|
+
const MAX_STDERR = 2 * 1024;
|
|
23
|
+
/**
|
|
24
|
+
* The command that starts the process of a run: `hammerkit server-run`, from the
|
|
25
|
+
* build this module is part of. Only found next to a built `index.js`.
|
|
26
|
+
*/
|
|
27
|
+
function defaultWorker() {
|
|
28
|
+
const entry = (0, path_1.resolve)(__dirname, '..', 'index.js');
|
|
29
|
+
return (0, fs_1.existsSync)(entry) ? [process.execPath, entry, 'server-run'] : undefined;
|
|
30
|
+
}
|
|
31
|
+
exports.defaultWorker = defaultWorker;
|
|
32
|
+
// The cache a run shares with others is the one of its source: the runs of a repository
|
|
33
|
+
// share one, those of an upload only the uploader's. Somebody who can only upload can
|
|
34
|
+
// not leave an entry for a run from a reviewed repository to find.
|
|
35
|
+
function cacheNamespace(run) {
|
|
36
|
+
const key = run.source.type === 'ref' ? `ref\0${run.source.repo}` : `upload\0${run.owner.iss}\0${run.owner.sub}`;
|
|
37
|
+
return `${run.source.type}-${(0, crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 24)}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Runs the tasks of a run on this machine with the engine of `hammerkit run`, in a
|
|
41
|
+
* process of its own.
|
|
42
|
+
*
|
|
43
|
+
* The working directory is made for the run and removed afterwards. Its build
|
|
44
|
+
* file is the published version the run is pinned to, written over the code of the
|
|
45
|
+
* source, so the commands are the published ones whatever the source says. The process
|
|
46
|
+
* starts with an environment the server builds: the variables `runs.env` lists, a home
|
|
47
|
+
* directory that is the run's alone, and a cache directory per source trust. So what
|
|
48
|
+
* the engine reads of its process (the docker host, the registry logins in the home
|
|
49
|
+
* directory, the credentials of the operator) is what the operator gave runs and
|
|
50
|
+
* nothing else. The cache is shared by the runs of the same source, a task is keyed by
|
|
51
|
+
* its definition and sources and not by where they are.
|
|
52
|
+
*/
|
|
53
|
+
class HostExecutor {
|
|
54
|
+
constructor(options) {
|
|
55
|
+
this.options = options;
|
|
56
|
+
}
|
|
57
|
+
async execute(run, context) {
|
|
58
|
+
const { definitions, planning, repos } = this.options;
|
|
59
|
+
const version = definitions.find(run.definition.name, run.definition.hash);
|
|
60
|
+
const bundle = version ? await definitions.files(version) : undefined;
|
|
61
|
+
if (!version || !bundle) {
|
|
62
|
+
throw new Error(`the build file ${run.definition.name} at ${run.definition.hash} is no longer on this server`);
|
|
63
|
+
}
|
|
64
|
+
// pulled back after the run was accepted, while it waited for an approval or in the queue
|
|
65
|
+
if (version.yanked) {
|
|
66
|
+
throw new Error(`the build file ${run.definition.name} at ${run.definition.hash} was yanked${version.yanked.reason ? `: ${version.yanked.reason}` : ''}`);
|
|
67
|
+
}
|
|
68
|
+
if (run.source.type !== 'ref') {
|
|
69
|
+
throw new Error('runs from an upload are not supported by this server yet, use a git ref');
|
|
70
|
+
}
|
|
71
|
+
const { repo, ref, commit: pinned } = run.source;
|
|
72
|
+
if (!repos.readable(repo)) {
|
|
73
|
+
throw new Error(`this server can not read ${repo}, the operator lists it in sources`);
|
|
74
|
+
}
|
|
75
|
+
const worker = this.options.worker ?? defaultWorker();
|
|
76
|
+
if (!worker) {
|
|
77
|
+
throw new Error('this server has no command to start a run with, it needs to run from a built hammerkit');
|
|
78
|
+
}
|
|
79
|
+
// what was approved is what runs: the accounts are read again from the files with
|
|
80
|
+
// the environment of the run, and the run may use no more than it was admitted for
|
|
81
|
+
const planned = await (0, run_plan_1.planRun)(bundle, run, planning.environment, planning.policy);
|
|
82
|
+
const extra = planned.accounts.filter((account) => !run.accounts.includes(account));
|
|
83
|
+
if (extra.length > 0) {
|
|
84
|
+
throw new Error(`the run would use ${extra.join(', ')}, which it was not admitted for`);
|
|
85
|
+
}
|
|
86
|
+
const abortCtrl = new AbortController();
|
|
87
|
+
const abort = () => abortCtrl.abort();
|
|
88
|
+
if (context.signal.aborted) {
|
|
89
|
+
abort();
|
|
90
|
+
}
|
|
91
|
+
context.signal.addEventListener('abort', abort, { once: true });
|
|
92
|
+
const root = (0, path_1.join)(this.options.directory, run.id);
|
|
93
|
+
const checkout = (0, path_1.join)(root, 'source');
|
|
94
|
+
try {
|
|
95
|
+
const credentials = await repos.credentials(repo);
|
|
96
|
+
let commit;
|
|
97
|
+
try {
|
|
98
|
+
commit = await (0, git_checkout_1.checkoutRef)(repos.url(repo), ref, checkout, credentials.env, abortCtrl.signal, pinned);
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
throw repos.scrub(e, credentials.values);
|
|
102
|
+
}
|
|
103
|
+
context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `checked out ${repo} at ${commit}` });
|
|
104
|
+
await this.writeBundle(checkout, bundle.files);
|
|
105
|
+
const home = (0, path_1.join)(root, 'home');
|
|
106
|
+
await (0, promises_1.mkdir)(home, { recursive: true });
|
|
107
|
+
const cache = (0, path_1.join)(this.options.cache, cacheNamespace(run));
|
|
108
|
+
await (0, promises_1.mkdir)(cache, { recursive: true });
|
|
109
|
+
const input = {
|
|
110
|
+
entry: (0, path_1.join)(checkout, version.entry),
|
|
111
|
+
tasks: run.tasks,
|
|
112
|
+
options: run.options,
|
|
113
|
+
accounts: run.accounts,
|
|
114
|
+
catalog: planning.environment.hosted?.catalog,
|
|
115
|
+
};
|
|
116
|
+
await this.runInProcess(worker, input, checkout, this.processEnvironment(home, cache), context, abortCtrl.signal);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
context.signal.removeEventListener('abort', abort);
|
|
120
|
+
await (0, promises_1.rm)(root, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// What the process of a run sees: the variables the operator listed, and a home
|
|
124
|
+
// directory and cache that are the run's. The server's own are not in it.
|
|
125
|
+
processEnvironment(home, cache) {
|
|
126
|
+
const env = {};
|
|
127
|
+
for (const [key, value] of Object.entries(this.options.planning.environment.processEnvs)) {
|
|
128
|
+
if (value !== undefined) {
|
|
129
|
+
env[key] = value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
env.HOME = home;
|
|
133
|
+
env.USERPROFILE = home;
|
|
134
|
+
env.APPDATA = (0, path_1.join)(home, 'AppData', 'Roaming');
|
|
135
|
+
env.LOCALAPPDATA = (0, path_1.join)(home, 'AppData', 'Local');
|
|
136
|
+
env.HAMMERKIT_DIR = cache;
|
|
137
|
+
return env;
|
|
138
|
+
}
|
|
139
|
+
// Starts the process, hands it the run on its standard input and relays what it reports. It
|
|
140
|
+
// is stopped with SIGTERM when the run is cancelled, so the engine removes its containers
|
|
141
|
+
// and stops its tasks, and killed if it does not stop in time.
|
|
142
|
+
runInProcess(command, input, cwd, env, context, signal) {
|
|
143
|
+
return new Promise((resolvePromise, reject) => {
|
|
144
|
+
const child = (0, child_process_1.spawn)(command[0], command.slice(1), {
|
|
145
|
+
cwd,
|
|
146
|
+
env,
|
|
147
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
148
|
+
windowsHide: true,
|
|
149
|
+
});
|
|
150
|
+
let result;
|
|
151
|
+
let stderr = '';
|
|
152
|
+
let killTimer;
|
|
153
|
+
const stop = () => {
|
|
154
|
+
child.kill('SIGTERM');
|
|
155
|
+
killTimer = setTimeout(() => child.kill('SIGKILL'), STOP_GRACE_MS);
|
|
156
|
+
killTimer.unref();
|
|
157
|
+
};
|
|
158
|
+
if (signal.aborted) {
|
|
159
|
+
stop();
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
163
|
+
}
|
|
164
|
+
(0, readline_1.createInterface)({ input: child.stdout }).on('line', (line) => {
|
|
165
|
+
try {
|
|
166
|
+
const message = JSON.parse(line);
|
|
167
|
+
if (message.kind === 'event') {
|
|
168
|
+
context.emit(message.event);
|
|
169
|
+
}
|
|
170
|
+
else if (message.kind === 'result') {
|
|
171
|
+
result = message;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// not ours: whatever else the process prints is not an event of the run
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
child.stderr.on('data', (chunk) => {
|
|
179
|
+
stderr = (stderr + chunk.toString('utf8')).slice(-MAX_STDERR);
|
|
180
|
+
});
|
|
181
|
+
child.stdin.on('error', () => undefined);
|
|
182
|
+
child.stdin.end(JSON.stringify(input));
|
|
183
|
+
child.on('error', (e) => {
|
|
184
|
+
signal.removeEventListener('abort', stop);
|
|
185
|
+
clearTimeout(killTimer);
|
|
186
|
+
reject(new Error(`the process of the run could not start: ${e.message}`));
|
|
187
|
+
});
|
|
188
|
+
child.on('close', (code, killed) => {
|
|
189
|
+
signal.removeEventListener('abort', stop);
|
|
190
|
+
clearTimeout(killTimer);
|
|
191
|
+
if (result?.ok) {
|
|
192
|
+
resolvePromise();
|
|
193
|
+
}
|
|
194
|
+
else if (result) {
|
|
195
|
+
reject(new Error(result.error ?? 'the run failed'));
|
|
196
|
+
}
|
|
197
|
+
else if (signal.aborted) {
|
|
198
|
+
reject(new Error('the run was cancelled'));
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
const reason = stderr.trim().split('\n').pop() ?? '';
|
|
202
|
+
reject(new Error(`the process of the run stopped (${killed ? `signal ${killed}` : `code ${code}`})${reason ? `: ${reason}` : ''}`));
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
// The published files go over the checkout; nothing may land outside of it or in
|
|
208
|
+
// its git directory. The checkout is somebody else's: it may hold a symbolic link where
|
|
209
|
+
// a directory or a file of the bundle goes, which would carry the write out of it, so
|
|
210
|
+
// none is followed. `.git` is compared the way a case-insensitive or Windows file system
|
|
211
|
+
// would read it.
|
|
212
|
+
async writeBundle(directory, files) {
|
|
213
|
+
for (const [path, content] of Object.entries(files)) {
|
|
214
|
+
const target = (0, path_1.resolve)(directory, path);
|
|
215
|
+
const refused = () => new Error(`the published file ${JSON.stringify(path)} can not be written into the source`);
|
|
216
|
+
if (!(0, resolve_git_source_1.isWithin)(directory, target) || path.split(/[\\/]/).some((part) => isGitDirectory(part))) {
|
|
217
|
+
throw refused();
|
|
218
|
+
}
|
|
219
|
+
const parts = (0, path_1.relative)(directory, target).split(path_1.sep);
|
|
220
|
+
let current = directory;
|
|
221
|
+
for (const [index, part] of parts.entries()) {
|
|
222
|
+
current = (0, path_1.join)(current, part);
|
|
223
|
+
const stats = await (0, promises_1.lstat)(current).catch(() => undefined);
|
|
224
|
+
if (!stats) {
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
if (stats.isSymbolicLink() || (index < parts.length - 1 && !stats.isDirectory())) {
|
|
228
|
+
throw refused();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)(target), { recursive: true });
|
|
232
|
+
await (0, promises_1.writeFile)(target, content);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
exports.HostExecutor = HostExecutor;
|
|
237
|
+
//# sourceMappingURL=executor.js.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveRef = exports.RefError = 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
|
+
// the commit the ref was resolved to when the run was submitted: a ref that moved since
|
|
25
|
+
// is not what was approved
|
|
26
|
+
expectedCommit) {
|
|
27
|
+
if (!isValidRef(ref)) {
|
|
28
|
+
throw new Error(`${JSON.stringify(ref.slice(0, 80))} is not a branch, tag or commit`);
|
|
29
|
+
}
|
|
30
|
+
await (0, promises_1.mkdir)(directory, { recursive: true });
|
|
31
|
+
const run = (args) => git(args, env, signal, `${repository} at ${ref}`);
|
|
32
|
+
await run(['init', '--quiet', directory]);
|
|
33
|
+
await run(['-C', directory, 'fetch', '--quiet', '--depth', '1', '--', repository, ref]);
|
|
34
|
+
await run(['-C', directory, '-c', 'advice.detachedHead=false', 'checkout', '--quiet', 'FETCH_HEAD']);
|
|
35
|
+
const commit = (await run(['-C', directory, 'rev-parse', 'HEAD'])).trim();
|
|
36
|
+
if (expectedCommit && commit !== expectedCommit) {
|
|
37
|
+
throw new Error(`${ref} is at ${commit} now, the run was submitted for ${expectedCommit}`);
|
|
38
|
+
}
|
|
39
|
+
return commit;
|
|
40
|
+
}
|
|
41
|
+
exports.checkoutRef = checkoutRef;
|
|
42
|
+
class RefError extends Error {
|
|
43
|
+
}
|
|
44
|
+
exports.RefError = RefError;
|
|
45
|
+
const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
46
|
+
/**
|
|
47
|
+
* The commit a branch or tag of a repository points to now: `refs/heads/<ref>`, else
|
|
48
|
+
* `refs/tags/<ref>` (the commit of an annotated tag). A name that is both a branch and
|
|
49
|
+
* a tag is ambiguous. A full commit hash is taken as it is.
|
|
50
|
+
*/
|
|
51
|
+
async function resolveRef(repository, ref, env, signal) {
|
|
52
|
+
if (!isValidRef(ref)) {
|
|
53
|
+
throw new RefError(`${JSON.stringify(ref.slice(0, 80))} is not a branch, tag or commit`);
|
|
54
|
+
}
|
|
55
|
+
if (COMMIT.test(ref.toLowerCase())) {
|
|
56
|
+
return ref.toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
const output = await git([
|
|
59
|
+
'ls-remote',
|
|
60
|
+
'--heads',
|
|
61
|
+
'--tags',
|
|
62
|
+
'--',
|
|
63
|
+
repository,
|
|
64
|
+
`refs/heads/${ref}`,
|
|
65
|
+
`refs/tags/${ref}`,
|
|
66
|
+
`refs/tags/${ref}^{}`,
|
|
67
|
+
], env, signal, `the refs of ${repository}`);
|
|
68
|
+
const found = new Map();
|
|
69
|
+
for (const line of output.split('\n')) {
|
|
70
|
+
const [commit, name] = line.split('\t');
|
|
71
|
+
if (commit && name) {
|
|
72
|
+
found.set(name, commit);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const branch = found.get(`refs/heads/${ref}`);
|
|
76
|
+
const tag = found.get(`refs/tags/${ref}^{}`) ?? found.get(`refs/tags/${ref}`);
|
|
77
|
+
if (branch && tag) {
|
|
78
|
+
throw new RefError(`${ref} is both a branch and a tag, name one of them`);
|
|
79
|
+
}
|
|
80
|
+
const commit = branch ?? tag;
|
|
81
|
+
if (!commit) {
|
|
82
|
+
throw new RefError(`there is no branch or tag ${ref}`);
|
|
83
|
+
}
|
|
84
|
+
return commit;
|
|
85
|
+
}
|
|
86
|
+
exports.resolveRef = resolveRef;
|
|
87
|
+
async function git(args, baseEnv, signal, what) {
|
|
88
|
+
const env = {};
|
|
89
|
+
for (const [key, value] of Object.entries(baseEnv)) {
|
|
90
|
+
if (value !== undefined) {
|
|
91
|
+
env[key] = value;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
env.GIT_TERMINAL_PROMPT = '0';
|
|
95
|
+
env.GIT_ALLOW_PROTOCOL = ALLOWED_PROTOCOLS;
|
|
96
|
+
try {
|
|
97
|
+
return (await execFileAsync('git', args, { env, signal })).stdout;
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
const error = e;
|
|
101
|
+
if (error.code === 'ABORT_ERR') {
|
|
102
|
+
throw new Error(`fetching ${what} was cancelled`);
|
|
103
|
+
}
|
|
104
|
+
if (error.code === 'ENOENT') {
|
|
105
|
+
throw new Error(`unable to fetch ${what}: git is not installed`);
|
|
106
|
+
}
|
|
107
|
+
const reason = (error.stderr ?? '')
|
|
108
|
+
.split('\n')
|
|
109
|
+
.map((line) => line.trim())
|
|
110
|
+
.filter((line) => line.length > 0)
|
|
111
|
+
.pop() ?? error.message;
|
|
112
|
+
throw new Error(`unable to fetch ${what}: ${reason}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
//# 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
|