@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.
Files changed (50) hide show
  1. package/README.md +38 -4
  2. package/dist/agent.d.ts +66 -6
  3. package/dist/agent.js +851 -170
  4. package/dist/archive.js +9 -3
  5. package/dist/artifacts.js +97 -34
  6. package/dist/build.d.ts +1 -1
  7. package/dist/build.js +17 -8
  8. package/dist/cli-build.js +34 -8
  9. package/dist/cli-config.js +16 -4
  10. package/dist/cli-install.d.ts +10 -0
  11. package/dist/cli-install.js +38 -4
  12. package/dist/cli-live-transport.d.ts +3 -0
  13. package/dist/cli-live-transport.js +230 -0
  14. package/dist/cli-live.d.ts +77 -0
  15. package/dist/cli-live.js +249 -0
  16. package/dist/cli.js +408 -90
  17. package/dist/compose.d.ts +35 -9
  18. package/dist/compose.js +240 -132
  19. package/dist/container-spec.d.ts +56 -0
  20. package/dist/container-spec.js +359 -0
  21. package/dist/control.d.ts +5 -3
  22. package/dist/control.js +1047 -146
  23. package/dist/db.d.ts +1 -0
  24. package/dist/db.js +7 -1
  25. package/dist/docker-api.d.ts +232 -0
  26. package/dist/docker-api.js +416 -0
  27. package/dist/ingress.d.ts +16 -8
  28. package/dist/ingress.js +84 -29
  29. package/dist/live.d.ts +83 -0
  30. package/dist/live.js +276 -0
  31. package/dist/main.js +3 -1
  32. package/dist/mesh.js +32 -13
  33. package/dist/normalize.d.ts +20 -0
  34. package/dist/normalize.js +75 -0
  35. package/dist/protocol.d.ts +158 -0
  36. package/dist/protocol.js +16 -2
  37. package/dist/reconciler.d.ts +89 -0
  38. package/dist/reconciler.js +362 -0
  39. package/dist/runners.d.ts +1 -6
  40. package/dist/runners.js +1 -5
  41. package/dist/secrets.js +3 -1
  42. package/dist/templates/control-compose.yml +1 -0
  43. package/dist/templates/install-docker.sh +12 -7
  44. package/dist/tokens.d.ts +1 -1
  45. package/dist/tokens.js +35 -9
  46. package/dist/util.d.ts +7 -0
  47. package/dist/util.js +73 -16
  48. package/package.json +8 -4
  49. package/dist/rollout.d.ts +0 -72
  50. 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({ strict: true, onReadEntry(entry) {
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 => { input.destroy(); reject(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 => { error = (error + chunk).slice(-4000); });
30
- const finished = new Promise((resolve, reject) => { child.once('error', reject); child.once('close', code => code === 0 ? resolve() : reject(new Error(`docker save failed (${code}): ${error}`))); });
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([pipeline(child.stdout, createGzip(), createWriteStream(file, { mode: 0o600 })), finished]);
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('/') || name.split('/').includes('..') || !['File', 'Directory'].includes(entry.type) || names.has(name))
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', () => { blobHashes.set(name, hash.digest('hex')); if (json)
90
- blobMetadata.set(name, Buffer.concat(chunks)); });
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', () => { const value = Buffer.concat(chunks); if (name === 'manifest.json')
99
- manifestBytes = value;
100
- else if (name === 'index.json')
101
- indexBytes = value;
102
- else
103
- repositoriesBytes = value; });
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) || manifest.length !== 1 || JSON.stringify(manifest[0].RepoTags) !== JSON.stringify([expectedRef]))
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) || manifest[0].Layers.some((layer) => typeof layer !== 'string' || !regular.has(layer)))
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', () => { configBytes = Buffer.concat(chunks); });
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([expectedRef, `docker.io/${expectedRef}`, ...(!expectedRef.includes('/') ? [`docker.io/library/${expectedRef}`] : [])]);
135
- if (!Array.isArray(index.manifests) || index.manifests.length < 1 || !allowedNames.has(annotations['io.containerd.image.name']) || (annotations['org.opencontainers.image.ref.name'] && !allowedNames.has(annotations['org.opencontainers.image.ref.name']) && annotations['org.opencontainers.image.ref.name'] !== expectedRef.split(':').at(-1)))
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 && document.artifactType === 'application/vnd.docker.attestation.manifest.v1+json') || (imageConfig.os === 'unknown' && imageConfig.architecture === 'unknown');
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 ? readBlob(subject).config : undefined;
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' || !/^sha256:[a-f0-9]{64}$/.test(layer.digest) || blobHashes.get(name) !== layer.digest.slice(7))
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'] || desc.annotations?.['org.opencontainers.image.ref.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' && desc.platform?.os === 'unknown' && desc.platform?.architecture === 'unknown';
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 || desc.annotations?.['vnd.docker.reference.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 || annotationNames[0] !== 'io.containerd.manifest.subject' || !/^sha256:[a-f0-9]{64}$/.test(extra.digest))
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 || !Array.isArray(document.layers) || subject !== extra.annotations['io.containerd.manifest.subject'] || !isAttestationDocument(document, readBlob(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]) || JSON.stringify(Object.keys(repositories[repo])) !== JSON.stringify([tag]))
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 { imageId: `sha256:${createHash('sha256').update(configBytes).digest('hex')}`, platform: `${config.os}/${config.architecture}`, ...await hashArchive(file) };
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({ transform(chunk, _encoding, callback) { bytes += chunk.length; if (bytes > expected.sizeBytes)
237
- return callback(new Error('image download exceeds expected size')); hash.update(chunk); callback(null, chunk); }, flush(callback) { callback(bytes === expected.sizeBytes && hash.digest('hex') === expected.sha256 ? undefined : new Error('image download checksum or size mismatch')); } });
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 ? resolve({ out, err }) : reject(new Error(`${cmd} ${args.join(' ')} failed (${code})\n${out}\n${err}`)));
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 || entries > 100000 || path.isAbsolute(entry.path) || entry.path.split('/').includes('..') || !['File', 'Directory'].includes(entry.type))
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 ? path.resolve(contextDir, buildPath, buildSpec.dockerfile) : undefined;
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) && !fullContext.startsWith(`${path.resolve(contextDir)}${path.sep}`))
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.replaceAll('\\', '/').replace(/^(\.\/)+/, '').replace(/\/$/, '');
10
- if (relative.split('/').some(part => ['.git', '.tug.json'].includes(part)))
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.map(file => path.relative(contextDir, file).split(path.sep).join('/')).filter(file => file !== '..' && !file.startsWith('../') && !path.isAbsolute(file));
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')).split(/\r?\n/).map(line => line.trim().replace(/\/$/, '')).filter(line => line && !line.startsWith('#') && !line.startsWith('!')));
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({ gzip: true, file: archive, cwd: contextDir, filter: entry => sourceArchiveFilter(entry, configPaths, ignores) }, ['.']);
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 = { build: buildAndPush, save: saveImageArchive, nonce: randomUUID, remove: async (imageRef) => { await run('docker', ['image', 'rm', imageRef]); } }) {
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({ contextDir, service, buildSpec: spec.build, imageRef, push: false });
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
  }
@@ -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 { file, controlUrl: normalizeControlUrl(controlUrl), operatorToken: env.TUG_TOKEN || config.operatorToken, project: options.project || env.TUG_PROJECT || config.project || 'site' };
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) || url.username || url.password || url.search || url.hash)
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) || !Number.isSafeInteger(Number(value)) || Number(value) < minimum || Number(value) > maximum)
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
  }
@@ -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;
@@ -1,12 +1,46 @@
1
1
  import yaml from 'js-yaml';
2
- export const MESH_RUN_FLAGS = '--network host --cap-add NET_ADMIN';
3
- export function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'`; }
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 ` : ''}-v /var/run/docker.sock:/var/run/docker.sock -v tug-agent:/var/lib/tug -e TUG_JOIN_KEY=${shellQuote(joinKey)} ${shellQuote(image)} agent`;
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' + yaml.dump({ write_files: [{ path: '/opt/tug/install-docker.sh', permissions: '0700', content: bootstrap }], runcmd: [['sh', '-eu', '-c', `sh /opt/tug/install-docker.sh && ${command}`]] }, { lineWidth: -1, noRefs: true });
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) {
@@ -0,0 +1,3 @@
1
+ import type { CliLiveTransport } from './cli-live.ts';
2
+ /** Credentials travel only in the upgrade header, never a URL or frame. */
3
+ export declare function createCliLiveTransport(controlUrl: string, operatorToken?: string): CliLiveTransport;