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.
- package/index.cjs +1 -0
- package/index.d.ts +193 -0
- package/index.mjs +4 -0
- package/package.json +47 -0
- package/src/bootstrap/index.cjs +211 -0
- package/src/detect/index.cjs +461 -0
- package/src/dns/cloudflare.cjs +163 -0
- package/src/execute/index.cjs +386 -0
- package/src/index.cjs +67 -0
- package/src/mobile/index.cjs +1093 -0
- package/src/mobile/web-android-emulator-adapter.cjs +701 -0
- package/src/plan/index.cjs +200 -0
- package/src/runtime/index.cjs +16 -0
- package/src/runtime/local-runner.cjs +69 -0
- package/src/runtime/ssh-runner.cjs +206 -0
- package/src/shared/source.cjs +145 -0
- package/src/shared/utils.cjs +104 -0
- package/src/templates/dockerfile.cjs +114 -0
- package/src/templates/manifests.cjs +291 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
const { createConfigMapManifest, createDatabaseManifests, createDeploymentManifest, createIngressManifest, createSecretManifest, createServiceManifest } = require('../templates/manifests.cjs');
|
|
2
|
+
const { filterPublicEnv, slugify, toDatabaseName } = require('../shared/utils.cjs');
|
|
3
|
+
|
|
4
|
+
function createGeneratedEnv(inspection, overrides = {}) {
|
|
5
|
+
const backendEnv = { ...(overrides.privateEnv || {}) };
|
|
6
|
+
const frontendPublic = filterPublicEnv(overrides.publicEnv || {});
|
|
7
|
+
const primaryStore = inspection.datastores.find((item) => item.role === 'primary');
|
|
8
|
+
const projectSlug = inspection.projectSlug;
|
|
9
|
+
const dbName = toDatabaseName(projectSlug);
|
|
10
|
+
|
|
11
|
+
if (primaryStore) {
|
|
12
|
+
if (primaryStore.kind === 'postgresql') {
|
|
13
|
+
backendEnv.DATABASE_URL = `postgresql://app:apppass@${projectSlug}-postgresql:5432/${dbName}`;
|
|
14
|
+
backendEnv.DB_HOST = `${projectSlug}-postgresql`;
|
|
15
|
+
backendEnv.DB_PORT = '5432';
|
|
16
|
+
backendEnv.DB_NAME = dbName;
|
|
17
|
+
backendEnv.DB_USER = 'app';
|
|
18
|
+
backendEnv.DB_PASSWORD = 'apppass';
|
|
19
|
+
backendEnv.SPRING_DATASOURCE_URL = `jdbc:postgresql://${projectSlug}-postgresql:5432/${dbName}`;
|
|
20
|
+
backendEnv.SPRING_DATASOURCE_USERNAME = 'app';
|
|
21
|
+
backendEnv.SPRING_DATASOURCE_PASSWORD = 'apppass';
|
|
22
|
+
}
|
|
23
|
+
if (primaryStore.kind === 'mysql') {
|
|
24
|
+
backendEnv.DATABASE_URL = `mysql://app:apppass@${projectSlug}-mysql:3306/${dbName}`;
|
|
25
|
+
backendEnv.DB_HOST = `${projectSlug}-mysql`;
|
|
26
|
+
backendEnv.DB_PORT = '3306';
|
|
27
|
+
backendEnv.DB_NAME = dbName;
|
|
28
|
+
backendEnv.DB_USER = 'app';
|
|
29
|
+
backendEnv.DB_PASSWORD = 'apppass';
|
|
30
|
+
backendEnv.SPRING_DATASOURCE_URL = `jdbc:mysql://${projectSlug}-mysql:3306/${dbName}`;
|
|
31
|
+
backendEnv.SPRING_DATASOURCE_USERNAME = 'app';
|
|
32
|
+
backendEnv.SPRING_DATASOURCE_PASSWORD = 'apppass';
|
|
33
|
+
}
|
|
34
|
+
if (primaryStore.kind === 'mongodb') {
|
|
35
|
+
backendEnv.DATABASE_URL = `mongodb://${projectSlug}-mongodb:27017/${dbName}`;
|
|
36
|
+
backendEnv.MONGODB_URL = `mongodb://${projectSlug}-mongodb:27017/${dbName}`;
|
|
37
|
+
backendEnv.MONGO_URL = `mongodb://${projectSlug}-mongodb:27017/${dbName}`;
|
|
38
|
+
}
|
|
39
|
+
if (primaryStore.kind === 'sqlite') {
|
|
40
|
+
backendEnv.DATABASE_URL = `sqlite:////data/${dbName}.sqlite3`;
|
|
41
|
+
backendEnv.SQLITE_PATH = `/data/${dbName}.sqlite3`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (inspection.datastores.some((item) => item.kind === 'redis')) {
|
|
46
|
+
backendEnv.REDIS_URL = `redis://${projectSlug}-redis:6379`;
|
|
47
|
+
backendEnv.REDIS_HOST = `${projectSlug}-redis`;
|
|
48
|
+
backendEnv.REDIS_PORT = '6379';
|
|
49
|
+
backendEnv.SPRING_DATA_REDIS_HOST = `${projectSlug}-redis`;
|
|
50
|
+
backendEnv.SPRING_DATA_REDIS_PORT = '6379';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (inspection.datastores.some((item) => item.kind === 'elasticsearch')) {
|
|
54
|
+
backendEnv.ELASTICSEARCH_URL = `http://${projectSlug}-elasticsearch:9200`;
|
|
55
|
+
backendEnv.SPRING_ELASTICSEARCH_URIS = `http://${projectSlug}-elasticsearch:9200`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const frontendUnit = inspection.units.find((unit) => unit.role === 'frontend');
|
|
59
|
+
if (frontendUnit && overrides.domain) {
|
|
60
|
+
const apiUrl = overrides.apiUrl || (inspection.layout === 'single-role' ? `https://${overrides.domain}` : `https://${overrides.domain}`);
|
|
61
|
+
if (frontendUnit.framework === 'nextjs') {
|
|
62
|
+
frontendPublic.NEXT_PUBLIC_API_URL = frontendPublic.NEXT_PUBLIC_API_URL || apiUrl;
|
|
63
|
+
} else if (frontendUnit.framework === 'react' || frontendUnit.framework === 'vue') {
|
|
64
|
+
frontendPublic.VITE_API_URL = frontendPublic.VITE_API_URL || apiUrl;
|
|
65
|
+
frontendPublic.REACT_APP_API_URL = frontendPublic.REACT_APP_API_URL || apiUrl;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
backend: backendEnv,
|
|
71
|
+
frontendPublic,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolvePublicUnits(inspection) {
|
|
76
|
+
if (inspection.layout === 'monorepo') {
|
|
77
|
+
const frontend = inspection.units.find((unit) => unit.role === 'frontend');
|
|
78
|
+
return frontend ? [frontend.id] : [];
|
|
79
|
+
}
|
|
80
|
+
return inspection.units.filter((unit) => unit.role !== 'mobile').map((unit) => unit.id);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildPlanInternals(inspection, overrides = {}, config = {}) {
|
|
84
|
+
const registryHost = overrides.registryHost || config.registryHost || 'localhost:5000';
|
|
85
|
+
const namespace = slugify(overrides.namespace || 'default') || 'default';
|
|
86
|
+
const basePort = Number(overrides.serviceBasePort || 30080);
|
|
87
|
+
const publicUnits = resolvePublicUnits(inspection);
|
|
88
|
+
const units = inspection.units
|
|
89
|
+
.filter((unit) => unit.role !== 'mobile')
|
|
90
|
+
.map((unit, index) => ({
|
|
91
|
+
...unit,
|
|
92
|
+
name: slugify(`${inspection.projectSlug}-${unit.id}`),
|
|
93
|
+
image: `${registryHost}/${slugify(`${inspection.projectSlug}-${unit.id}`)}:latest`,
|
|
94
|
+
public: publicUnits.includes(unit.id),
|
|
95
|
+
nodePort: publicUnits.includes(unit.id) && !overrides.domain ? basePort + index : null,
|
|
96
|
+
secretName: unit.role === 'backend' ? `${inspection.projectSlug}-backend-env` : null,
|
|
97
|
+
configMapName: unit.role === 'frontend' ? `${inspection.projectSlug}-frontend-env` : null,
|
|
98
|
+
}));
|
|
99
|
+
return {
|
|
100
|
+
registryHost,
|
|
101
|
+
namespace,
|
|
102
|
+
units,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createDeploymentPlan(inspection, overrides = {}, config = {}) {
|
|
107
|
+
const env = createGeneratedEnv(inspection, overrides);
|
|
108
|
+
const internals = buildPlanInternals(inspection, overrides, config);
|
|
109
|
+
const secretManifest = createSecretManifest(`${inspection.projectSlug}-backend-env`, internals.namespace, env.backend);
|
|
110
|
+
const configMapManifest = createConfigMapManifest(
|
|
111
|
+
`${inspection.projectSlug}-frontend-env`,
|
|
112
|
+
internals.namespace,
|
|
113
|
+
env.frontendPublic,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const data = inspection.datastores.flatMap((datastore) =>
|
|
117
|
+
createDatabaseManifests({
|
|
118
|
+
kind: datastore.kind,
|
|
119
|
+
namespace: internals.namespace,
|
|
120
|
+
projectSlug: inspection.projectSlug,
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const apps = [];
|
|
125
|
+
if (secretManifest) {
|
|
126
|
+
apps.push(secretManifest);
|
|
127
|
+
}
|
|
128
|
+
if (configMapManifest) {
|
|
129
|
+
apps.push(configMapManifest);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const unit of internals.units) {
|
|
133
|
+
apps.push(
|
|
134
|
+
createDeploymentManifest({
|
|
135
|
+
name: unit.name,
|
|
136
|
+
namespace: internals.namespace,
|
|
137
|
+
image: unit.image,
|
|
138
|
+
port: unit.port || 3000,
|
|
139
|
+
secretName: unit.secretName,
|
|
140
|
+
configMapName: unit.configMapName,
|
|
141
|
+
volumeClaimName:
|
|
142
|
+
inspection.datastores.find((item) => item.kind === 'sqlite' && item.role === 'primary') && unit.role === 'backend'
|
|
143
|
+
? `${inspection.projectSlug}-sqlite-pvc`
|
|
144
|
+
: null,
|
|
145
|
+
volumeMountPath:
|
|
146
|
+
inspection.datastores.find((item) => item.kind === 'sqlite' && item.role === 'primary') && unit.role === 'backend'
|
|
147
|
+
? '/data'
|
|
148
|
+
: null,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
apps.push(
|
|
152
|
+
createServiceManifest({
|
|
153
|
+
name: unit.name,
|
|
154
|
+
namespace: internals.namespace,
|
|
155
|
+
port: 80,
|
|
156
|
+
targetPort: unit.port || 3000,
|
|
157
|
+
nodePort: unit.nodePort,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
if (overrides.domain && unit.public) {
|
|
161
|
+
apps.push(
|
|
162
|
+
createIngressManifest({
|
|
163
|
+
name: `${unit.name}-ingress`,
|
|
164
|
+
namespace: internals.namespace,
|
|
165
|
+
domain: overrides.domain,
|
|
166
|
+
serviceName: unit.name,
|
|
167
|
+
servicePort: 80,
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const mobileUnit = inspection.units.find((unit) => unit.role === 'mobile');
|
|
174
|
+
const mobile =
|
|
175
|
+
mobileUnit && mobileUnit.framework === 'android'
|
|
176
|
+
? { framework: 'android', apkPath: `${mobileUnit.root}/app/build/outputs/apk/release/app-release.apk` }
|
|
177
|
+
: mobileUnit
|
|
178
|
+
? { framework: 'flutter', apkPath: `${mobileUnit.root}/build/app/outputs/flutter-apk/app-release.apk` }
|
|
179
|
+
: undefined;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
inspection,
|
|
183
|
+
cluster: {
|
|
184
|
+
bootstrap: 'if-missing',
|
|
185
|
+
registryHost: internals.registryHost,
|
|
186
|
+
namespace: internals.namespace,
|
|
187
|
+
},
|
|
188
|
+
env,
|
|
189
|
+
manifests: {
|
|
190
|
+
apps,
|
|
191
|
+
data,
|
|
192
|
+
},
|
|
193
|
+
mobile,
|
|
194
|
+
resolved: internals,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
createDeploymentPlan,
|
|
200
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const { LocalRunner } = require('./local-runner.cjs');
|
|
2
|
+
const { SshRunner } = require('./ssh-runner.cjs');
|
|
3
|
+
|
|
4
|
+
async function createRunner(target, config = {}) {
|
|
5
|
+
if (config.runnerFactory) {
|
|
6
|
+
return config.runnerFactory(target);
|
|
7
|
+
}
|
|
8
|
+
if (!target || target.kind === 'local') {
|
|
9
|
+
return new LocalRunner(target);
|
|
10
|
+
}
|
|
11
|
+
return new SshRunner(target);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
createRunner,
|
|
16
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
class LocalRunner {
|
|
6
|
+
constructor(target = {}) {
|
|
7
|
+
this.target = target;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async run(command, options = {}) {
|
|
11
|
+
if (options.dryRun) {
|
|
12
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
13
|
+
}
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const child = spawn('bash', ['-lc', command], {
|
|
16
|
+
cwd: options.cwd,
|
|
17
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
18
|
+
});
|
|
19
|
+
let stdout = '';
|
|
20
|
+
let stderr = '';
|
|
21
|
+
|
|
22
|
+
child.stdout.on('data', (chunk) => {
|
|
23
|
+
const text = chunk.toString();
|
|
24
|
+
stdout += text;
|
|
25
|
+
if (options.onStdout) {
|
|
26
|
+
options.onStdout(text);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
child.stderr.on('data', (chunk) => {
|
|
30
|
+
const text = chunk.toString();
|
|
31
|
+
stderr += text;
|
|
32
|
+
if (options.onStderr) {
|
|
33
|
+
options.onStderr(text);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
child.on('error', reject);
|
|
37
|
+
child.on('close', (code) => {
|
|
38
|
+
if (code !== 0 && !options.allowFailure) {
|
|
39
|
+
reject(new Error(stderr || stdout || `Command failed: ${command}`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
resolve({ stdout, stderr, code });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async exists(filePath) {
|
|
48
|
+
try {
|
|
49
|
+
await fs.access(filePath);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async writeFile(filePath, content) {
|
|
57
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
58
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async writeBase64File(filePath, contentBase64) {
|
|
62
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
63
|
+
await fs.writeFile(filePath, Buffer.from(contentBase64, 'base64'));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
LocalRunner,
|
|
69
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { Client } = require('ssh2');
|
|
4
|
+
const { shellQuote } = require('../shared/utils.cjs');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SUDO_PROMPT_PATTERN = /\[sudo\] password for [^:]+:\s*/g;
|
|
7
|
+
|
|
8
|
+
function normalizePrivateKey(privateKey) {
|
|
9
|
+
const normalized = String(privateKey || '')
|
|
10
|
+
.replace(/\r\n/g, '\n')
|
|
11
|
+
.replace(/\\n/g, '\n')
|
|
12
|
+
.trim();
|
|
13
|
+
return normalized ? `${normalized}\n` : '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class SshRunner {
|
|
17
|
+
constructor(target) {
|
|
18
|
+
this.target = target;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
getSudoPassword() {
|
|
22
|
+
return this.target.sudoPassword || this.target.password || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
decorateCommand(command) {
|
|
26
|
+
const sudoPassword = this.getSudoPassword();
|
|
27
|
+
if (!sudoPassword || !command.includes('sudo ')) {
|
|
28
|
+
return command;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return command.replace(
|
|
32
|
+
/\bsudo\s+/g,
|
|
33
|
+
`printf '%s\\n' ${shellQuote(sudoPassword)} | sudo -S -p '' `,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async getConnectOptions() {
|
|
38
|
+
const connectOptions = {
|
|
39
|
+
host: this.target.host,
|
|
40
|
+
port: this.target.port || 22,
|
|
41
|
+
username: this.target.user,
|
|
42
|
+
readyTimeout: (this.target.connectTimeoutSec || 15) * 1000,
|
|
43
|
+
tryKeyboard: Boolean(this.target.password),
|
|
44
|
+
keepaliveInterval: 10000,
|
|
45
|
+
keepaliveCountMax: 30,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (this.target.privateKey) {
|
|
49
|
+
connectOptions.privateKey = normalizePrivateKey(this.target.privateKey);
|
|
50
|
+
} else if (this.target.privateKeyPath) {
|
|
51
|
+
const privateKey = await fs.readFile(
|
|
52
|
+
path.resolve(this.target.privateKeyPath),
|
|
53
|
+
'utf8',
|
|
54
|
+
);
|
|
55
|
+
connectOptions.privateKey = normalizePrivateKey(privateKey);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (this.target.password) {
|
|
59
|
+
connectOptions.password = this.target.password;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return connectOptions;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async run(command, options = {}) {
|
|
66
|
+
if (options.dryRun) {
|
|
67
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const decoratedCommand = this.decorateCommand(command);
|
|
71
|
+
const remoteCommand = `${options.cwd ? `cd ${shellQuote(options.cwd)} && ` : ''}${decoratedCommand}`;
|
|
72
|
+
const connectOptions = await this.getConnectOptions();
|
|
73
|
+
const timeoutMs =
|
|
74
|
+
options.timeoutMs || this.target.commandTimeoutMs || 15 * 60 * 1000;
|
|
75
|
+
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const conn = new Client();
|
|
78
|
+
let stdout = '';
|
|
79
|
+
let stderr = '';
|
|
80
|
+
let settled = false;
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
finish(
|
|
83
|
+
new Error(`Remote command timed out after ${timeoutMs}ms: ${command}`),
|
|
84
|
+
);
|
|
85
|
+
}, timeoutMs);
|
|
86
|
+
|
|
87
|
+
const finish = (error, result) => {
|
|
88
|
+
if (settled) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
conn.end();
|
|
94
|
+
if (error) {
|
|
95
|
+
reject(error);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
resolve(result);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
conn
|
|
102
|
+
.on('ready', () => {
|
|
103
|
+
conn.exec(`bash -lc ${shellQuote(remoteCommand)}`, (error, stream) => {
|
|
104
|
+
if (error) {
|
|
105
|
+
finish(error);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const handleChunk = (chunk, sink, callback) => {
|
|
110
|
+
const text = chunk.toString();
|
|
111
|
+
const normalizedText = text.replace(
|
|
112
|
+
DEFAULT_SUDO_PROMPT_PATTERN,
|
|
113
|
+
'',
|
|
114
|
+
);
|
|
115
|
+
if (normalizedText) {
|
|
116
|
+
sink.value += normalizedText;
|
|
117
|
+
if (callback) {
|
|
118
|
+
callback(normalizedText);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
stream.on('close', (code) => {
|
|
124
|
+
if (code !== 0 && !options.allowFailure) {
|
|
125
|
+
finish(
|
|
126
|
+
new Error(
|
|
127
|
+
stderr || stdout || `Remote command failed: ${command}`,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
finish(null, { stdout, stderr, code });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
stream.on('data', (chunk) => {
|
|
136
|
+
handleChunk(
|
|
137
|
+
chunk,
|
|
138
|
+
{
|
|
139
|
+
get value() {
|
|
140
|
+
return stdout;
|
|
141
|
+
},
|
|
142
|
+
set value(nextValue) {
|
|
143
|
+
stdout = nextValue;
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
options.onStdout,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
stream.stderr.on('data', (chunk) => {
|
|
151
|
+
handleChunk(
|
|
152
|
+
chunk,
|
|
153
|
+
{
|
|
154
|
+
get value() {
|
|
155
|
+
return stderr;
|
|
156
|
+
},
|
|
157
|
+
set value(nextValue) {
|
|
158
|
+
stderr = nextValue;
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
options.onStderr,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
})
|
|
166
|
+
.on('keyboard-interactive', (_name, _instructions, _lang, prompts, cb) => {
|
|
167
|
+
if (!this.target.password) {
|
|
168
|
+
cb([]);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
cb(prompts.map(() => this.target.password));
|
|
172
|
+
})
|
|
173
|
+
.on('error', (error) => {
|
|
174
|
+
finish(error);
|
|
175
|
+
})
|
|
176
|
+
.connect(connectOptions);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async exists(filePath) {
|
|
181
|
+
const result = await this.run(
|
|
182
|
+
`[ -e ${shellQuote(filePath)} ] && echo yes || echo no`,
|
|
183
|
+
{
|
|
184
|
+
allowFailure: true,
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
return result.stdout.trim() === 'yes';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async writeFile(filePath, content) {
|
|
191
|
+
await this.run(`mkdir -p ${shellQuote(path.posix.dirname(filePath))}`);
|
|
192
|
+
await this.run(`cat > ${shellQuote(filePath)} <<'EOF'\n${content}\nEOF`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async writeBase64File(filePath, contentBase64) {
|
|
196
|
+
const encodedPath = `${filePath}.k3s-deployer.b64`;
|
|
197
|
+
await this.run(`mkdir -p ${shellQuote(path.posix.dirname(filePath))}`);
|
|
198
|
+
await this.run(
|
|
199
|
+
`cat > ${shellQuote(encodedPath)} <<'EOF'\n${contentBase64}\nEOF\nbase64 -d ${shellQuote(encodedPath)} > ${shellQuote(filePath)}\nrm -f ${shellQuote(encodedPath)}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
SshRunner,
|
|
206
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const { execFile } = require('node:child_process');
|
|
5
|
+
const { promisify } = require('node:util');
|
|
6
|
+
const { ensureDir, hashText, shellQuote, slugify } = require('./utils.cjs');
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
function applyGitToken(url, token) {
|
|
11
|
+
if (!token) {
|
|
12
|
+
return url;
|
|
13
|
+
}
|
|
14
|
+
if (url.startsWith('https://')) {
|
|
15
|
+
return url.replace('https://', `https://${token}@`);
|
|
16
|
+
}
|
|
17
|
+
if (url.startsWith('git@github.com:')) {
|
|
18
|
+
return `https://${token}@github.com/${url.replace('git@github.com:', '')}`;
|
|
19
|
+
}
|
|
20
|
+
return url;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function deriveSourceName(source) {
|
|
24
|
+
if (source.kind === 'local') {
|
|
25
|
+
return path.basename(path.resolve(source.path));
|
|
26
|
+
}
|
|
27
|
+
if (source.fullName) {
|
|
28
|
+
return source.fullName.split('/').pop() || 'project';
|
|
29
|
+
}
|
|
30
|
+
return source.url.split('/').pop()?.replace(/\.git$/, '') || 'project';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fingerprintSource(source) {
|
|
34
|
+
return hashText(
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
kind: source.kind,
|
|
37
|
+
url: source.kind === 'git' ? source.url : path.resolve(source.path),
|
|
38
|
+
branch: source.kind === 'git' ? source.branch : source.branch || '',
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function withLocalRepository(source, workspaceRoot, callback) {
|
|
44
|
+
if (source.kind === 'local') {
|
|
45
|
+
const localPath = path.resolve(source.path);
|
|
46
|
+
return callback(localPath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tempBase = workspaceRoot || os.tmpdir();
|
|
50
|
+
await ensureDir(tempBase);
|
|
51
|
+
const tempRoot = await fs.mkdtemp(path.join(tempBase, 'k3s-deployer-inspect-'));
|
|
52
|
+
const repoPath = path.join(tempRoot, slugify(deriveSourceName(source)) || 'repo');
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await execFileAsync(
|
|
56
|
+
'git',
|
|
57
|
+
['clone', '--depth', '1', '--branch', source.branch, applyGitToken(source.url, source.accessToken), repoPath],
|
|
58
|
+
{
|
|
59
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
return await callback(repoPath);
|
|
63
|
+
} finally {
|
|
64
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function materializeLocalSource(source, workspaceRoot, releaseId) {
|
|
69
|
+
const targetRoot = path.join(workspaceRoot, releaseId);
|
|
70
|
+
await fs.rm(targetRoot, { recursive: true, force: true });
|
|
71
|
+
await ensureDir(workspaceRoot);
|
|
72
|
+
if (source.kind === 'local') {
|
|
73
|
+
await fs.cp(path.resolve(source.path), targetRoot, { recursive: true });
|
|
74
|
+
return targetRoot;
|
|
75
|
+
}
|
|
76
|
+
await execFileAsync(
|
|
77
|
+
'git',
|
|
78
|
+
['clone', '--depth', '1', '--branch', source.branch, applyGitToken(source.url, source.accessToken), targetRoot],
|
|
79
|
+
{
|
|
80
|
+
maxBuffer: 1024 * 1024 * 20,
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
return targetRoot;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveRemoteWorkspacePath(workspaceRoot, workspaceName) {
|
|
87
|
+
if (!workspaceRoot || workspaceRoot === '.' || workspaceRoot === './') {
|
|
88
|
+
return workspaceName;
|
|
89
|
+
}
|
|
90
|
+
return path.posix.join(workspaceRoot, workspaceName);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function materializeRemoteGitSource(
|
|
94
|
+
source,
|
|
95
|
+
runner,
|
|
96
|
+
workspaceRoot,
|
|
97
|
+
releaseId,
|
|
98
|
+
options = {},
|
|
99
|
+
) {
|
|
100
|
+
if (source.kind !== 'git') {
|
|
101
|
+
throw new Error('Local path sources require a local runtime target');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const workspacePath = resolveRemoteWorkspacePath(workspaceRoot, releaseId);
|
|
105
|
+
const remoteUrl = applyGitToken(source.url, source.accessToken);
|
|
106
|
+
const branchRef = `refs/heads/${source.branch}`;
|
|
107
|
+
const originBranchRef = `origin/${source.branch}`;
|
|
108
|
+
const gitPrefix = 'GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/true';
|
|
109
|
+
if (workspaceRoot && workspaceRoot !== '.' && workspaceRoot !== './') {
|
|
110
|
+
await runner.run(`mkdir -p ${shellQuote(workspaceRoot)}`, {
|
|
111
|
+
onStdout: options.onLog,
|
|
112
|
+
onStderr: options.onLog,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
await runner.run(
|
|
116
|
+
`if [ -d ${shellQuote(workspacePath)} ]; then
|
|
117
|
+
echo "Directory ${workspacePath} exists. Pulling latest changes..."
|
|
118
|
+
cd ${shellQuote(workspacePath)}
|
|
119
|
+
${gitPrefix} git remote set-url origin ${shellQuote(remoteUrl)}
|
|
120
|
+
${gitPrefix} git fetch --progress origin ${shellQuote(source.branch)}
|
|
121
|
+
if git show-ref --verify --quiet ${shellQuote(branchRef)}; then
|
|
122
|
+
git checkout ${shellQuote(source.branch)}
|
|
123
|
+
else
|
|
124
|
+
git checkout -b ${shellQuote(source.branch)} ${shellQuote(originBranchRef)}
|
|
125
|
+
fi
|
|
126
|
+
${gitPrefix} git pull --progress origin ${shellQuote(source.branch)}
|
|
127
|
+
else
|
|
128
|
+
echo "Directory ${workspacePath} not found. Cloning repository..."
|
|
129
|
+
${gitPrefix} git clone --progress --depth 1 --branch ${shellQuote(source.branch)} ${shellQuote(remoteUrl)} ${shellQuote(workspacePath)}
|
|
130
|
+
fi`,
|
|
131
|
+
{
|
|
132
|
+
onStdout: options.onLog,
|
|
133
|
+
onStderr: options.onLog,
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
return workspacePath;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
deriveSourceName,
|
|
141
|
+
fingerprintSource,
|
|
142
|
+
materializeLocalSource,
|
|
143
|
+
materializeRemoteGitSource,
|
|
144
|
+
withLocalRepository,
|
|
145
|
+
};
|