start-command 0.37.8 → 0.38.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/CHANGELOG.md +16 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/bin/cli.js +66 -26
- package/src/lib/args-parser-queries.js +90 -9
- package/src/lib/args-parser.js +20 -1
- package/src/lib/container-removal.js +163 -0
- package/src/lib/detached-finalize.js +29 -2
- package/src/lib/docker-cleanup.js +7 -4
- package/src/lib/docker-command-handoff.js +34 -6
- package/src/lib/docker-post-mortem.js +2 -0
- package/src/lib/docker-runtime-args.js +7 -0
- package/src/lib/docker-snapshot-resume.js +222 -0
- package/src/lib/docker-snapshot-safety.js +124 -12
- package/src/lib/execution-attempt.js +21 -9
- package/src/lib/execution-chain.js +182 -0
- package/src/lib/execution-cleanup.js +193 -0
- package/src/lib/execution-control.js +2 -1
- package/src/lib/execution-env.js +120 -0
- package/src/lib/execution-identity.js +180 -0
- package/src/lib/execution-recovery.js +19 -0
- package/src/lib/execution-resume.js +77 -6
- package/src/lib/execution-store.js +63 -22
- package/src/lib/isolation.js +25 -3
- package/src/lib/launch-identity-options.js +81 -0
- package/src/lib/launch-identity.js +411 -0
- package/src/lib/log-uploader.js +15 -1
- package/src/lib/query-commands.js +34 -6
- package/src/lib/screen-isolation.js +11 -2
- package/src/lib/snapshot-resume-options.js +174 -0
- package/src/lib/spawn-helpers.js +33 -16
- package/src/lib/status-formatter.js +64 -35
- package/src/lib/status-links-notation.js +97 -0
- package/src/lib/store-lock.js +3 -0
- package/src/lib/usage.js +15 -1
- package/test/args-parser-attach-resume.js +1 -1
- package/test/args-parser-control.js +2 -2
- package/test/regression-224.js +807 -0
- package/test/regression-225.js +415 -0
- package/test/regression-226.js +81 -0
- package/test/regression-229.js +267 -0
- package/test/regression-230.js +234 -0
- package/test/regression-231.js +298 -0
- package/test/shared-golden.js +35 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Continuation chains (issue #231): a launch with `--continues <id>` stores
|
|
3
|
+
* the UUID it continues, so a supervisor that relaunches fresh instead of
|
|
4
|
+
* `--resume`-ing in place can still treat every execution of one task as a
|
|
5
|
+
* whole. `--status` prints the chain and `--upload-log` uploads one file with
|
|
6
|
+
* every execution of it, oldest first.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { executionAliases, hasUserNames } = require('./execution-identity');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* UUIDs of the chain `record` belongs to, oldest first. Backward links come
|
|
15
|
+
* from `continues`; forward, the most recently started record that continues
|
|
16
|
+
* the current one is followed. Cycles stop the walk.
|
|
17
|
+
* @param {object[]} records - Stored records
|
|
18
|
+
* @param {object} record - Any member of the chain
|
|
19
|
+
* @returns {object[]} Records of the chain, oldest first
|
|
20
|
+
*/
|
|
21
|
+
function buildChain(records, record) {
|
|
22
|
+
const seen = new Set([record.uuid]);
|
|
23
|
+
const chain = [record];
|
|
24
|
+
for (let cur = record; cur.continues; ) {
|
|
25
|
+
const prev = records.find((r) => r.uuid === cur.continues);
|
|
26
|
+
if (!prev || seen.has(prev.uuid)) {
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
seen.add(prev.uuid);
|
|
30
|
+
chain.unshift(prev);
|
|
31
|
+
cur = prev;
|
|
32
|
+
}
|
|
33
|
+
for (let cur = record; ; ) {
|
|
34
|
+
const next = records
|
|
35
|
+
.filter((r) => r.continues === cur.uuid && !seen.has(r.uuid))
|
|
36
|
+
.sort((a, b) => Date.parse(b.startTime) - Date.parse(a.startTime))[0];
|
|
37
|
+
if (!next) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
seen.add(next.uuid);
|
|
41
|
+
chain.push(next);
|
|
42
|
+
cur = next;
|
|
43
|
+
}
|
|
44
|
+
return chain;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve `--continues <id>` for a new launch to the UUID it stores.
|
|
49
|
+
* @param {object|null} store - ExecutionStore
|
|
50
|
+
* @param {string} identifier - Any name of the earlier execution
|
|
51
|
+
* @param {string} sessionId - UUID of the new launch
|
|
52
|
+
* @returns {string} UUID of the continued execution
|
|
53
|
+
* @throws {Error} When it cannot be resolved
|
|
54
|
+
*/
|
|
55
|
+
function resolveContinues(store, identifier, sessionId) {
|
|
56
|
+
if (!store) {
|
|
57
|
+
throw new Error('--continues requires execution tracking.');
|
|
58
|
+
}
|
|
59
|
+
const target = store.get(identifier);
|
|
60
|
+
if (!target) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`No execution found with UUID or session name: ${identifier}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (target.uuid === sessionId) {
|
|
66
|
+
throw new Error(`Execution ${sessionId} cannot continue itself`);
|
|
67
|
+
}
|
|
68
|
+
return target.uuid;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Wrap a status record so `toObject()` also reports the names it answers to
|
|
73
|
+
* (`aliases`, once `--alias`/`--rename` gave it one) and its continuation
|
|
74
|
+
* `chain`, right after `uuid`.
|
|
75
|
+
* @param {object} record - Record-like object with toObject()
|
|
76
|
+
* @param {object|null} store - ExecutionStore
|
|
77
|
+
* @returns {object} The record, or a wrapper with the extra fields
|
|
78
|
+
*/
|
|
79
|
+
function attachIdentity(record, store) {
|
|
80
|
+
const aliases = hasUserNames(record) ? executionAliases(record) : null;
|
|
81
|
+
const chain =
|
|
82
|
+
store &&
|
|
83
|
+
(record.continues ||
|
|
84
|
+
store.getAll().some((r) => r.continues === record.uuid))
|
|
85
|
+
? buildChain(store.getAll(), record).map((r) => r.uuid)
|
|
86
|
+
: null;
|
|
87
|
+
if (!aliases && !(chain && chain.length > 1)) {
|
|
88
|
+
return record;
|
|
89
|
+
}
|
|
90
|
+
const wrapped = Object.create(Object.getPrototypeOf(record));
|
|
91
|
+
Object.assign(wrapped, record);
|
|
92
|
+
const source = record.toObject.bind(record);
|
|
93
|
+
wrapped.toObject = function () {
|
|
94
|
+
const ordered = {};
|
|
95
|
+
for (const [key, value] of Object.entries(source())) {
|
|
96
|
+
ordered[key] = value;
|
|
97
|
+
if (key === 'uuid') {
|
|
98
|
+
if (aliases) {
|
|
99
|
+
ordered.aliases = aliases;
|
|
100
|
+
}
|
|
101
|
+
if (chain && chain.length > 1) {
|
|
102
|
+
ordered.chain = chain;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return ordered;
|
|
107
|
+
};
|
|
108
|
+
return wrapped;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Text-format lines for the identity fields of a status object.
|
|
113
|
+
* @param {object} obj - Output of toObject()
|
|
114
|
+
* @returns {string[]} Lines
|
|
115
|
+
*/
|
|
116
|
+
function identityTextLines(obj) {
|
|
117
|
+
return [
|
|
118
|
+
...(obj.aliases ? [`Aliases: ${obj.aliases.join(', ')}`] : []),
|
|
119
|
+
...(obj.continues ? [`Continues: ${obj.continues}`] : []),
|
|
120
|
+
...(obj.chain ? [`Chain: ${obj.chain.join(' -> ')}`] : []),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Concatenate the logs of a chain into one temporary file, oldest first,
|
|
126
|
+
* each preceded by a lifecycle header in the format `--resume` writes.
|
|
127
|
+
* @param {object[]} chain - Records, oldest first
|
|
128
|
+
* @returns {{path: string, cleanup: Function}} Combined log
|
|
129
|
+
*/
|
|
130
|
+
function writeChainLog(chain) {
|
|
131
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-chain-log-'));
|
|
132
|
+
const file = path.join(dir, `${chain[chain.length - 1].uuid}.chain.log`);
|
|
133
|
+
chain.forEach((record, index) => {
|
|
134
|
+
const header = {
|
|
135
|
+
event: 'continuation',
|
|
136
|
+
uuid: record.uuid,
|
|
137
|
+
chainIndex: index + 1,
|
|
138
|
+
chainLength: chain.length,
|
|
139
|
+
continues: record.continues || null,
|
|
140
|
+
command: record.command,
|
|
141
|
+
startTime: record.startTime,
|
|
142
|
+
logPath: record.logPath || null,
|
|
143
|
+
};
|
|
144
|
+
fs.appendFileSync(
|
|
145
|
+
file,
|
|
146
|
+
`\n[Start Command Lifecycle] ${JSON.stringify(header)}\n`
|
|
147
|
+
);
|
|
148
|
+
if (record.logPath && fs.existsSync(record.logPath)) {
|
|
149
|
+
fs.appendFileSync(file, fs.readFileSync(record.logPath));
|
|
150
|
+
} else {
|
|
151
|
+
fs.appendFileSync(file, `Log file not found: ${record.logPath}\n`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
path: file,
|
|
156
|
+
cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Log path `--upload-log` should upload for `record`: its own log, or a
|
|
162
|
+
* combined log when it belongs to a chain of more than one execution.
|
|
163
|
+
* @param {object} store - ExecutionStore
|
|
164
|
+
* @param {object} record - Resolved record
|
|
165
|
+
* @returns {{path: string, cleanup: Function}} Log to upload
|
|
166
|
+
*/
|
|
167
|
+
function prepareUploadLog(store, record) {
|
|
168
|
+
const chain = buildChain(store.getAll(), record);
|
|
169
|
+
if (chain.length < 2) {
|
|
170
|
+
return { path: record.logPath, cleanup: () => {} };
|
|
171
|
+
}
|
|
172
|
+
return writeChainLog(chain);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
attachIdentity,
|
|
177
|
+
buildChain,
|
|
178
|
+
identityTextLines,
|
|
179
|
+
prepareUploadLog,
|
|
180
|
+
resolveContinues,
|
|
181
|
+
writeChainLog,
|
|
182
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$ --cleanup <uuid-or-session-name>` (issue #224): remove the Docker
|
|
3
|
+
* containers of one execution and the snapshot images they were derived from.
|
|
4
|
+
*
|
|
5
|
+
* For callers that remove containers themselves: start-command's completion
|
|
6
|
+
* watcher removes a snapshot image only when it removes the container. Nothing
|
|
7
|
+
* here is forced, so Docker keeps any container that is still running and any
|
|
8
|
+
* image that something else still uses; those are reported as retained.
|
|
9
|
+
*
|
|
10
|
+
* The containers are the current one and every earlier container of a
|
|
11
|
+
* `--resume` chain (`sessionNameHistory`). The images are the
|
|
12
|
+
* `start-command-resume/*` images named by the `start-command.snapshot-image`
|
|
13
|
+
* label of those containers, by the record, and by the `-resume-N` names of
|
|
14
|
+
* the chain, removed newest first.
|
|
15
|
+
*/
|
|
16
|
+
const { getDockerCommand } = require('./docker-cleanup');
|
|
17
|
+
const { runCommand } = require('./execution-control');
|
|
18
|
+
const { SNAPSHOT_IMAGE_LABEL } = require('./docker-snapshot-safety');
|
|
19
|
+
|
|
20
|
+
const SNAPSHOT_IMAGE_PATTERN = /^start-command-resume\/[a-z0-9._-]+:\d+$/;
|
|
21
|
+
const NOT_FOUND_PATTERN = /no such (container|image|object)/i;
|
|
22
|
+
|
|
23
|
+
/** Docker image name for snapshot attempt `attempt` of `sessionName`. */
|
|
24
|
+
function snapshotImageName(sessionName, attempt) {
|
|
25
|
+
const { buildSnapshotImageName } = require('./execution-resume');
|
|
26
|
+
return buildSnapshotImageName(sessionName, attempt);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Snapshot images implied by a `<name>-resume-<N>` chain, newest first. */
|
|
30
|
+
function chainImages(containerName) {
|
|
31
|
+
const images = [];
|
|
32
|
+
let current = String(containerName || '');
|
|
33
|
+
for (
|
|
34
|
+
let match = /^(.+)-resume-(\d+)$/.exec(current);
|
|
35
|
+
match;
|
|
36
|
+
match = /^(.+)-resume-(\d+)$/.exec(current)
|
|
37
|
+
) {
|
|
38
|
+
images.push(snapshotImageName(match[1], match[2]));
|
|
39
|
+
current = match[1];
|
|
40
|
+
}
|
|
41
|
+
return images;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function attemptOf(image) {
|
|
45
|
+
return Number(/:(\d+)$/.exec(image)?.[1] || 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function errorText(result) {
|
|
49
|
+
return (
|
|
50
|
+
String(result?.stderr || '').trim() ||
|
|
51
|
+
result?.error ||
|
|
52
|
+
`exit code ${result?.status}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The containers and images `--cleanup <execution>` would remove.
|
|
58
|
+
* @param {object} record - Execution record
|
|
59
|
+
* @param {Function} runner - Sync command runner
|
|
60
|
+
* @returns {{containers: string[], images: string[]}}
|
|
61
|
+
*/
|
|
62
|
+
function planExecutionCleanup(record, runner) {
|
|
63
|
+
const options = record.options || {};
|
|
64
|
+
const current = options.sessionName || options.containerId || null;
|
|
65
|
+
const containers = [
|
|
66
|
+
...new Set(
|
|
67
|
+
[current, ...[...(options.sessionNameHistory || [])].reverse()].filter(
|
|
68
|
+
Boolean
|
|
69
|
+
)
|
|
70
|
+
),
|
|
71
|
+
];
|
|
72
|
+
const images = [options.snapshotImage];
|
|
73
|
+
for (const container of containers) {
|
|
74
|
+
const labelled = runner(getDockerCommand(), [
|
|
75
|
+
'inspect',
|
|
76
|
+
'-f',
|
|
77
|
+
`{{index .Config.Labels "${SNAPSHOT_IMAGE_LABEL}"}}`,
|
|
78
|
+
container,
|
|
79
|
+
]);
|
|
80
|
+
if (labelled.success) {
|
|
81
|
+
images.push(String(labelled.stdout || '').trim());
|
|
82
|
+
}
|
|
83
|
+
images.push(...chainImages(container));
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
containers,
|
|
87
|
+
images: [...new Set(images)]
|
|
88
|
+
.filter((image) => SNAPSHOT_IMAGE_PATTERN.test(image || ''))
|
|
89
|
+
.sort((a, b) => attemptOf(b) - attemptOf(a)),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Remove (or, with `dryRun`, list) one object without force.
|
|
95
|
+
* @returns {{name: string, kind: string, state: string, detail?: string}}
|
|
96
|
+
* `state` is removed, absent, retained or present (dry run)
|
|
97
|
+
*/
|
|
98
|
+
function removeObject(kind, name, runner, dryRun) {
|
|
99
|
+
const docker = getDockerCommand();
|
|
100
|
+
const args = dryRun
|
|
101
|
+
? kind === 'image'
|
|
102
|
+
? ['image', 'inspect', '--format', '{{.Id}}', name]
|
|
103
|
+
: ['container', 'inspect', '--format', '{{.Id}}', name]
|
|
104
|
+
: kind === 'image'
|
|
105
|
+
? ['rmi', name]
|
|
106
|
+
: ['rm', name];
|
|
107
|
+
const result = runner(docker, args);
|
|
108
|
+
if (result.success) {
|
|
109
|
+
return { kind, name, state: dryRun ? 'present' : 'removed' };
|
|
110
|
+
}
|
|
111
|
+
const detail = errorText(result);
|
|
112
|
+
if (!result.error && NOT_FOUND_PATTERN.test(detail)) {
|
|
113
|
+
return { kind, name, state: 'absent' };
|
|
114
|
+
}
|
|
115
|
+
return { kind, name, state: 'retained', detail };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const LABELS = {
|
|
119
|
+
removed: 'Removed',
|
|
120
|
+
absent: 'Already gone:',
|
|
121
|
+
retained: 'Retained',
|
|
122
|
+
present: 'Would remove',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function formatOutcome(outcome) {
|
|
126
|
+
const line = `${LABELS[outcome.state]} ${outcome.kind} ${outcome.name}`;
|
|
127
|
+
return outcome.detail ? `${line} (${outcome.detail})` : line;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remove one execution's containers, then its snapshot images.
|
|
132
|
+
* @param {?object} store - Execution store
|
|
133
|
+
* @param {string} identifier - UUID or session name
|
|
134
|
+
* @param {object} [deps] - {dryRun, runner}
|
|
135
|
+
* @returns {{success: boolean, output?: string, error?: string}}
|
|
136
|
+
*/
|
|
137
|
+
function cleanupExecution(store, identifier, deps = {}) {
|
|
138
|
+
if (!store) {
|
|
139
|
+
return { success: false, error: 'Execution tracking is disabled.' };
|
|
140
|
+
}
|
|
141
|
+
const record = store.get(identifier);
|
|
142
|
+
if (!record) {
|
|
143
|
+
return {
|
|
144
|
+
success: false,
|
|
145
|
+
error: `No execution found with UUID or session name: ${identifier}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (record.options?.isolated !== 'docker') {
|
|
149
|
+
return {
|
|
150
|
+
success: false,
|
|
151
|
+
error: `--cleanup <execution> only removes Docker containers and images; ${identifier} is not a Docker execution.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const runner = deps.runner || runCommand;
|
|
155
|
+
const dryRun = deps.dryRun === true;
|
|
156
|
+
const plan = planExecutionCleanup(record, runner);
|
|
157
|
+
const outcomes = plan.containers.map((name) =>
|
|
158
|
+
removeObject('container', name, runner, dryRun)
|
|
159
|
+
);
|
|
160
|
+
// Images go only after their containers; Docker refuses a used image.
|
|
161
|
+
for (const image of plan.images) {
|
|
162
|
+
outcomes.push(removeObject('image', image, runner, dryRun));
|
|
163
|
+
}
|
|
164
|
+
const current = outcomes[0];
|
|
165
|
+
if (
|
|
166
|
+
!dryRun &&
|
|
167
|
+
current &&
|
|
168
|
+
['removed', 'absent'].includes(current.state) &&
|
|
169
|
+
typeof store.markContainerRemoved === 'function'
|
|
170
|
+
) {
|
|
171
|
+
store.markContainerRemoved(record.uuid, new Date().toISOString());
|
|
172
|
+
}
|
|
173
|
+
const retained = outcomes.filter((outcome) => outcome.state === 'retained');
|
|
174
|
+
const lines = outcomes.length
|
|
175
|
+
? outcomes.map(formatOutcome)
|
|
176
|
+
: ['No containers or snapshot images recorded for this execution.'];
|
|
177
|
+
const output = lines.join('\n');
|
|
178
|
+
if (retained.length > 0) {
|
|
179
|
+
return {
|
|
180
|
+
success: false,
|
|
181
|
+
output,
|
|
182
|
+
error: `${retained.length} Docker object(s) retained; nothing was forced.`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { success: true, output };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
SNAPSHOT_IMAGE_PATTERN,
|
|
190
|
+
chainImages,
|
|
191
|
+
cleanupExecution,
|
|
192
|
+
planExecutionCleanup,
|
|
193
|
+
};
|
|
@@ -17,10 +17,11 @@ const ControlAction = {
|
|
|
17
17
|
TERMINATE: 'terminate',
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
function runCommand(command, args) {
|
|
20
|
+
function runCommand(command, args, extra = {}) {
|
|
21
21
|
const options = {
|
|
22
22
|
encoding: 'utf8',
|
|
23
23
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
24
|
+
...(extra.timeout ? { timeout: extra.timeout } : {}),
|
|
24
25
|
};
|
|
25
26
|
const result = spawnSync(
|
|
26
27
|
command,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* START_* variables that tell a launched command which execution it belongs
|
|
3
|
+
* to (issue #230). Every backend exports the same three variables, next to
|
|
4
|
+
* the kill-recovery `START_COMMAND_RECOVERY_ATTEMPT`:
|
|
5
|
+
*
|
|
6
|
+
* - START_EXECUTION_ID: the execution UUID (`--session-id`)
|
|
7
|
+
* - START_SESSION_NAME: the isolation session/container name (`--session`);
|
|
8
|
+
* empty for direct runs, which have no session
|
|
9
|
+
* - START_ATTEMPT: 1 for the first run, n for the n-th attempt (`--resume`,
|
|
10
|
+
* kill recovery or `--append-attempt`), the same number `--status` shows
|
|
11
|
+
*/
|
|
12
|
+
const { shellQuote } = require('./isolation-log-utils');
|
|
13
|
+
|
|
14
|
+
const EXECUTION_ID_ENV = 'START_EXECUTION_ID';
|
|
15
|
+
const SESSION_NAME_ENV = 'START_SESSION_NAME';
|
|
16
|
+
const ATTEMPT_ENV = 'START_ATTEMPT';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the START_* variables for one attempt.
|
|
20
|
+
* @param {object} params - {executionId, sessionName, attempt}
|
|
21
|
+
* @returns {object} Variables in a stable order, or {} without an execution id
|
|
22
|
+
*/
|
|
23
|
+
function buildExecutionEnv({ executionId, sessionName, attempt } = {}) {
|
|
24
|
+
if (!executionId) {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
const number = Number(attempt);
|
|
28
|
+
return {
|
|
29
|
+
[EXECUTION_ID_ENV]: String(executionId),
|
|
30
|
+
[SESSION_NAME_ENV]: sessionName ? String(sessionName) : '',
|
|
31
|
+
[ATTEMPT_ENV]: String(
|
|
32
|
+
Number.isSafeInteger(number) && number > 0 ? number : 1
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the variables from isolation options (`runIsolated`, `docker run`).
|
|
39
|
+
* @param {object} options - {sessionId|executionId, containerName|session, attemptNumber}
|
|
40
|
+
* @returns {object} Variables, or {}
|
|
41
|
+
*/
|
|
42
|
+
function executionEnvFromOptions(options = {}) {
|
|
43
|
+
return buildExecutionEnv({
|
|
44
|
+
executionId: options.sessionId || options.executionId,
|
|
45
|
+
sessionName: options.containerName || options.session,
|
|
46
|
+
attempt: options.attemptNumber,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `KEY=value` assignments, e.g. for `docker run -e`.
|
|
52
|
+
* @param {object} env - Variables
|
|
53
|
+
* @returns {string[]} Assignments
|
|
54
|
+
*/
|
|
55
|
+
function executionEnvAssignments(env = {}) {
|
|
56
|
+
return Object.entries(env).map(([key, value]) => `${key}=${value}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* POSIX `export` statement for the variables, without a trailing separator.
|
|
61
|
+
* @param {object} env - Variables
|
|
62
|
+
* @returns {string} Statement, or '' when there is nothing to export
|
|
63
|
+
*/
|
|
64
|
+
function buildExecutionEnvExport(env = {}) {
|
|
65
|
+
const entries = Object.entries(env);
|
|
66
|
+
if (!entries.length) {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
return `export ${entries
|
|
70
|
+
.map(([key, value]) => `${key}=${shellQuote(value)}`)
|
|
71
|
+
.join(' ')}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Prefix a shell command with the export statement. Used by the backends
|
|
76
|
+
* whose command runs through a shell that does not inherit our environment:
|
|
77
|
+
* a running tmux server, ssh, and `sudo` (which resets the environment).
|
|
78
|
+
* @param {string} command - Shell command
|
|
79
|
+
* @param {object} env - Variables
|
|
80
|
+
* @returns {string} Prefixed command
|
|
81
|
+
*/
|
|
82
|
+
function prefixWithExecutionEnv(command, env = {}) {
|
|
83
|
+
const statement = buildExecutionEnvExport(env);
|
|
84
|
+
return statement ? `${statement}; ${command}` : command;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Child-process environment with the variables applied.
|
|
89
|
+
* @param {object} env - Variables
|
|
90
|
+
* @param {object} [base] - Base environment (defaults to process.env)
|
|
91
|
+
* @returns {object} Environment
|
|
92
|
+
*/
|
|
93
|
+
function withExecutionEnv(env = {}, base = process.env) {
|
|
94
|
+
return { ...base, ...env };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True when this process was itself started by execution `executionId`, i.e.
|
|
99
|
+
* it is the next level of a stacked isolation that passes `--session-id`
|
|
100
|
+
* down (see command-builder.js).
|
|
101
|
+
* @param {string} executionId - Execution UUID
|
|
102
|
+
* @param {object} [env] - Environment to inspect
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
function isNestedLaunchOf(executionId, env = process.env) {
|
|
106
|
+
return Boolean(executionId) && env[EXECUTION_ID_ENV] === executionId;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
EXECUTION_ID_ENV,
|
|
111
|
+
SESSION_NAME_ENV,
|
|
112
|
+
ATTEMPT_ENV,
|
|
113
|
+
buildExecutionEnv,
|
|
114
|
+
executionEnvFromOptions,
|
|
115
|
+
executionEnvAssignments,
|
|
116
|
+
buildExecutionEnvExport,
|
|
117
|
+
prefixWithExecutionEnv,
|
|
118
|
+
withExecutionEnv,
|
|
119
|
+
isNestedLaunchOf,
|
|
120
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names an execution answers to (issue #231).
|
|
3
|
+
*
|
|
4
|
+
* An execution is found by its UUID, its backend session name, the session
|
|
5
|
+
* names it used before a resume (`options.sessionNameHistory`, #162), its
|
|
6
|
+
* display name (`options.name`, set by `--rename`) and its aliases
|
|
7
|
+
* (`options.aliases`, added by `--alias` and `--rename`). Aliases are kept
|
|
8
|
+
* apart from `sessionNameHistory` because that list names real backend
|
|
9
|
+
* sessions: `--cleanup` removes those containers and `--resume` derives the
|
|
10
|
+
* root session from it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function hasName(record, identifier) {
|
|
14
|
+
const options = record.options || {};
|
|
15
|
+
return (
|
|
16
|
+
options.name === identifier ||
|
|
17
|
+
(Array.isArray(options.aliases) && options.aliases.includes(identifier))
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve an identifier among stored records: UUID, then current session
|
|
23
|
+
* name, then earlier session names, then display name and aliases.
|
|
24
|
+
* @param {object[]} records - Stored records
|
|
25
|
+
* @param {string} identifier - Any name of the execution
|
|
26
|
+
* @returns {object|null} The record, or null
|
|
27
|
+
*/
|
|
28
|
+
function findRecord(records, identifier) {
|
|
29
|
+
if (identifier === null || identifier === undefined || identifier === '') {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return (
|
|
33
|
+
records.find((r) => r.uuid === identifier) ||
|
|
34
|
+
records.find((r) => r.options && r.options.sessionName === identifier) ||
|
|
35
|
+
records.find(
|
|
36
|
+
(r) =>
|
|
37
|
+
r.options &&
|
|
38
|
+
Array.isArray(r.options.sessionNameHistory) &&
|
|
39
|
+
r.options.sessionNameHistory.includes(identifier)
|
|
40
|
+
) ||
|
|
41
|
+
records.find((r) => hasName(r, identifier)) ||
|
|
42
|
+
null
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every name the record answers to, UUID first, without duplicates.
|
|
48
|
+
* @param {object} record - Execution record
|
|
49
|
+
* @returns {string[]} Names
|
|
50
|
+
*/
|
|
51
|
+
function executionAliases(record) {
|
|
52
|
+
const options = record.options || {};
|
|
53
|
+
const names = [
|
|
54
|
+
record.uuid,
|
|
55
|
+
...(options.sessionNameHistory || []),
|
|
56
|
+
options.sessionName,
|
|
57
|
+
...(options.aliases || []),
|
|
58
|
+
options.name,
|
|
59
|
+
].filter((name) => typeof name === 'string' && name);
|
|
60
|
+
return [...new Set(names)];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True once the record has a caller-given name (`--alias`/`--rename`). */
|
|
64
|
+
function hasUserNames(record) {
|
|
65
|
+
const options = record.options || {};
|
|
66
|
+
return Boolean(options.name) || (options.aliases || []).length > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run `mutate(records)` under the store's database lock and persist the
|
|
71
|
+
* records it returns as changed.
|
|
72
|
+
* @param {object} store - ExecutionStore
|
|
73
|
+
* @param {Function} mutate - (records) => changed record, or null for none
|
|
74
|
+
* @returns {*} What `mutate` returned
|
|
75
|
+
*/
|
|
76
|
+
function withStoreLock(store, mutate) {
|
|
77
|
+
const { LockManager } = require('./execution-store');
|
|
78
|
+
const lock = new LockManager(store.lockFilePath);
|
|
79
|
+
if (!lock.acquire()) {
|
|
80
|
+
throw new Error('Failed to acquire lock for database write');
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const records = store.readLinoRecords();
|
|
84
|
+
const changed = mutate(records);
|
|
85
|
+
if (changed) {
|
|
86
|
+
store.writeLinoRecords(records);
|
|
87
|
+
if (store.useLinks) {
|
|
88
|
+
store.writeLinksRecord(changed);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return changed;
|
|
92
|
+
} finally {
|
|
93
|
+
lock.release();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateName(name) {
|
|
98
|
+
if (typeof name !== 'string' || !name.trim()) {
|
|
99
|
+
throw new Error('The new name must not be empty');
|
|
100
|
+
}
|
|
101
|
+
if (name.startsWith('-')) {
|
|
102
|
+
throw new Error(`Invalid name "${name}": names cannot start with "-"`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Add a name to an execution (`$ --alias <id> <name>`) or rename it
|
|
108
|
+
* (`$ --rename <id> <name>`, which keeps the previous display name as an
|
|
109
|
+
* alias). The backend session/container keeps its name; only lookup and the
|
|
110
|
+
* displayed name change.
|
|
111
|
+
* @param {object} store - ExecutionStore
|
|
112
|
+
* @param {string} identifier - Any current name of the execution
|
|
113
|
+
* @param {string} name - Name to add
|
|
114
|
+
* @param {object} [options] - {rename: boolean}
|
|
115
|
+
* @returns {{success: boolean, output?: string, error?: string}}
|
|
116
|
+
*/
|
|
117
|
+
function nameExecution(store, identifier, name, options = {}) {
|
|
118
|
+
if (!store) {
|
|
119
|
+
return { success: false, error: 'Execution tracking is disabled.' };
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
validateName(name);
|
|
123
|
+
let unchanged = false;
|
|
124
|
+
const record = withStoreLock(store, (records) => {
|
|
125
|
+
const target = findRecord(records, identifier);
|
|
126
|
+
if (!target) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`No execution found with UUID or session name: ${identifier}`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const owner = findRecord(records, name);
|
|
132
|
+
if (owner && owner.uuid !== target.uuid) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Name "${name}" already refers to execution ${owner.uuid}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const opts = (target.options = target.options || {});
|
|
138
|
+
const aliases = [...(opts.aliases || [])];
|
|
139
|
+
if (options.rename) {
|
|
140
|
+
const previous = opts.name || opts.sessionName || target.uuid;
|
|
141
|
+
if (previous === name) {
|
|
142
|
+
unchanged = true;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
// A session name or UUID still resolves on its own; an earlier
|
|
146
|
+
// display name has to become an alias to keep resolving.
|
|
147
|
+
if (opts.name) {
|
|
148
|
+
aliases.push(previous);
|
|
149
|
+
}
|
|
150
|
+
opts.name = name;
|
|
151
|
+
} else if (owner) {
|
|
152
|
+
unchanged = true;
|
|
153
|
+
return null;
|
|
154
|
+
} else {
|
|
155
|
+
aliases.push(name);
|
|
156
|
+
}
|
|
157
|
+
opts.aliases = [...new Set(aliases)].filter((a) => a !== opts.name);
|
|
158
|
+
if (opts.aliases.length === 0) {
|
|
159
|
+
delete opts.aliases;
|
|
160
|
+
}
|
|
161
|
+
return target;
|
|
162
|
+
});
|
|
163
|
+
const current = record || store.get(identifier);
|
|
164
|
+
const action = options.rename ? 'Renamed' : 'Aliased';
|
|
165
|
+
return {
|
|
166
|
+
success: true,
|
|
167
|
+
output: `${unchanged ? 'Unchanged' : action} execution ${current.uuid}\naliases: [${executionAliases(current).join(', ')}]`,
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return { success: false, error: error.message };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
executionAliases,
|
|
176
|
+
findRecord,
|
|
177
|
+
hasUserNames,
|
|
178
|
+
nameExecution,
|
|
179
|
+
withStoreLock,
|
|
180
|
+
};
|