k3s-deployer 0.1.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.
@@ -0,0 +1,386 @@
1
+ const path = require('node:path');
2
+ const { createGeneratedDockerfile } = require('../templates/dockerfile.cjs');
3
+ const { ensureCluster } = require('../bootstrap/index.cjs');
4
+ const { upsertCloudflareDnsRecord } = require('../dns/cloudflare.cjs');
5
+ const { buildMobileUnit, startMobilePreview } = require('../mobile/index.cjs');
6
+ const { createDeploymentPlan } = require('../plan/index.cjs');
7
+ const { createRunner } = require('../runtime/index.cjs');
8
+ const { materializeLocalSource, materializeRemoteGitSource } = require('../shared/source.cjs');
9
+ const { deriveHostAddress, ensureDir, shellQuote, slugify } = require('../shared/utils.cjs');
10
+
11
+ function joinCommand(command) {
12
+ return Array.isArray(command) ? command.join(' ') : String(command || '');
13
+ }
14
+
15
+ function normalizeSupplementalFilePath(filePath) {
16
+ const normalized = path.posix
17
+ .normalize(String(filePath || '').replace(/\\/g, '/').trim())
18
+ .replace(/^\.\/+/, '');
19
+
20
+ if (
21
+ !normalized ||
22
+ normalized === '.' ||
23
+ normalized.includes('\n') ||
24
+ normalized.includes('\0') ||
25
+ normalized.startsWith('../') ||
26
+ path.posix.isAbsolute(normalized)
27
+ ) {
28
+ throw new Error(`Invalid supplemental file path: ${filePath}`);
29
+ }
30
+
31
+ return normalized;
32
+ }
33
+
34
+ function resolveCloudflareDnsOptions(config = {}, options = {}) {
35
+ const base = config.cloudflareDns || {};
36
+ const override = options.cloudflareDns || {};
37
+
38
+ return {
39
+ apiToken: override.apiToken || base.apiToken || undefined,
40
+ apiKey: override.apiKey || base.apiKey || undefined,
41
+ email: override.email || base.email || undefined,
42
+ zoneId: override.zoneId || base.zoneId || undefined,
43
+ targetIp: override.targetIp || base.targetIp || undefined,
44
+ proxied:
45
+ typeof override.proxied === 'boolean'
46
+ ? override.proxied
47
+ : typeof base.proxied === 'boolean'
48
+ ? base.proxied
49
+ : false,
50
+ };
51
+ }
52
+
53
+ async function writeManifestFiles(runner, manifestRoot, manifests) {
54
+ let index = 0;
55
+ for (const manifest of manifests) {
56
+ const filePath = path.posix.join(manifestRoot, `${String(index).padStart(2, '0')}.yaml`);
57
+ await runner.writeFile(filePath, manifest);
58
+ index += 1;
59
+ }
60
+ }
61
+
62
+ async function applySupplementalFiles(
63
+ runner,
64
+ workspacePath,
65
+ supplementalFiles,
66
+ options = {},
67
+ ) {
68
+ if (!Array.isArray(supplementalFiles) || supplementalFiles.length === 0) {
69
+ return;
70
+ }
71
+
72
+ for (const file of supplementalFiles) {
73
+ const normalizedPath = normalizeSupplementalFilePath(file.path);
74
+ const targetPath = path.posix.join(workspacePath, normalizedPath);
75
+
76
+ if (options.dryRun) {
77
+ if (options.onLog) {
78
+ await options.onLog(`Would inject supplemental file ${normalizedPath}`);
79
+ }
80
+ continue;
81
+ }
82
+
83
+ if (typeof runner.writeBase64File === 'function') {
84
+ await runner.writeBase64File(targetPath, file.contentBase64);
85
+ } else {
86
+ await runner.writeFile(
87
+ targetPath,
88
+ Buffer.from(file.contentBase64, 'base64').toString('utf8'),
89
+ );
90
+ }
91
+
92
+ if (options.onLog) {
93
+ await options.onLog(`Injected supplemental file ${normalizedPath}`);
94
+ }
95
+ }
96
+ }
97
+
98
+ async function prepareWorkspace(source, target, runner, config, workspaceName, onLog) {
99
+ const workspaceRoot =
100
+ (target && target.workspaceRoot) || config.workspaceRoot || (target && target.kind === 'ssh' ? '/tmp/k3s-deployer' : path.join(require('node:os').tmpdir(), 'k3s-deployer'));
101
+ if (target && target.kind === 'ssh') {
102
+ return materializeRemoteGitSource(source, runner, workspaceRoot, workspaceName, {
103
+ onLog,
104
+ });
105
+ }
106
+ await ensureDir(workspaceRoot);
107
+ return materializeLocalSource(source, workspaceRoot, workspaceName);
108
+ }
109
+
110
+ async function buildUnitImage(plan, unit, workspacePath, runner, options = {}) {
111
+ const unitPath = unit.root === '.' ? workspacePath : path.posix.join(workspacePath, unit.root);
112
+ const dockerfilePath = path.posix.join(unitPath, 'Dockerfile');
113
+ const generatedDockerfilePath = path.posix.join(unitPath, 'Dockerfile.k3s-deployer');
114
+ const existingDockerfile = await runner.exists(dockerfilePath);
115
+ if (!existingDockerfile) {
116
+ await runner.writeFile(generatedDockerfilePath, createGeneratedDockerfile(unit));
117
+ }
118
+
119
+ const envFileEntries = unit.role === 'backend' ? plan.env.backend : plan.env.frontendPublic;
120
+ if (Object.keys(envFileEntries).length > 0) {
121
+ const envText = Object.entries(envFileEntries)
122
+ .map(([key, value]) => `${key}=${String(value).replace(/\n/g, '\\n')}`)
123
+ .join('\n');
124
+ await runner.writeFile(path.posix.join(unitPath, '.env.k3s-deployer'), envText);
125
+ }
126
+
127
+ const image = plan.resolved.units.find((resolvedUnit) => resolvedUnit.id === unit.id)?.image;
128
+ const buildArgs =
129
+ unit.role === 'frontend'
130
+ ? Object.entries(plan.env.frontendPublic)
131
+ .map(([key, value]) => `--build-arg ${shellQuote(`${key}=${value}`)}`)
132
+ .join(' ')
133
+ : '';
134
+ const imageArchivePath = path.posix.join(
135
+ unitPath,
136
+ `.k3s-deployer-${unit.id}-image.tar`,
137
+ );
138
+ const importCommand =
139
+ options.target && options.target.kind === 'ssh'
140
+ ? `sudo k3s ctr -n k8s.io images import ${shellQuote(imageArchivePath)} >/dev/null 2>&1 || sudo ctr -n k8s.io images import ${shellQuote(imageArchivePath)} >/dev/null 2>&1 || k3s ctr -n k8s.io images import ${shellQuote(imageArchivePath)} >/dev/null 2>&1 || ctr -n k8s.io images import ${shellQuote(imageArchivePath)} >/dev/null 2>&1 || true`
141
+ : `k3s ctr -n k8s.io images import ${shellQuote(imageArchivePath)} >/dev/null 2>&1 || ctr -n k8s.io images import ${shellQuote(imageArchivePath)} >/dev/null 2>&1 || sudo -n k3s ctr -n k8s.io images import ${shellQuote(imageArchivePath)} >/dev/null 2>&1 || sudo -n ctr -n k8s.io images import ${shellQuote(imageArchivePath)} >/dev/null 2>&1 || true`;
142
+ const verifyImportCommand =
143
+ options.target && options.target.kind === 'ssh'
144
+ ? `sudo k3s ctr -n k8s.io images list | grep -F ${shellQuote(image)} >/dev/null 2>&1 || sudo ctr -n k8s.io images list | grep -F ${shellQuote(image)} >/dev/null 2>&1 || k3s ctr -n k8s.io images list | grep -F ${shellQuote(image)} >/dev/null 2>&1 || ctr -n k8s.io images list | grep -F ${shellQuote(image)} >/dev/null 2>&1`
145
+ : `k3s ctr -n k8s.io images list | grep -F ${shellQuote(image)} >/dev/null 2>&1 || ctr -n k8s.io images list | grep -F ${shellQuote(image)} >/dev/null 2>&1 || sudo -n k3s ctr -n k8s.io images list | grep -F ${shellQuote(image)} >/dev/null 2>&1 || sudo -n ctr -n k8s.io images list | grep -F ${shellQuote(image)} >/dev/null 2>&1`;
146
+
147
+ await runner.run(`docker build --provenance=false --sbom=false -t ${shellQuote(image)} ${buildArgs} -f ${shellQuote(existingDockerfile ? dockerfilePath : generatedDockerfilePath)} ${shellQuote(unitPath)}`, {
148
+ dryRun: options.dryRun,
149
+ onStdout: options.onLog,
150
+ onStderr: options.onLog,
151
+ });
152
+ await runner.run(`docker push ${shellQuote(image)}`, {
153
+ dryRun: options.dryRun,
154
+ onStdout: options.onLog,
155
+ onStderr: options.onLog,
156
+ });
157
+ await runner.run(`docker save -o ${shellQuote(imageArchivePath)} ${shellQuote(image)}`, {
158
+ dryRun: options.dryRun,
159
+ onStdout: options.onLog,
160
+ onStderr: options.onLog,
161
+ });
162
+ await runner.run(importCommand, {
163
+ dryRun: options.dryRun,
164
+ allowFailure: true,
165
+ onStdout: options.onLog,
166
+ onStderr: options.onLog,
167
+ });
168
+ const importVerification = await runner.run(verifyImportCommand, {
169
+ dryRun: options.dryRun,
170
+ allowFailure: true,
171
+ });
172
+ if (options.onLog && !options.dryRun) {
173
+ await options.onLog(
174
+ importVerification.code === 0
175
+ ? `Imported ${image} into k3s runtime`
176
+ : `Could not verify ${image} in k3s runtime; kubelet will use the registry path`,
177
+ );
178
+ }
179
+ await runner.run(`rm -f ${shellQuote(imageArchivePath)}`, {
180
+ dryRun: options.dryRun,
181
+ allowFailure: true,
182
+ });
183
+ }
184
+
185
+ function resolveUnitUrls(plan, target, overrides = {}) {
186
+ const host = deriveHostAddress(target);
187
+ const urls = {};
188
+ for (const unit of plan.resolved.units) {
189
+ if (!unit.public) {
190
+ urls[unit.id] = `http://${unit.name}.${plan.cluster.namespace}.svc.cluster.local:${unit.port || 3000}`;
191
+ continue;
192
+ }
193
+ urls[unit.id] = overrides.domain ? `https://${overrides.domain}` : `http://${host}:${unit.nodePort}`;
194
+ }
195
+ return urls;
196
+ }
197
+
198
+ async function syncCloudflareDns(plan, options, config, onLog) {
199
+ const domain = String(options.overrides?.domain || '').trim().toLowerCase();
200
+ if (!domain) {
201
+ return null;
202
+ }
203
+
204
+ const publicUnit = (plan.resolved?.units || []).find((unit) => unit.public);
205
+ if (!publicUnit) {
206
+ return null;
207
+ }
208
+
209
+ const dnsOptions = resolveCloudflareDnsOptions(config, options);
210
+ if (!dnsOptions.targetIp) {
211
+ await onLog(`Skipping Cloudflare DNS for ${domain}: target IP is not configured`);
212
+ return null;
213
+ }
214
+
215
+ if (!dnsOptions.apiToken && !(dnsOptions.apiKey && dnsOptions.email)) {
216
+ await onLog(`Skipping Cloudflare DNS for ${domain}: Cloudflare credentials are not configured`);
217
+ return null;
218
+ }
219
+
220
+ await onLog(
221
+ `${options.dryRun ? 'Would sync' : 'Syncing'} Cloudflare DNS for ${domain} -> ${dnsOptions.targetIp}`,
222
+ );
223
+
224
+ if (options.dryRun) {
225
+ return {
226
+ provider: 'cloudflare',
227
+ type: 'A',
228
+ domain,
229
+ targetIp: dnsOptions.targetIp,
230
+ proxied: Boolean(dnsOptions.proxied),
231
+ };
232
+ }
233
+
234
+ try {
235
+ const dnsRecord = await upsertCloudflareDnsRecord({
236
+ ...dnsOptions,
237
+ domain,
238
+ });
239
+ await onLog(`Cloudflare DNS synced for ${domain} -> ${dnsRecord.targetIp}`);
240
+ return dnsRecord;
241
+ } catch (error) {
242
+ const message =
243
+ error instanceof Error ? error.message : 'Cloudflare DNS sync failed';
244
+ await onLog(`Cloudflare DNS sync failed for ${domain}: ${message}`);
245
+ return null;
246
+ }
247
+ }
248
+
249
+ async function deploy(sourceOrPlan, options = {}, config = {}) {
250
+ const target = options.target || config.defaultTarget || { kind: 'local' };
251
+ const runner = await createRunner(target, config);
252
+ const source = sourceOrPlan && sourceOrPlan.inspection ? options.source : sourceOrPlan;
253
+ const plan =
254
+ sourceOrPlan && sourceOrPlan.inspection
255
+ ? sourceOrPlan
256
+ : await config.inspectAndPlan(source, options.overrides || {});
257
+
258
+ const releaseId = slugify(`${plan.inspection.projectSlug}-${Date.now().toString(36)}`);
259
+ const workspaceName = options.workspaceName || releaseId;
260
+ const logs = [];
261
+ const onLog = async (chunk) => {
262
+ if (!chunk) {
263
+ return;
264
+ }
265
+ const lines = String(chunk)
266
+ .split('\n')
267
+ .filter(Boolean);
268
+ for (const line of lines) {
269
+ logs.push(line);
270
+ if (options.onLog) {
271
+ await options.onLog(line);
272
+ }
273
+ }
274
+ };
275
+
276
+ await onLog(`Preparing deployment on ${target && target.kind === 'ssh' ? 'ssh' : 'local'} target`);
277
+ await ensureCluster(target, config, {
278
+ runner,
279
+ namespace: plan.cluster.namespace,
280
+ dryRun: options.dryRun,
281
+ onLog,
282
+ });
283
+
284
+ if (!source) {
285
+ throw new Error('A repository source is required for deployment');
286
+ }
287
+
288
+ await onLog(`Syncing repository workspace into ${workspaceName}`);
289
+ const workspacePath = await prepareWorkspace(
290
+ source,
291
+ target,
292
+ runner,
293
+ config,
294
+ workspaceName,
295
+ onLog,
296
+ );
297
+ await onLog(`Prepared workspace at ${workspacePath}`);
298
+ await applySupplementalFiles(
299
+ runner,
300
+ workspacePath,
301
+ options.supplementalFiles,
302
+ {
303
+ dryRun: options.dryRun,
304
+ onLog,
305
+ },
306
+ );
307
+
308
+ for (const unit of plan.inspection.units.filter((item) => item.role !== 'mobile')) {
309
+ await onLog(`Building image for ${unit.id}`);
310
+ await buildUnitImage(plan, unit, workspacePath, runner, {
311
+ dryRun: options.dryRun,
312
+ onLog,
313
+ target,
314
+ });
315
+ }
316
+
317
+ const manifestRoot = path.posix.join(workspacePath, '.k3s-deployer', 'manifests');
318
+ await onLog(`Writing manifests to ${manifestRoot}`);
319
+ await runner.run(`mkdir -p ${shellQuote(path.posix.join(manifestRoot, 'data'))} ${shellQuote(path.posix.join(manifestRoot, 'apps'))}`, {
320
+ dryRun: options.dryRun,
321
+ });
322
+ await writeManifestFiles(runner, path.posix.join(manifestRoot, 'data'), plan.manifests.data);
323
+ await writeManifestFiles(runner, path.posix.join(manifestRoot, 'apps'), plan.manifests.apps);
324
+ if (plan.manifests.data.length > 0) {
325
+ await runner.run(`kubectl apply -f ${shellQuote(path.posix.join(manifestRoot, 'data'))}`, {
326
+ dryRun: options.dryRun,
327
+ onStdout: onLog,
328
+ onStderr: onLog,
329
+ });
330
+ }
331
+ if (plan.manifests.apps.length > 0) {
332
+ await runner.run(`kubectl apply -f ${shellQuote(path.posix.join(manifestRoot, 'apps'))}`, {
333
+ dryRun: options.dryRun,
334
+ onStdout: onLog,
335
+ onStderr: onLog,
336
+ });
337
+ }
338
+
339
+ for (const unit of plan.resolved.units) {
340
+ await runner.run(`kubectl rollout restart deployment/${shellQuote(unit.name)} -n ${shellQuote(plan.cluster.namespace)}`, {
341
+ dryRun: options.dryRun,
342
+ allowFailure: true,
343
+ onStdout: onLog,
344
+ onStderr: onLog,
345
+ });
346
+ await runner.run(`kubectl rollout status deployment/${shellQuote(unit.name)} -n ${shellQuote(plan.cluster.namespace)} --timeout=300s`, {
347
+ dryRun: options.dryRun,
348
+ onStdout: onLog,
349
+ onStderr: onLog,
350
+ });
351
+ }
352
+
353
+ let mobilePreviewUrl;
354
+ let mobileArtifactPath;
355
+ let mobileApplicationId;
356
+ if (plan.mobile) {
357
+ const mobileUnit = plan.inspection.units.find((item) => item.role === 'mobile');
358
+ const artifactPath = await buildMobileUnit(plan, mobileUnit, runner, workspacePath, {
359
+ dryRun: options.dryRun,
360
+ onLog,
361
+ timeoutMs: config.mobileBuildTimeoutMs,
362
+ });
363
+ mobileArtifactPath = artifactPath;
364
+ mobileApplicationId = plan.mobile.applicationId;
365
+ if (config.mobilePreviewAdapter) {
366
+ const preview = await startMobilePreview(plan, config, { artifactPath, runner, onLog, workspacePath, overrides: options.overrides, cloudflareDns: resolveCloudflareDnsOptions(config, options) });
367
+ mobilePreviewUrl = preview.url;
368
+ }
369
+ }
370
+
371
+ const dnsRecord = await syncCloudflareDns(plan, options, config, onLog);
372
+
373
+ return {
374
+ releaseId,
375
+ unitUrls: resolveUnitUrls(plan, target, options.overrides || {}),
376
+ mobilePreviewUrl,
377
+ mobileArtifactPath,
378
+ mobileApplicationId,
379
+ dnsRecord,
380
+ logs,
381
+ };
382
+ }
383
+
384
+ module.exports = {
385
+ deploy,
386
+ };
package/src/index.cjs ADDED
@@ -0,0 +1,67 @@
1
+ const path = require('node:path');
2
+ const os = require('node:os');
3
+ const { inspectRepository } = require('./detect/index.cjs');
4
+ const { createDeploymentPlan } = require('./plan/index.cjs');
5
+ const { ensureCluster } = require('./bootstrap/index.cjs');
6
+ const { deploy } = require('./execute/index.cjs');
7
+ const { startMobilePreview } = require('./mobile/index.cjs');
8
+ const { createWebAndroidEmulatorAdapter } = require('./mobile/web-android-emulator-adapter.cjs');
9
+
10
+ function normalizeCloudflareDns(config = {}) {
11
+ if (!config || typeof config !== 'object') {
12
+ return undefined;
13
+ }
14
+
15
+ const normalized = {
16
+ apiToken: config.apiToken || undefined,
17
+ apiKey: config.apiKey || undefined,
18
+ email: config.email || undefined,
19
+ zoneId: config.zoneId || undefined,
20
+ targetIp: config.targetIp || undefined,
21
+ proxied:
22
+ typeof config.proxied === 'boolean' ? config.proxied : undefined,
23
+ };
24
+
25
+ return Object.values(normalized).some((value) => value !== undefined)
26
+ ? normalized
27
+ : undefined;
28
+ }
29
+
30
+ function normalizeConfig(config = {}) {
31
+ return {
32
+ workspaceRoot: config.workspaceRoot || path.join(os.tmpdir(), 'k3s-deployer'),
33
+ registryHost: config.registryHost || 'localhost:5000',
34
+ namespacePrefix: config.namespacePrefix || 'apps',
35
+ defaultTarget: config.defaultTarget || { kind: 'local' },
36
+ mobilePreviewAdapter: config.mobilePreviewAdapter,
37
+ runnerFactory: config.runnerFactory,
38
+ mobileBuildTimeoutMs:
39
+ config.mobileBuildTimeoutMs || 5 * 60 * 60 * 1000,
40
+ cloudflareDns: normalizeCloudflareDns(config.cloudflareDns),
41
+ };
42
+ }
43
+
44
+ function createK3sDeployer(inputConfig = {}) {
45
+ const config = normalizeConfig(inputConfig);
46
+ const inspect = (source, overrides) => inspectRepository(source, overrides, config);
47
+ const plan = async (source, overrides) => {
48
+ const inspection = await inspect(source, overrides);
49
+ return createDeploymentPlan(inspection, overrides, config);
50
+ };
51
+ return {
52
+ inspectRepository: inspect,
53
+ planDeployment: plan,
54
+ ensureCluster: (target) => ensureCluster(target || config.defaultTarget, config),
55
+ deploy: (sourceOrPlan, options = {}) =>
56
+ deploy(sourceOrPlan, options, {
57
+ ...config,
58
+ inspectAndPlan: (source, overrides) => plan(source, overrides),
59
+ }),
60
+ startMobilePreview: (deploymentPlan, options) => startMobilePreview(deploymentPlan, config, options),
61
+ };
62
+ }
63
+
64
+ module.exports = {
65
+ createK3sDeployer,
66
+ createWebAndroidEmulatorAdapter,
67
+ };