@wpelevator/tug 0.2.1 → 0.4.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 +38 -4
- package/dist/agent.d.ts +66 -6
- package/dist/agent.js +851 -170
- package/dist/archive.js +9 -3
- package/dist/artifacts.js +97 -34
- package/dist/build.d.ts +1 -1
- package/dist/build.js +17 -8
- package/dist/cli-build.js +34 -8
- package/dist/cli-config.js +16 -4
- package/dist/cli-install.d.ts +10 -0
- package/dist/cli-install.js +38 -4
- package/dist/cli-live-transport.d.ts +3 -0
- package/dist/cli-live-transport.js +230 -0
- package/dist/cli-live.d.ts +77 -0
- package/dist/cli-live.js +249 -0
- package/dist/cli.js +408 -90
- package/dist/compose.d.ts +35 -9
- package/dist/compose.js +240 -132
- package/dist/container-spec.d.ts +56 -0
- package/dist/container-spec.js +359 -0
- package/dist/control.d.ts +5 -3
- package/dist/control.js +1047 -146
- package/dist/db.d.ts +1 -0
- package/dist/db.js +7 -1
- package/dist/docker-api.d.ts +232 -0
- package/dist/docker-api.js +416 -0
- package/dist/ingress.d.ts +16 -8
- package/dist/ingress.js +84 -29
- package/dist/live.d.ts +83 -0
- package/dist/live.js +276 -0
- package/dist/main.js +3 -1
- package/dist/mesh.js +32 -13
- package/dist/normalize.d.ts +20 -0
- package/dist/normalize.js +75 -0
- package/dist/protocol.d.ts +158 -0
- package/dist/protocol.js +16 -2
- package/dist/reconciler.d.ts +89 -0
- package/dist/reconciler.js +362 -0
- package/dist/runners.d.ts +1 -6
- package/dist/runners.js +1 -5
- package/dist/secrets.js +3 -1
- package/dist/templates/control-compose.yml +1 -0
- package/dist/templates/install-docker.sh +12 -7
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.js +35 -9
- package/dist/util.d.ts +7 -0
- package/dist/util.js +73 -16
- package/package.json +8 -4
- package/dist/rollout.d.ts +0 -72
- package/dist/rollout.js +0 -319
package/dist/archive.js
CHANGED
|
@@ -3,7 +3,9 @@ import * as tar from 'tar';
|
|
|
3
3
|
export async function scanArchive(file, inspect) {
|
|
4
4
|
await new Promise((resolve, reject) => {
|
|
5
5
|
const input = createReadStream(file);
|
|
6
|
-
const parser = new tar.Parser({
|
|
6
|
+
const parser = new tar.Parser({
|
|
7
|
+
strict: true,
|
|
8
|
+
onReadEntry(entry) {
|
|
7
9
|
try {
|
|
8
10
|
inspect(entry);
|
|
9
11
|
entry.resume();
|
|
@@ -11,9 +13,13 @@ export async function scanArchive(file, inspect) {
|
|
|
11
13
|
catch (error) {
|
|
12
14
|
parser.abort(error);
|
|
13
15
|
}
|
|
14
|
-
}
|
|
16
|
+
},
|
|
17
|
+
});
|
|
15
18
|
input.once('error', reject);
|
|
16
|
-
parser.once('error', error => {
|
|
19
|
+
parser.once('error', (error) => {
|
|
20
|
+
input.destroy();
|
|
21
|
+
reject(error);
|
|
22
|
+
});
|
|
17
23
|
parser.once('end', resolve);
|
|
18
24
|
input.pipe(parser);
|
|
19
25
|
});
|
package/dist/artifacts.js
CHANGED
|
@@ -26,10 +26,18 @@ export async function hashArchive(file) {
|
|
|
26
26
|
export async function saveImageArchive(imageRef, file) {
|
|
27
27
|
const child = spawn('docker', ['save', imageRef], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
28
28
|
let error = '';
|
|
29
|
-
child.stderr.on('data', chunk => {
|
|
30
|
-
|
|
29
|
+
child.stderr.on('data', (chunk) => {
|
|
30
|
+
error = (error + chunk).slice(-4000);
|
|
31
|
+
});
|
|
32
|
+
const finished = new Promise((resolve, reject) => {
|
|
33
|
+
child.once('error', reject);
|
|
34
|
+
child.once('close', (code) => code === 0 ? resolve() : reject(new Error(`docker save failed (${code}): ${error}`)));
|
|
35
|
+
});
|
|
31
36
|
try {
|
|
32
|
-
await Promise.all([
|
|
37
|
+
await Promise.all([
|
|
38
|
+
pipeline(child.stdout, createGzip(), createWriteStream(file, { mode: 0o600 })),
|
|
39
|
+
finished,
|
|
40
|
+
]);
|
|
33
41
|
}
|
|
34
42
|
catch (e) {
|
|
35
43
|
child.kill();
|
|
@@ -53,11 +61,14 @@ export async function validateImageArchive(file, expectedRef, maxExpandedBytes =
|
|
|
53
61
|
let manifestBytes;
|
|
54
62
|
let indexBytes;
|
|
55
63
|
let repositoriesBytes;
|
|
56
|
-
await scanArchive(file, entry => {
|
|
64
|
+
await scanArchive(file, (entry) => {
|
|
57
65
|
const name = entry.path.replace(/^\.\//, '');
|
|
58
66
|
if (++entries > 100000 || (expanded += entry.size) > maxExpandedBytes)
|
|
59
67
|
invalid = 'image archive exceeds expanded limits';
|
|
60
|
-
if (name.startsWith('/') ||
|
|
68
|
+
if (name.startsWith('/') ||
|
|
69
|
+
name.split('/').includes('..') ||
|
|
70
|
+
!['File', 'Directory'].includes(entry.type) ||
|
|
71
|
+
names.has(name))
|
|
61
72
|
invalid = 'unsafe image archive entry';
|
|
62
73
|
if (invalid)
|
|
63
74
|
throw new Error(invalid);
|
|
@@ -86,42 +97,53 @@ export async function validateImageArchive(file, expectedRef, maxExpandedBytes =
|
|
|
86
97
|
chunks.push(Buffer.from(chunk));
|
|
87
98
|
}
|
|
88
99
|
});
|
|
89
|
-
entry.on('end', () => {
|
|
90
|
-
|
|
100
|
+
entry.on('end', () => {
|
|
101
|
+
blobHashes.set(name, hash.digest('hex'));
|
|
102
|
+
if (json)
|
|
103
|
+
blobMetadata.set(name, Buffer.concat(chunks));
|
|
104
|
+
});
|
|
91
105
|
}
|
|
92
106
|
if (['manifest.json', 'index.json', 'repositories'].includes(name) && !invalid) {
|
|
93
107
|
if (entry.size > 1024 ** 2) {
|
|
94
108
|
throw new Error('image manifest exceeds limit');
|
|
95
109
|
}
|
|
96
110
|
const chunks = [];
|
|
97
|
-
entry.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
|
98
|
-
entry.on('end', () => {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
111
|
+
entry.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
112
|
+
entry.on('end', () => {
|
|
113
|
+
const value = Buffer.concat(chunks);
|
|
114
|
+
if (name === 'manifest.json')
|
|
115
|
+
manifestBytes = value;
|
|
116
|
+
else if (name === 'index.json')
|
|
117
|
+
indexBytes = value;
|
|
118
|
+
else
|
|
119
|
+
repositoriesBytes = value;
|
|
120
|
+
});
|
|
104
121
|
}
|
|
105
122
|
});
|
|
106
123
|
if (invalid)
|
|
107
124
|
throw new Error(invalid);
|
|
108
125
|
const manifest = JSON.parse(manifestBytes?.toString() || 'null');
|
|
109
|
-
if (!Array.isArray(manifest) ||
|
|
126
|
+
if (!Array.isArray(manifest) ||
|
|
127
|
+
manifest.length !== 1 ||
|
|
128
|
+
JSON.stringify(manifest[0].RepoTags) !== JSON.stringify([expectedRef]))
|
|
110
129
|
throw new Error('archive must contain exactly the declared Tug image tag');
|
|
111
130
|
if (typeof manifest[0].Config !== 'string' || !regular.has(manifest[0].Config))
|
|
112
131
|
throw new Error('image archive is missing its configuration');
|
|
113
|
-
if (!Array.isArray(manifest[0].Layers) ||
|
|
132
|
+
if (!Array.isArray(manifest[0].Layers) ||
|
|
133
|
+
manifest[0].Layers.some((layer) => typeof layer !== 'string' || !regular.has(layer)))
|
|
114
134
|
throw new Error('image archive is missing layers');
|
|
115
135
|
let configBytes;
|
|
116
|
-
await scanArchive(file, entry => {
|
|
136
|
+
await scanArchive(file, (entry) => {
|
|
117
137
|
if (entry.path.replace(/^\.\//, '') !== manifest[0].Config)
|
|
118
138
|
return;
|
|
119
139
|
if (entry.size > 4 * 1024 ** 2) {
|
|
120
140
|
throw new Error('image configuration exceeds limit');
|
|
121
141
|
}
|
|
122
142
|
const chunks = [];
|
|
123
|
-
entry.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
|
124
|
-
entry.on('end', () => {
|
|
143
|
+
entry.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
144
|
+
entry.on('end', () => {
|
|
145
|
+
configBytes = Buffer.concat(chunks);
|
|
146
|
+
});
|
|
125
147
|
});
|
|
126
148
|
if (invalid)
|
|
127
149
|
throw new Error(invalid);
|
|
@@ -131,8 +153,17 @@ export async function validateImageArchive(file, expectedRef, maxExpandedBytes =
|
|
|
131
153
|
const index = JSON.parse(indexBytes.toString());
|
|
132
154
|
const descriptor = index.manifests?.[0];
|
|
133
155
|
const annotations = descriptor?.annotations || {};
|
|
134
|
-
const allowedNames = new Set([
|
|
135
|
-
|
|
156
|
+
const allowedNames = new Set([
|
|
157
|
+
expectedRef,
|
|
158
|
+
`docker.io/${expectedRef}`,
|
|
159
|
+
...(!expectedRef.includes('/') ? [`docker.io/library/${expectedRef}`] : []),
|
|
160
|
+
]);
|
|
161
|
+
if (!Array.isArray(index.manifests) ||
|
|
162
|
+
index.manifests.length < 1 ||
|
|
163
|
+
!allowedNames.has(annotations['io.containerd.image.name']) ||
|
|
164
|
+
(annotations['org.opencontainers.image.ref.name'] &&
|
|
165
|
+
!allowedNames.has(annotations['org.opencontainers.image.ref.name']) &&
|
|
166
|
+
annotations['org.opencontainers.image.ref.name'] !== expectedRef.split(':').at(-1)))
|
|
136
167
|
throw new Error('OCI index contains unexpected image names');
|
|
137
168
|
const expectedConfig = `sha256:${createHash('sha256').update(configBytes).digest('hex')}`;
|
|
138
169
|
const expectedLayers = manifest[0].Layers;
|
|
@@ -147,15 +178,21 @@ export async function validateImageArchive(file, expectedRef, maxExpandedBytes =
|
|
|
147
178
|
throw new Error('OCI metadata content digest mismatch or missing blob');
|
|
148
179
|
return JSON.parse(bytes.toString());
|
|
149
180
|
};
|
|
150
|
-
const isAttestationDocument = (document, imageConfig) => (Object.keys(imageConfig).length === 0 &&
|
|
181
|
+
const isAttestationDocument = (document, imageConfig) => (Object.keys(imageConfig).length === 0 &&
|
|
182
|
+
document.artifactType === 'application/vnd.docker.attestation.manifest.v1+json') ||
|
|
183
|
+
(imageConfig.os === 'unknown' && imageConfig.architecture === 'unknown');
|
|
151
184
|
/** An attestation is acceptable only when it describes this exact saved image and carries nothing but in-toto statements. */
|
|
152
185
|
const verifyAttestation = (document, subject) => {
|
|
153
|
-
const subjectConfig = subject
|
|
186
|
+
const subjectConfig = subject
|
|
187
|
+
? readBlob(subject).config
|
|
188
|
+
: undefined;
|
|
154
189
|
if (subjectConfig?.digest !== expectedConfig)
|
|
155
190
|
throw new Error('attestation subject does not match saved image');
|
|
156
191
|
for (const layer of document.layers) {
|
|
157
192
|
const name = `blobs/sha256/${layer.digest?.slice(7)}`;
|
|
158
|
-
if (layer.mediaType !== 'application/vnd.in-toto+json' ||
|
|
193
|
+
if (layer.mediaType !== 'application/vnd.in-toto+json' ||
|
|
194
|
+
!/^sha256:[a-f0-9]{64}$/.test(layer.digest) ||
|
|
195
|
+
blobHashes.get(name) !== layer.digest.slice(7))
|
|
159
196
|
throw new Error('invalid attestation layer');
|
|
160
197
|
}
|
|
161
198
|
};
|
|
@@ -163,7 +200,8 @@ export async function validateImageArchive(file, expectedRef, maxExpandedBytes =
|
|
|
163
200
|
if (visited.has(desc.digest))
|
|
164
201
|
return;
|
|
165
202
|
visited.add(desc.digest);
|
|
166
|
-
if (desc.annotations?.['io.containerd.image.name'] ||
|
|
203
|
+
if (desc.annotations?.['io.containerd.image.name'] ||
|
|
204
|
+
desc.annotations?.['org.opencontainers.image.ref.name'])
|
|
167
205
|
throw new Error('nested OCI metadata contains unexpected image names');
|
|
168
206
|
const document = readBlob(desc.digest);
|
|
169
207
|
if (Array.isArray(document.manifests)) {
|
|
@@ -179,14 +217,17 @@ export async function validateImageArchive(file, expectedRef, maxExpandedBytes =
|
|
|
179
217
|
if (!configDescriptor?.digest || !Array.isArray(document.layers))
|
|
180
218
|
throw new Error('invalid OCI image manifest');
|
|
181
219
|
const imageConfig = readBlob(configDescriptor.digest);
|
|
182
|
-
const attestation = desc.annotations?.['vnd.docker.reference.type'] === 'attestation-manifest' &&
|
|
220
|
+
const attestation = desc.annotations?.['vnd.docker.reference.type'] === 'attestation-manifest' &&
|
|
221
|
+
desc.platform?.os === 'unknown' &&
|
|
222
|
+
desc.platform?.architecture === 'unknown';
|
|
183
223
|
if (attestation && isAttestationDocument(document, imageConfig)) {
|
|
184
|
-
verifyAttestation(document, document.subject?.digest ||
|
|
224
|
+
verifyAttestation(document, document.subject?.digest ||
|
|
225
|
+
desc.annotations?.['vnd.docker.reference.digest']);
|
|
185
226
|
return;
|
|
186
227
|
}
|
|
187
228
|
if (configDescriptor.digest !== expectedConfig)
|
|
188
229
|
throw new Error('OCI graph configuration does not match saved image');
|
|
189
|
-
const layerPaths = document.layers.map(layer => {
|
|
230
|
+
const layerPaths = document.layers.map((layer) => {
|
|
190
231
|
if (!/^sha256:[a-f0-9]{64}$/.test(layer.digest))
|
|
191
232
|
throw new Error('invalid OCI layer digest');
|
|
192
233
|
const name = `blobs/sha256/${layer.digest.slice(7)}`;
|
|
@@ -207,12 +248,17 @@ export async function validateImageArchive(file, expectedRef, maxExpandedBytes =
|
|
|
207
248
|
for (const extra of index.manifests.slice(1)) {
|
|
208
249
|
const annotationNames = Object.keys(extra.annotations || {});
|
|
209
250
|
const unexpected = () => new Error('OCI index contains an unexpected extra manifest');
|
|
210
|
-
if (annotationNames.length !== 1 ||
|
|
251
|
+
if (annotationNames.length !== 1 ||
|
|
252
|
+
annotationNames[0] !== 'io.containerd.manifest.subject' ||
|
|
253
|
+
!/^sha256:[a-f0-9]{64}$/.test(extra.digest))
|
|
211
254
|
throw unexpected();
|
|
212
255
|
const document = readBlob(extra.digest);
|
|
213
256
|
const configDescriptor = document.config;
|
|
214
257
|
const subject = document.subject?.digest;
|
|
215
|
-
if (!configDescriptor?.digest ||
|
|
258
|
+
if (!configDescriptor?.digest ||
|
|
259
|
+
!Array.isArray(document.layers) ||
|
|
260
|
+
subject !== extra.annotations['io.containerd.manifest.subject'] ||
|
|
261
|
+
!isAttestationDocument(document, readBlob(configDescriptor.digest)))
|
|
216
262
|
throw unexpected();
|
|
217
263
|
verifyAttestation(document, subject);
|
|
218
264
|
}
|
|
@@ -222,17 +268,34 @@ export async function validateImageArchive(file, expectedRef, maxExpandedBytes =
|
|
|
222
268
|
const colon = expectedRef.lastIndexOf(':');
|
|
223
269
|
const repo = expectedRef.slice(0, colon);
|
|
224
270
|
const tag = expectedRef.slice(colon + 1);
|
|
225
|
-
if (JSON.stringify(Object.keys(repositories)) !== JSON.stringify([repo]) ||
|
|
271
|
+
if (JSON.stringify(Object.keys(repositories)) !== JSON.stringify([repo]) ||
|
|
272
|
+
JSON.stringify(Object.keys(repositories[repo])) !== JSON.stringify([tag]))
|
|
226
273
|
throw new Error('archive repositories contain unexpected image tags');
|
|
227
274
|
}
|
|
228
275
|
const config = JSON.parse(configBytes.toString());
|
|
229
276
|
if (config.os !== 'linux' || !['amd64', 'arm64'].includes(config.architecture))
|
|
230
277
|
throw new Error('only linux/amd64 and linux/arm64 image archives are supported');
|
|
231
|
-
return {
|
|
278
|
+
return {
|
|
279
|
+
imageId: `sha256:${createHash('sha256').update(configBytes).digest('hex')}`,
|
|
280
|
+
platform: `${config.os}/${config.architecture}`,
|
|
281
|
+
...(await hashArchive(file)),
|
|
282
|
+
};
|
|
232
283
|
}
|
|
233
284
|
export function verifyDownload(expected) {
|
|
234
285
|
const hash = createHash('sha256');
|
|
235
286
|
let bytes = 0;
|
|
236
|
-
return new Transform({
|
|
237
|
-
|
|
287
|
+
return new Transform({
|
|
288
|
+
transform(chunk, _encoding, callback) {
|
|
289
|
+
bytes += chunk.length;
|
|
290
|
+
if (bytes > expected.sizeBytes)
|
|
291
|
+
return callback(new Error('image download exceeds expected size'));
|
|
292
|
+
hash.update(chunk);
|
|
293
|
+
callback(null, chunk);
|
|
294
|
+
},
|
|
295
|
+
flush(callback) {
|
|
296
|
+
callback(bytes === expected.sizeBytes && hash.digest('hex') === expected.sha256
|
|
297
|
+
? undefined
|
|
298
|
+
: new Error('image download checksum or size mismatch'));
|
|
299
|
+
},
|
|
300
|
+
});
|
|
238
301
|
}
|
package/dist/build.d.ts
CHANGED
|
@@ -19,4 +19,4 @@ export interface BuildAndPushOptions {
|
|
|
19
19
|
imageRef: string;
|
|
20
20
|
push?: boolean;
|
|
21
21
|
}
|
|
22
|
-
export declare function buildAndPush({ contextDir, service, buildSpec, imageRef, push }: BuildAndPushOptions): Promise<void>;
|
|
22
|
+
export declare function buildAndPush({ contextDir, service, buildSpec, imageRef, push, }: BuildAndPushOptions): Promise<void>;
|
package/dist/build.js
CHANGED
|
@@ -8,10 +8,12 @@ export function run(cmd, args, opts = {}) {
|
|
|
8
8
|
return new Promise((resolve, reject) => {
|
|
9
9
|
const p = spawn(cmd, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
10
10
|
let out = '', err = '';
|
|
11
|
-
p.stdout.on('data', d => out += d);
|
|
12
|
-
p.stderr.on('data', d => err += d);
|
|
11
|
+
p.stdout.on('data', (d) => (out += d));
|
|
12
|
+
p.stderr.on('data', (d) => (err += d));
|
|
13
13
|
p.on('error', reject);
|
|
14
|
-
p.on('close', code => code === 0
|
|
14
|
+
p.on('close', (code) => code === 0
|
|
15
|
+
? resolve({ out, err })
|
|
16
|
+
: reject(new Error(`${cmd} ${args.join(' ')} failed (${code})\n${out}\n${err}`)));
|
|
15
17
|
});
|
|
16
18
|
}
|
|
17
19
|
export async function unpackContext(tarball, dest) {
|
|
@@ -19,22 +21,29 @@ export async function unpackContext(tarball, dest) {
|
|
|
19
21
|
await ensureDir(dest);
|
|
20
22
|
let expanded = 0;
|
|
21
23
|
let entries = 0;
|
|
22
|
-
await scanArchive(tarball, entry => {
|
|
24
|
+
await scanArchive(tarball, (entry) => {
|
|
23
25
|
expanded += entry.size;
|
|
24
26
|
entries++;
|
|
25
|
-
if (expanded > 20 * 1024 ** 3 ||
|
|
27
|
+
if (expanded > 20 * 1024 ** 3 ||
|
|
28
|
+
entries > 100000 ||
|
|
29
|
+
path.isAbsolute(entry.path) ||
|
|
30
|
+
entry.path.split('/').includes('..') ||
|
|
31
|
+
!['File', 'Directory'].includes(entry.type))
|
|
26
32
|
throw new Error('source context has unsafe entries or exceeds expanded limits');
|
|
27
33
|
});
|
|
28
34
|
await tar.x({ file: tarball, cwd: dest, strict: true });
|
|
29
35
|
}
|
|
30
|
-
export async function buildAndPush({ contextDir, service, buildSpec, imageRef, push = true }) {
|
|
36
|
+
export async function buildAndPush({ contextDir, service, buildSpec, imageRef, push = true, }) {
|
|
31
37
|
const buildPath = typeof buildSpec === 'string' ? buildSpec : buildSpec?.context || '.';
|
|
32
|
-
const dockerfilePath = typeof buildSpec === 'object' && buildSpec.dockerfile
|
|
38
|
+
const dockerfilePath = typeof buildSpec === 'object' && buildSpec.dockerfile
|
|
39
|
+
? path.resolve(contextDir, buildPath, buildSpec.dockerfile)
|
|
40
|
+
: undefined;
|
|
33
41
|
if (dockerfilePath && !dockerfilePath.startsWith(`${path.resolve(contextDir)}${path.sep}`))
|
|
34
42
|
throw new Error(`Dockerfile escapes uploaded root for ${service}`);
|
|
35
43
|
const dockerfile = dockerfilePath ? ['-f', dockerfilePath] : [];
|
|
36
44
|
const fullContext = path.resolve(contextDir, buildPath);
|
|
37
|
-
if (fullContext !== path.resolve(contextDir) &&
|
|
45
|
+
if (fullContext !== path.resolve(contextDir) &&
|
|
46
|
+
!fullContext.startsWith(`${path.resolve(contextDir)}${path.sep}`))
|
|
38
47
|
throw new Error(`build context escapes uploaded root for ${service}`);
|
|
39
48
|
if (!(await exists(fullContext)))
|
|
40
49
|
throw new Error(`build context not found for ${service}: ${buildPath}`);
|
package/dist/cli-build.js
CHANGED
|
@@ -6,25 +6,45 @@ import { buildAndPush, run } from "./build.js";
|
|
|
6
6
|
import { saveImageArchive } from "./artifacts.js";
|
|
7
7
|
/** Credential exclusions are mandatory and cannot be re-included by source ignore rules. */
|
|
8
8
|
export function sourceArchiveFilter(entry, configPaths, ignores) {
|
|
9
|
-
const relative = entry
|
|
10
|
-
|
|
9
|
+
const relative = entry
|
|
10
|
+
.replaceAll('\\', '/')
|
|
11
|
+
.replace(/^(\.\/)+/, '')
|
|
12
|
+
.replace(/\/$/, '');
|
|
13
|
+
if (relative.split('/').some((part) => ['.git', '.tug.json'].includes(part)))
|
|
11
14
|
return false;
|
|
12
|
-
return ![...configPaths, ...ignores].some(ignore => relative === ignore || relative.startsWith(`${ignore}/`));
|
|
15
|
+
return ![...configPaths, ...ignores].some((ignore) => relative === ignore || relative.startsWith(`${ignore}/`));
|
|
13
16
|
}
|
|
14
17
|
export async function createSourceArchive(contextDir, archive, configFiles) {
|
|
15
|
-
const configPaths = configFiles
|
|
18
|
+
const configPaths = configFiles
|
|
19
|
+
.map((file) => path.relative(contextDir, file).split(path.sep).join('/'))
|
|
20
|
+
.filter((file) => file !== '..' && !file.startsWith('../') && !path.isAbsolute(file));
|
|
16
21
|
const ignores = ['node_modules', 'data', 'registry-data'];
|
|
17
22
|
try {
|
|
18
|
-
ignores.push(...(await fs.readFile(path.join(contextDir, '.dockerignore'), 'utf8'))
|
|
23
|
+
ignores.push(...(await fs.readFile(path.join(contextDir, '.dockerignore'), 'utf8'))
|
|
24
|
+
.split(/\r?\n/)
|
|
25
|
+
.map((line) => line.trim().replace(/\/$/, ''))
|
|
26
|
+
.filter((line) => line && !line.startsWith('#') && !line.startsWith('!')));
|
|
19
27
|
}
|
|
20
28
|
catch (error) {
|
|
21
29
|
if (error.code !== 'ENOENT')
|
|
22
30
|
throw error;
|
|
23
31
|
}
|
|
24
|
-
await tar.c({
|
|
32
|
+
await tar.c({
|
|
33
|
+
gzip: true,
|
|
34
|
+
file: archive,
|
|
35
|
+
cwd: contextDir,
|
|
36
|
+
filter: (entry) => sourceArchiveFilter(entry, configPaths, ignores),
|
|
37
|
+
}, ['.']);
|
|
25
38
|
}
|
|
26
39
|
/** Build only application build services; callers own temporary files and upload transport. */
|
|
27
|
-
export async function prepareLocalImages(compose, contextDir, directory, dependencies = {
|
|
40
|
+
export async function prepareLocalImages(compose, contextDir, directory, dependencies = {
|
|
41
|
+
build: buildAndPush,
|
|
42
|
+
save: saveImageArchive,
|
|
43
|
+
nonce: randomUUID,
|
|
44
|
+
remove: async (imageRef) => {
|
|
45
|
+
await run('docker', ['image', 'rm', imageRef]);
|
|
46
|
+
},
|
|
47
|
+
}) {
|
|
28
48
|
const images = [];
|
|
29
49
|
const nonce = dependencies.nonce();
|
|
30
50
|
for (const [service, spec] of Object.entries(compose.services)) {
|
|
@@ -33,7 +53,13 @@ export async function prepareLocalImages(compose, contextDir, directory, depende
|
|
|
33
53
|
const field = `image-${images.length}`;
|
|
34
54
|
const imageRef = `tug-build/${nonce}/${field}:local`;
|
|
35
55
|
try {
|
|
36
|
-
await dependencies.build({
|
|
56
|
+
await dependencies.build({
|
|
57
|
+
contextDir,
|
|
58
|
+
service,
|
|
59
|
+
buildSpec: spec.build,
|
|
60
|
+
imageRef,
|
|
61
|
+
push: false,
|
|
62
|
+
});
|
|
37
63
|
const archive = await dependencies.save(imageRef, path.join(directory, `${field}.tar.gz`));
|
|
38
64
|
images.push({ service, imageRef, ...archive, field });
|
|
39
65
|
}
|
package/dist/cli-config.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
export async function resolveCliConfig(options, env, cwd, creating = false) {
|
|
5
5
|
for (const key of ['config', 'controlUrl', 'project'])
|
|
6
6
|
if (options[key] !== undefined && (!options[key].trim() || /[\r\n\0]/.test(options[key])))
|
|
7
|
-
throw new Error(`--${key.replace(/[A-Z]/g, c => '-' + c.toLowerCase())} must be a nonempty single-line value`);
|
|
7
|
+
throw new Error(`--${key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())} must be a nonempty single-line value`);
|
|
8
8
|
const file = path.resolve(cwd, options.config || env.TUG_CONFIG || '.tug.json');
|
|
9
9
|
let config = {};
|
|
10
10
|
try {
|
|
@@ -24,7 +24,12 @@ export async function resolveCliConfig(options, env, cwd, creating = false) {
|
|
|
24
24
|
throw new Error(`Cannot read config ${file}: ${error.message}`, { cause: error });
|
|
25
25
|
}
|
|
26
26
|
const controlUrl = options.controlUrl || env.TUG_CONTROL_URL || config.controlUrl || 'http://localhost:8080';
|
|
27
|
-
return {
|
|
27
|
+
return {
|
|
28
|
+
file,
|
|
29
|
+
controlUrl: normalizeControlUrl(controlUrl),
|
|
30
|
+
operatorToken: env.TUG_TOKEN || config.operatorToken,
|
|
31
|
+
project: options.project || env.TUG_PROJECT || config.project || 'site',
|
|
32
|
+
};
|
|
28
33
|
}
|
|
29
34
|
export function normalizeControlUrl(value) {
|
|
30
35
|
if (!value.trim() || /[\r\n\0]/.test(value))
|
|
@@ -36,12 +41,19 @@ export function normalizeControlUrl(value) {
|
|
|
36
41
|
catch {
|
|
37
42
|
throw new Error('control URL must be an absolute http:// or https:// URL');
|
|
38
43
|
}
|
|
39
|
-
if (!['http:', 'https:'].includes(url.protocol) ||
|
|
44
|
+
if (!['http:', 'https:'].includes(url.protocol) ||
|
|
45
|
+
url.username ||
|
|
46
|
+
url.password ||
|
|
47
|
+
url.search ||
|
|
48
|
+
url.hash)
|
|
40
49
|
throw new Error('control URL must use http:// or https:// without credentials, query or fragment');
|
|
41
50
|
return value.replace(/\/+$/, '');
|
|
42
51
|
}
|
|
43
52
|
export function integerOption(value, name, minimum = 1, maximum = Number.MAX_SAFE_INTEGER) {
|
|
44
|
-
if (!/^\d+$/.test(value) ||
|
|
53
|
+
if (!/^\d+$/.test(value) ||
|
|
54
|
+
!Number.isSafeInteger(Number(value)) ||
|
|
55
|
+
Number(value) < minimum ||
|
|
56
|
+
Number(value) > maximum)
|
|
45
57
|
throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`);
|
|
46
58
|
return Number(value);
|
|
47
59
|
}
|
package/dist/cli-install.d.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
/** The agent dials container IPs for health checks; from the default bridge they are unreachable on current Docker (29.x), so it always shares the host network namespace. */
|
|
2
|
+
export declare const HOST_NETWORK_FLAG = "--network host";
|
|
1
3
|
export declare const MESH_RUN_FLAGS = "--network host --cap-add NET_ADMIN";
|
|
4
|
+
/** Docker platform for the architecture a host reports with `uname -m`. */
|
|
5
|
+
export declare function dockerPlatform(uname: string): string;
|
|
6
|
+
/** Build for the host the image will run on, not the machine that builds it (an arm64 laptop and an amd64 VPS differ). */
|
|
7
|
+
export declare function localImageBuildArgs(imageTag: string, uname: string): string[];
|
|
2
8
|
export declare function shellQuote(value: unknown): string;
|
|
3
9
|
export declare function dockerRunSnippet(image: string, joinKey: string, mesh?: boolean): string;
|
|
4
10
|
/** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
|
|
5
11
|
export declare function enrollmentCloudInit(bootstrap: string, command: string): string;
|
|
12
|
+
/** Name of the container `control-install` creates (Compose project `tug-control`, service `control`). */
|
|
13
|
+
export declare const CONTROL_CONTAINER = "tug-control-control-1";
|
|
14
|
+
/** A TCP port is free, or is held by the control container this install would replace, so installing again upgrades in place. */
|
|
15
|
+
export declare function controlPortCheck(port: number): string;
|
|
6
16
|
/** An occupied mesh port is reusable only when the installed Tug agent owns the live key. */
|
|
7
17
|
export declare function meshPortCheck(port: number): string;
|
package/dist/cli-install.js
CHANGED
|
@@ -1,12 +1,46 @@
|
|
|
1
1
|
import yaml from 'js-yaml';
|
|
2
|
-
|
|
3
|
-
export
|
|
2
|
+
/** The agent dials container IPs for health checks; from the default bridge they are unreachable on current Docker (29.x), so it always shares the host network namespace. */
|
|
3
|
+
export const HOST_NETWORK_FLAG = '--network host';
|
|
4
|
+
export const MESH_RUN_FLAGS = `${HOST_NETWORK_FLAG} --cap-add NET_ADMIN`;
|
|
5
|
+
/** Docker platform for the architecture a host reports with `uname -m`. */
|
|
6
|
+
export function dockerPlatform(uname) {
|
|
7
|
+
const arch = uname.trim();
|
|
8
|
+
if (arch === 'x86_64' || arch === 'amd64')
|
|
9
|
+
return 'linux/amd64';
|
|
10
|
+
if (arch === 'aarch64' || arch === 'arm64')
|
|
11
|
+
return 'linux/arm64';
|
|
12
|
+
throw new Error(`unsupported host architecture '${arch}'; --build-local-image supports x86_64 and aarch64`);
|
|
13
|
+
}
|
|
14
|
+
/** Build for the host the image will run on, not the machine that builds it (an arm64 laptop and an amd64 VPS differ). */
|
|
15
|
+
export function localImageBuildArgs(imageTag, uname) {
|
|
16
|
+
return ['build', '--platform', dockerPlatform(uname), '-t', imageTag, '.'];
|
|
17
|
+
}
|
|
18
|
+
export function shellQuote(value) {
|
|
19
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
20
|
+
}
|
|
4
21
|
export function dockerRunSnippet(image, joinKey, mesh = false) {
|
|
5
|
-
return `docker run -d --name tug-agent --restart unless-stopped ${mesh ? `${MESH_RUN_FLAGS} -e TUG_MESH=true ` :
|
|
22
|
+
return `docker run -d --name tug-agent --restart unless-stopped ${mesh ? `${MESH_RUN_FLAGS} -e TUG_MESH=true ` : `${HOST_NETWORK_FLAG} `}-v /var/run/docker.sock:/var/run/docker.sock -v tug-agent:/var/lib/tug -e TUG_JOIN_KEY=${shellQuote(joinKey)} ${shellQuote(image)} agent`;
|
|
6
23
|
}
|
|
7
24
|
/** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
|
|
8
25
|
export function enrollmentCloudInit(bootstrap, command) {
|
|
9
|
-
return '#cloud-config\n' +
|
|
26
|
+
return ('#cloud-config\n' +
|
|
27
|
+
yaml.dump({
|
|
28
|
+
write_files: [
|
|
29
|
+
{ path: '/opt/tug/install-docker.sh', permissions: '0700', content: bootstrap },
|
|
30
|
+
],
|
|
31
|
+
runcmd: [['sh', '-eu', '-c', `sh /opt/tug/install-docker.sh && ${command}`]],
|
|
32
|
+
}, { lineWidth: -1, noRefs: true }));
|
|
33
|
+
}
|
|
34
|
+
/** Name of the container `control-install` creates (Compose project `tug-control`, service `control`). */
|
|
35
|
+
export const CONTROL_CONTAINER = 'tug-control-control-1';
|
|
36
|
+
/** A TCP port is free, or is held by the control container this install would replace, so installing again upgrades in place. */
|
|
37
|
+
export function controlPortCheck(port) {
|
|
38
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
39
|
+
throw new Error('port must be an integer from 1 to 65535');
|
|
40
|
+
return `command -v ss >/dev/null 2>&1 || exit 0
|
|
41
|
+
ss -ltn | awk '{print $4}' | grep -qE '[.:]${port}$' || exit 0
|
|
42
|
+
[ -n "$(docker ps -q --filter 'name=^/${CONTROL_CONTAINER}$' --filter 'publish=${port}' 2>/dev/null)" ]
|
|
43
|
+
`;
|
|
10
44
|
}
|
|
11
45
|
/** An occupied mesh port is reusable only when the installed Tug agent owns the live key. */
|
|
12
46
|
export function meshPortCheck(port) {
|