@wpelevator/tug 0.0.0 → 0.2.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 +63 -0
- package/dist/agent.d.ts +40 -0
- package/dist/agent.js +547 -0
- package/dist/archive.d.ts +2 -0
- package/dist/archive.js +20 -0
- package/dist/artifacts.d.ts +23 -0
- package/dist/artifacts.js +238 -0
- package/dist/build.d.ts +22 -0
- package/dist/build.js +44 -0
- package/dist/cli-build.d.ts +15 -0
- package/dist/cli-build.js +50 -0
- package/dist/cli-config.d.ts +20 -0
- package/dist/cli-config.js +47 -0
- package/dist/cli-install.d.ts +7 -0
- package/dist/cli-install.js +31 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +422 -0
- package/dist/compose.d.ts +33 -0
- package/dist/compose.js +260 -0
- package/dist/control.d.ts +24 -0
- package/dist/control.js +1105 -0
- package/dist/db.d.ts +117 -0
- package/dist/db.js +51 -0
- package/dist/image.d.ts +9 -0
- package/dist/image.js +20 -0
- package/dist/ingress.d.ts +50 -0
- package/dist/ingress.js +180 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +22 -0
- package/dist/mesh.d.ts +70 -0
- package/dist/mesh.js +183 -0
- package/dist/protocol.d.ts +326 -0
- package/dist/protocol.js +8 -0
- package/dist/rollout.d.ts +72 -0
- package/dist/rollout.js +319 -0
- package/dist/runners.d.ts +12 -0
- package/dist/runners.js +10 -0
- package/dist/secrets.d.ts +3 -0
- package/dist/secrets.js +29 -0
- package/dist/templates/agent.env +6 -0
- package/dist/templates/control-compose.yml +18 -0
- package/dist/templates/control-install.sh +7 -0
- package/dist/templates/install-docker.sh +85 -0
- package/dist/templates/node-install.sh +8 -0
- package/dist/tokens.d.ts +91 -0
- package/dist/tokens.js +165 -0
- package/dist/util.d.ts +15 -0
- package/dist/util.js +35 -0
- package/package.json +62 -3
package/dist/archive.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import * as tar from 'tar';
|
|
3
|
+
export async function scanArchive(file, inspect) {
|
|
4
|
+
await new Promise((resolve, reject) => {
|
|
5
|
+
const input = createReadStream(file);
|
|
6
|
+
const parser = new tar.Parser({ strict: true, onReadEntry(entry) {
|
|
7
|
+
try {
|
|
8
|
+
inspect(entry);
|
|
9
|
+
entry.resume();
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
parser.abort(error);
|
|
13
|
+
}
|
|
14
|
+
} });
|
|
15
|
+
input.once('error', reject);
|
|
16
|
+
parser.once('error', error => { input.destroy(); reject(error); });
|
|
17
|
+
parser.once('end', resolve);
|
|
18
|
+
input.pipe(parser);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Transform } from 'node:stream';
|
|
2
|
+
import type { RunResult } from './build.ts';
|
|
3
|
+
export type ImageRunner = (cmd: string, args: string[]) => Promise<RunResult>;
|
|
4
|
+
export interface ImageInfo {
|
|
5
|
+
imageId: string;
|
|
6
|
+
platform: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ArchiveInfo extends ImageInfo {
|
|
9
|
+
sha256: string;
|
|
10
|
+
sizeBytes: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function inspectImage(imageRef: string, runner?: ImageRunner): Promise<ImageInfo>;
|
|
13
|
+
export declare function hashArchive(file: string): Promise<{
|
|
14
|
+
sha256: string;
|
|
15
|
+
sizeBytes: number;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function saveImageArchive(imageRef: string, file: string): Promise<ArchiveInfo>;
|
|
18
|
+
/** Validate Docker save metadata before Docker can import attacker-selected repository tags. */
|
|
19
|
+
export declare function validateImageArchive(file: string, expectedRef: string, maxExpandedBytes?: number, requireManagedReference?: boolean): Promise<ArchiveInfo>;
|
|
20
|
+
export declare function verifyDownload(expected: {
|
|
21
|
+
sha256: string;
|
|
22
|
+
sizeBytes: number;
|
|
23
|
+
}): Transform;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { createGzip } from 'node:zlib';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { Transform } from 'node:stream';
|
|
8
|
+
import { run } from "./build.js";
|
|
9
|
+
import { scanArchive } from "./archive.js";
|
|
10
|
+
export async function inspectImage(imageRef, runner = run) {
|
|
11
|
+
const images = JSON.parse((await runner('docker', ['image', 'inspect', imageRef])).out);
|
|
12
|
+
const image = images[0];
|
|
13
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(image?.Id) || !image.Os || !image.Architecture)
|
|
14
|
+
throw new Error(`invalid Docker image metadata for ${imageRef}`);
|
|
15
|
+
return { imageId: image.Id, platform: `${image.Os}/${image.Architecture}` };
|
|
16
|
+
}
|
|
17
|
+
export async function hashArchive(file) {
|
|
18
|
+
const hash = createHash('sha256');
|
|
19
|
+
let sizeBytes = 0;
|
|
20
|
+
for await (const chunk of createReadStream(file)) {
|
|
21
|
+
hash.update(chunk);
|
|
22
|
+
sizeBytes += chunk.length;
|
|
23
|
+
}
|
|
24
|
+
return { sha256: hash.digest('hex'), sizeBytes };
|
|
25
|
+
}
|
|
26
|
+
export async function saveImageArchive(imageRef, file) {
|
|
27
|
+
const child = spawn('docker', ['save', imageRef], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
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}`))); });
|
|
31
|
+
try {
|
|
32
|
+
await Promise.all([pipeline(child.stdout, createGzip(), createWriteStream(file, { mode: 0o600 })), finished]);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
child.kill();
|
|
36
|
+
await fs.rm(file, { force: true });
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
return validateImageArchive(file, imageRef, 20 * 1024 ** 3, false);
|
|
40
|
+
}
|
|
41
|
+
/** Validate Docker save metadata before Docker can import attacker-selected repository tags. */
|
|
42
|
+
export async function validateImageArchive(file, expectedRef, maxExpandedBytes = 20 * 1024 ** 3, requireManagedReference = true) {
|
|
43
|
+
if (requireManagedReference && !/^tug-build\/[a-z0-9._/-]+:[a-zA-Z0-9._-]+$/.test(expectedRef))
|
|
44
|
+
throw new Error('local image reference must be a unique tug-build/ reference');
|
|
45
|
+
const names = new Set();
|
|
46
|
+
const regular = new Set();
|
|
47
|
+
let expanded = 0;
|
|
48
|
+
let entries = 0;
|
|
49
|
+
let invalid;
|
|
50
|
+
const blobMetadata = new Map();
|
|
51
|
+
const blobHashes = new Map();
|
|
52
|
+
let metadataBytes = 0;
|
|
53
|
+
let manifestBytes;
|
|
54
|
+
let indexBytes;
|
|
55
|
+
let repositoriesBytes;
|
|
56
|
+
await scanArchive(file, entry => {
|
|
57
|
+
const name = entry.path.replace(/^\.\//, '');
|
|
58
|
+
if (++entries > 100000 || (expanded += entry.size) > maxExpandedBytes)
|
|
59
|
+
invalid = 'image archive exceeds expanded limits';
|
|
60
|
+
if (name.startsWith('/') || name.split('/').includes('..') || !['File', 'Directory'].includes(entry.type) || names.has(name))
|
|
61
|
+
invalid = 'unsafe image archive entry';
|
|
62
|
+
if (invalid)
|
|
63
|
+
throw new Error(invalid);
|
|
64
|
+
names.add(name);
|
|
65
|
+
if (entry.type === 'File')
|
|
66
|
+
regular.add(name);
|
|
67
|
+
if (/^blobs\/sha256\/[a-f0-9]{64}$/.test(name)) {
|
|
68
|
+
const hash = createHash('sha256');
|
|
69
|
+
const chunks = [];
|
|
70
|
+
let json = false;
|
|
71
|
+
let first = true;
|
|
72
|
+
entry.on('data', (chunk) => {
|
|
73
|
+
hash.update(chunk);
|
|
74
|
+
if (first) {
|
|
75
|
+
json = entry.size <= 4 * 1024 ** 2 && (chunk[0] === 123 || chunk[0] === 91);
|
|
76
|
+
first = false;
|
|
77
|
+
}
|
|
78
|
+
if (json) {
|
|
79
|
+
metadataBytes += chunk.length;
|
|
80
|
+
if (metadataBytes > 16 * 1024 ** 2) {
|
|
81
|
+
invalid = 'OCI metadata exceeds limit';
|
|
82
|
+
json = false;
|
|
83
|
+
chunks.length = 0;
|
|
84
|
+
}
|
|
85
|
+
else
|
|
86
|
+
chunks.push(Buffer.from(chunk));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
entry.on('end', () => { blobHashes.set(name, hash.digest('hex')); if (json)
|
|
90
|
+
blobMetadata.set(name, Buffer.concat(chunks)); });
|
|
91
|
+
}
|
|
92
|
+
if (['manifest.json', 'index.json', 'repositories'].includes(name) && !invalid) {
|
|
93
|
+
if (entry.size > 1024 ** 2) {
|
|
94
|
+
throw new Error('image manifest exceeds limit');
|
|
95
|
+
}
|
|
96
|
+
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; });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
if (invalid)
|
|
107
|
+
throw new Error(invalid);
|
|
108
|
+
const manifest = JSON.parse(manifestBytes?.toString() || 'null');
|
|
109
|
+
if (!Array.isArray(manifest) || manifest.length !== 1 || JSON.stringify(manifest[0].RepoTags) !== JSON.stringify([expectedRef]))
|
|
110
|
+
throw new Error('archive must contain exactly the declared Tug image tag');
|
|
111
|
+
if (typeof manifest[0].Config !== 'string' || !regular.has(manifest[0].Config))
|
|
112
|
+
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)))
|
|
114
|
+
throw new Error('image archive is missing layers');
|
|
115
|
+
let configBytes;
|
|
116
|
+
await scanArchive(file, entry => {
|
|
117
|
+
if (entry.path.replace(/^\.\//, '') !== manifest[0].Config)
|
|
118
|
+
return;
|
|
119
|
+
if (entry.size > 4 * 1024 ** 2) {
|
|
120
|
+
throw new Error('image configuration exceeds limit');
|
|
121
|
+
}
|
|
122
|
+
const chunks = [];
|
|
123
|
+
entry.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
|
124
|
+
entry.on('end', () => { configBytes = Buffer.concat(chunks); });
|
|
125
|
+
});
|
|
126
|
+
if (invalid)
|
|
127
|
+
throw new Error(invalid);
|
|
128
|
+
if (!configBytes)
|
|
129
|
+
throw new Error('image archive is missing its configuration');
|
|
130
|
+
if (indexBytes) {
|
|
131
|
+
const index = JSON.parse(indexBytes.toString());
|
|
132
|
+
const descriptor = index.manifests?.[0];
|
|
133
|
+
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)))
|
|
136
|
+
throw new Error('OCI index contains unexpected image names');
|
|
137
|
+
const expectedConfig = `sha256:${createHash('sha256').update(configBytes).digest('hex')}`;
|
|
138
|
+
const expectedLayers = manifest[0].Layers;
|
|
139
|
+
const visited = new Set();
|
|
140
|
+
let runnable = 0;
|
|
141
|
+
const readBlob = (digest) => {
|
|
142
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(digest))
|
|
143
|
+
throw new Error('invalid OCI content digest');
|
|
144
|
+
const name = `blobs/sha256/${digest.slice(7)}`;
|
|
145
|
+
const bytes = blobMetadata.get(name);
|
|
146
|
+
if (!bytes || blobHashes.get(name) !== digest.slice(7))
|
|
147
|
+
throw new Error('OCI metadata content digest mismatch or missing blob');
|
|
148
|
+
return JSON.parse(bytes.toString());
|
|
149
|
+
};
|
|
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');
|
|
151
|
+
/** An attestation is acceptable only when it describes this exact saved image and carries nothing but in-toto statements. */
|
|
152
|
+
const verifyAttestation = (document, subject) => {
|
|
153
|
+
const subjectConfig = subject ? readBlob(subject).config : undefined;
|
|
154
|
+
if (subjectConfig?.digest !== expectedConfig)
|
|
155
|
+
throw new Error('attestation subject does not match saved image');
|
|
156
|
+
for (const layer of document.layers) {
|
|
157
|
+
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))
|
|
159
|
+
throw new Error('invalid attestation layer');
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const walk = (desc) => {
|
|
163
|
+
if (visited.has(desc.digest))
|
|
164
|
+
return;
|
|
165
|
+
visited.add(desc.digest);
|
|
166
|
+
if (desc.annotations?.['io.containerd.image.name'] || desc.annotations?.['org.opencontainers.image.ref.name'])
|
|
167
|
+
throw new Error('nested OCI metadata contains unexpected image names');
|
|
168
|
+
const document = readBlob(desc.digest);
|
|
169
|
+
if (Array.isArray(document.manifests)) {
|
|
170
|
+
for (const child of document.manifests) {
|
|
171
|
+
if (typeof child.digest !== 'string')
|
|
172
|
+
throw new Error('invalid OCI descriptor');
|
|
173
|
+
if (regular.has(`blobs/sha256/${child.digest.slice(7)}`))
|
|
174
|
+
walk(child);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const configDescriptor = document.config;
|
|
179
|
+
if (!configDescriptor?.digest || !Array.isArray(document.layers))
|
|
180
|
+
throw new Error('invalid OCI image manifest');
|
|
181
|
+
const imageConfig = readBlob(configDescriptor.digest);
|
|
182
|
+
const attestation = desc.annotations?.['vnd.docker.reference.type'] === 'attestation-manifest' && desc.platform?.os === 'unknown' && desc.platform?.architecture === 'unknown';
|
|
183
|
+
if (attestation && isAttestationDocument(document, imageConfig)) {
|
|
184
|
+
verifyAttestation(document, document.subject?.digest || desc.annotations?.['vnd.docker.reference.digest']);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (configDescriptor.digest !== expectedConfig)
|
|
188
|
+
throw new Error('OCI graph configuration does not match saved image');
|
|
189
|
+
const layerPaths = document.layers.map(layer => {
|
|
190
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(layer.digest))
|
|
191
|
+
throw new Error('invalid OCI layer digest');
|
|
192
|
+
const name = `blobs/sha256/${layer.digest.slice(7)}`;
|
|
193
|
+
if (!regular.has(name) || blobHashes.get(name) !== layer.digest.slice(7))
|
|
194
|
+
throw new Error('OCI layer content digest mismatch');
|
|
195
|
+
return name;
|
|
196
|
+
});
|
|
197
|
+
if (JSON.stringify(layerPaths) !== JSON.stringify(expectedLayers))
|
|
198
|
+
throw new Error('OCI graph layers do not match saved image');
|
|
199
|
+
runnable++;
|
|
200
|
+
};
|
|
201
|
+
walk({ ...descriptor, annotations: {} });
|
|
202
|
+
if (runnable !== 1)
|
|
203
|
+
throw new Error('OCI archive must contain exactly one runnable saved image');
|
|
204
|
+
// On a containerd image store, `docker save` also lists build attestations that refer to the image as extra
|
|
205
|
+
// top-level entries. They carry no image name, so they cannot create tags; accept them only when they are
|
|
206
|
+
// genuine attestations of this exact image and reject any other extra manifest.
|
|
207
|
+
for (const extra of index.manifests.slice(1)) {
|
|
208
|
+
const annotationNames = Object.keys(extra.annotations || {});
|
|
209
|
+
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))
|
|
211
|
+
throw unexpected();
|
|
212
|
+
const document = readBlob(extra.digest);
|
|
213
|
+
const configDescriptor = document.config;
|
|
214
|
+
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)))
|
|
216
|
+
throw unexpected();
|
|
217
|
+
verifyAttestation(document, subject);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (repositoriesBytes) {
|
|
221
|
+
const repositories = JSON.parse(repositoriesBytes.toString());
|
|
222
|
+
const colon = expectedRef.lastIndexOf(':');
|
|
223
|
+
const repo = expectedRef.slice(0, colon);
|
|
224
|
+
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]))
|
|
226
|
+
throw new Error('archive repositories contain unexpected image tags');
|
|
227
|
+
}
|
|
228
|
+
const config = JSON.parse(configBytes.toString());
|
|
229
|
+
if (config.os !== 'linux' || !['amd64', 'arm64'].includes(config.architecture))
|
|
230
|
+
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) };
|
|
232
|
+
}
|
|
233
|
+
export function verifyDownload(expected) {
|
|
234
|
+
const hash = createHash('sha256');
|
|
235
|
+
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')); } });
|
|
238
|
+
}
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface RunResult {
|
|
2
|
+
out: string;
|
|
3
|
+
err: string;
|
|
4
|
+
}
|
|
5
|
+
export interface RunOptions {
|
|
6
|
+
cwd?: string;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
}
|
|
9
|
+
export interface BuildSpec {
|
|
10
|
+
context?: string;
|
|
11
|
+
dockerfile?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function run(cmd: string, args: string[], opts?: RunOptions): Promise<RunResult>;
|
|
14
|
+
export declare function unpackContext(tarball: string, dest: string): Promise<void>;
|
|
15
|
+
export interface BuildAndPushOptions {
|
|
16
|
+
contextDir: string;
|
|
17
|
+
service: string;
|
|
18
|
+
buildSpec: string | BuildSpec;
|
|
19
|
+
imageRef: string;
|
|
20
|
+
push?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function buildAndPush({ contextDir, service, buildSpec, imageRef, push }: BuildAndPushOptions): Promise<void>;
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import * as tar from 'tar';
|
|
5
|
+
import { scanArchive } from "./archive.js";
|
|
6
|
+
import { ensureDir, exists } from "./util.js";
|
|
7
|
+
export function run(cmd, args, opts = {}) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const p = spawn(cmd, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
10
|
+
let out = '', err = '';
|
|
11
|
+
p.stdout.on('data', d => out += d);
|
|
12
|
+
p.stderr.on('data', d => err += d);
|
|
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}`)));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export async function unpackContext(tarball, dest) {
|
|
18
|
+
await fs.rm(dest, { recursive: true, force: true });
|
|
19
|
+
await ensureDir(dest);
|
|
20
|
+
let expanded = 0;
|
|
21
|
+
let entries = 0;
|
|
22
|
+
await scanArchive(tarball, entry => {
|
|
23
|
+
expanded += entry.size;
|
|
24
|
+
entries++;
|
|
25
|
+
if (expanded > 20 * 1024 ** 3 || entries > 100000 || path.isAbsolute(entry.path) || entry.path.split('/').includes('..') || !['File', 'Directory'].includes(entry.type))
|
|
26
|
+
throw new Error('source context has unsafe entries or exceeds expanded limits');
|
|
27
|
+
});
|
|
28
|
+
await tar.x({ file: tarball, cwd: dest, strict: true });
|
|
29
|
+
}
|
|
30
|
+
export async function buildAndPush({ contextDir, service, buildSpec, imageRef, push = true }) {
|
|
31
|
+
const buildPath = typeof buildSpec === 'string' ? buildSpec : buildSpec?.context || '.';
|
|
32
|
+
const dockerfilePath = typeof buildSpec === 'object' && buildSpec.dockerfile ? path.resolve(contextDir, buildPath, buildSpec.dockerfile) : undefined;
|
|
33
|
+
if (dockerfilePath && !dockerfilePath.startsWith(`${path.resolve(contextDir)}${path.sep}`))
|
|
34
|
+
throw new Error(`Dockerfile escapes uploaded root for ${service}`);
|
|
35
|
+
const dockerfile = dockerfilePath ? ['-f', dockerfilePath] : [];
|
|
36
|
+
const fullContext = path.resolve(contextDir, buildPath);
|
|
37
|
+
if (fullContext !== path.resolve(contextDir) && !fullContext.startsWith(`${path.resolve(contextDir)}${path.sep}`))
|
|
38
|
+
throw new Error(`build context escapes uploaded root for ${service}`);
|
|
39
|
+
if (!(await exists(fullContext)))
|
|
40
|
+
throw new Error(`build context not found for ${service}: ${buildPath}`);
|
|
41
|
+
await run('docker', ['build', ...dockerfile, '-t', imageRef, fullContext]);
|
|
42
|
+
if (push)
|
|
43
|
+
await run('docker', ['push', imageRef]);
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { buildAndPush } from './build.ts';
|
|
2
|
+
import { saveImageArchive } from './artifacts.ts';
|
|
3
|
+
import type { ComposeFile, LocalImageUpload } from './protocol.ts';
|
|
4
|
+
export interface LocalBuildDependencies {
|
|
5
|
+
build: typeof buildAndPush;
|
|
6
|
+
save: typeof saveImageArchive;
|
|
7
|
+
nonce: () => string;
|
|
8
|
+
remove: (imageRef: string) => Promise<void>;
|
|
9
|
+
cleanupWarning?: (message: string) => void;
|
|
10
|
+
}
|
|
11
|
+
/** Credential exclusions are mandatory and cannot be re-included by source ignore rules. */
|
|
12
|
+
export declare function sourceArchiveFilter(entry: string, configPaths: string[], ignores: string[]): boolean;
|
|
13
|
+
export declare function createSourceArchive(contextDir: string, archive: string, configFiles: string[]): Promise<void>;
|
|
14
|
+
/** Build only application build services; callers own temporary files and upload transport. */
|
|
15
|
+
export declare function prepareLocalImages(compose: ComposeFile, contextDir: string, directory: string, dependencies?: LocalBuildDependencies): Promise<LocalImageUpload[]>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import * as tar from 'tar';
|
|
5
|
+
import { buildAndPush, run } from "./build.js";
|
|
6
|
+
import { saveImageArchive } from "./artifacts.js";
|
|
7
|
+
/** Credential exclusions are mandatory and cannot be re-included by source ignore rules. */
|
|
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)))
|
|
11
|
+
return false;
|
|
12
|
+
return ![...configPaths, ...ignores].some(ignore => relative === ignore || relative.startsWith(`${ignore}/`));
|
|
13
|
+
}
|
|
14
|
+
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));
|
|
16
|
+
const ignores = ['node_modules', 'data', 'registry-data'];
|
|
17
|
+
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('!')));
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code !== 'ENOENT')
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
await tar.c({ gzip: true, file: archive, cwd: contextDir, filter: entry => sourceArchiveFilter(entry, configPaths, ignores) }, ['.']);
|
|
25
|
+
}
|
|
26
|
+
/** 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]); } }) {
|
|
28
|
+
const images = [];
|
|
29
|
+
const nonce = dependencies.nonce();
|
|
30
|
+
for (const [service, spec] of Object.entries(compose.services)) {
|
|
31
|
+
if (!spec.build)
|
|
32
|
+
continue;
|
|
33
|
+
const field = `image-${images.length}`;
|
|
34
|
+
const imageRef = `tug-build/${nonce}/${field}:local`;
|
|
35
|
+
try {
|
|
36
|
+
await dependencies.build({ contextDir, service, buildSpec: spec.build, imageRef, push: false });
|
|
37
|
+
const archive = await dependencies.save(imageRef, path.join(directory, `${field}.tar.gz`));
|
|
38
|
+
images.push({ service, imageRef, ...archive, field });
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
try {
|
|
42
|
+
await dependencies.remove(imageRef);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
(dependencies.cleanupWarning || console.warn)(`Could not remove temporary application image ${imageRef}: ${error.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return images;
|
|
50
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface CliConfig {
|
|
2
|
+
controlUrl?: string;
|
|
3
|
+
operatorToken?: string;
|
|
4
|
+
project?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ConnectionOptions {
|
|
7
|
+
config?: string;
|
|
8
|
+
controlUrl?: string;
|
|
9
|
+
project?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ResolvedCliConfig {
|
|
12
|
+
file: string;
|
|
13
|
+
controlUrl: string;
|
|
14
|
+
operatorToken?: string;
|
|
15
|
+
project: string;
|
|
16
|
+
}
|
|
17
|
+
/** Resolve adapter settings once; domain operations receive these explicit values. */
|
|
18
|
+
export declare function resolveCliConfig(options: ConnectionOptions, env: NodeJS.ProcessEnv, cwd: string, creating?: boolean): Promise<ResolvedCliConfig>;
|
|
19
|
+
export declare function normalizeControlUrl(value: string): string;
|
|
20
|
+
export declare function integerOption(value: string, name: string, minimum?: number, maximum?: number): number;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Resolve adapter settings once; domain operations receive these explicit values. */
|
|
4
|
+
export async function resolveCliConfig(options, env, cwd, creating = false) {
|
|
5
|
+
for (const key of ['config', 'controlUrl', 'project'])
|
|
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`);
|
|
8
|
+
const file = path.resolve(cwd, options.config || env.TUG_CONFIG || '.tug.json');
|
|
9
|
+
let config = {};
|
|
10
|
+
try {
|
|
11
|
+
const input = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
12
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
13
|
+
throw new Error('expected a JSON object');
|
|
14
|
+
for (const key of ['controlUrl', 'operatorToken', 'project']) {
|
|
15
|
+
const value = input[key];
|
|
16
|
+
if (value !== undefined && (typeof value !== 'string' || !value.trim()))
|
|
17
|
+
throw new Error(`${key} must be a nonempty string`);
|
|
18
|
+
}
|
|
19
|
+
config = input;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
const missing = error.code === 'ENOENT';
|
|
23
|
+
if (!missing || (!creating && Boolean(options.config || env.TUG_CONFIG)))
|
|
24
|
+
throw new Error(`Cannot read config ${file}: ${error.message}`, { cause: error });
|
|
25
|
+
}
|
|
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' };
|
|
28
|
+
}
|
|
29
|
+
export function normalizeControlUrl(value) {
|
|
30
|
+
if (!value.trim() || /[\r\n\0]/.test(value))
|
|
31
|
+
throw new Error('control URL must be a nonempty single-line URL');
|
|
32
|
+
let url;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(value);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error('control URL must be an absolute http:// or https:// URL');
|
|
38
|
+
}
|
|
39
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
40
|
+
throw new Error('control URL must use http:// or https:// without credentials, query or fragment');
|
|
41
|
+
return value.replace(/\/+$/, '');
|
|
42
|
+
}
|
|
43
|
+
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)
|
|
45
|
+
throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`);
|
|
46
|
+
return Number(value);
|
|
47
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const MESH_RUN_FLAGS = "--network host --cap-add NET_ADMIN";
|
|
2
|
+
export declare function shellQuote(value: unknown): string;
|
|
3
|
+
export declare function dockerRunSnippet(image: string, joinKey: string, mesh?: boolean): string;
|
|
4
|
+
/** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
|
|
5
|
+
export declare function enrollmentCloudInit(bootstrap: string, command: string): string;
|
|
6
|
+
/** An occupied mesh port is reusable only when the installed Tug agent owns the live key. */
|
|
7
|
+
export declare function meshPortCheck(port: number): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
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, `'\\''`)}'`; }
|
|
4
|
+
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`;
|
|
6
|
+
}
|
|
7
|
+
/** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
|
|
8
|
+
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 });
|
|
10
|
+
}
|
|
11
|
+
/** An occupied mesh port is reusable only when the installed Tug agent owns the live key. */
|
|
12
|
+
export function meshPortCheck(port) {
|
|
13
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
14
|
+
throw new Error('mesh port must be an integer from 1 to 65535');
|
|
15
|
+
return `command -v ss >/dev/null 2>&1 || exit 1
|
|
16
|
+
listeners=$(ss -lun) || exit 1
|
|
17
|
+
if ! printf '%s\\n' "$listeners" | awk '{print $4}' | grep -qE '[.:]${port}$'; then exit 0; fi
|
|
18
|
+
[ "$(docker inspect --format '{{.HostConfig.NetworkMode}}' tug-agent 2>/dev/null)" = host ] || exit 1
|
|
19
|
+
[ "$(docker inspect --format '{{.State.Running}}' tug-agent 2>/dev/null)" = true ] || exit 1
|
|
20
|
+
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' tug-agent 2>/dev/null | grep -qx 'TUG_MESH=true' || exit 1
|
|
21
|
+
[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/var/lib/tug"}}{{.Source}}{{end}}{{end}}' tug-agent 2>/dev/null)" = /var/lib/tug ] || exit 1
|
|
22
|
+
docker exec tug-agent sh -eu -c ${shellQuote(`
|
|
23
|
+
test -s /var/lib/tug/wireguard/private.key
|
|
24
|
+
test -s /var/lib/tug/wireguard/reported.pub
|
|
25
|
+
test "$(wg show wg0 listen-port)" = "${port}"
|
|
26
|
+
live_key=$(wg show wg0 public-key)
|
|
27
|
+
test -n "$live_key"
|
|
28
|
+
test "$live_key" = "$(wg pubkey < /var/lib/tug/wireguard/private.key)"
|
|
29
|
+
test "$live_key" = "$(cat /var/lib/tug/wireguard/reported.pub)"`)}
|
|
30
|
+
`;
|
|
31
|
+
}
|
package/dist/cli.d.ts
ADDED