elestio 1.0.2 → 1.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/README.md +242 -4
- package/package.json +9 -2
- package/src/api.js +59 -21
- package/src/cli.js +16 -813
- package/src/commands/access.js +26 -13
- package/src/commands/auth.js +1 -1
- package/src/commands/cicd-template.js +363 -0
- package/src/commands/cicd.js +40 -15
- package/src/commands/clusters.js +337 -0
- package/src/commands/services.js +38 -26
- package/src/config.js +22 -2
- package/src/constants.js +76 -0
- package/src/deployment.js +33 -0
- package/src/payloads/pipeline.js +191 -0
- package/src/payloads/server.js +130 -0
- package/src/registry.js +663 -0
- package/src/router.js +188 -0
- package/src/templates/elestio-config.js +188 -0
- package/src/utils.js +27 -15
package/src/commands/access.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { apiRequest } from '../api.js';
|
|
2
2
|
import { loadConfig } from '../config.js';
|
|
3
|
-
import {
|
|
3
|
+
import { colors, outputJson } from '../utils.js';
|
|
4
4
|
import { getServiceDetails } from './services.js';
|
|
5
5
|
|
|
6
6
|
export async function getCredentials(vmID, projectId = null, json = false) {
|
|
@@ -71,22 +71,35 @@ export async function getSSH(vmID, projectId = null, json = false) {
|
|
|
71
71
|
return response;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
/**
|
|
75
|
+
* `--direct` prints the details for connecting with a local ssh client.
|
|
76
|
+
*
|
|
77
|
+
* These come from the service record: /api/servers/startSSHDirect returns a
|
|
78
|
+
* browser terminal URL, not host/port/user, so it cannot answer this.
|
|
79
|
+
*/
|
|
80
|
+
export async function getSSHDirect(vmID, projectId = null, json = false) {
|
|
81
|
+
const config = loadConfig();
|
|
82
|
+
const pid = projectId || config.defaultProject;
|
|
83
|
+
if (!pid) throw new Error('Project ID required');
|
|
77
84
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
const service = await getServiceDetails(vmID, pid);
|
|
86
|
+
if (!service) throw new Error(`Service with vmID ${vmID} not found`);
|
|
87
|
+
|
|
88
|
+
const host = service.ipv4 || service.cname;
|
|
89
|
+
if (!host) throw new Error(`Service ${vmID} has no IP yet; it may still be deploying`);
|
|
90
|
+
|
|
91
|
+
const details = { host, port: 22, user: 'root', command: `ssh root@${host}` };
|
|
92
|
+
|
|
93
|
+
if (json) { outputJson(details); return details; }
|
|
82
94
|
|
|
83
95
|
console.log(`\n${colors.bold}Direct SSH${colors.reset}\n`);
|
|
84
|
-
console.log(` Host: ${
|
|
85
|
-
console.log(` Port:
|
|
86
|
-
console.log(` User:
|
|
87
|
-
console.log(`\n ${colors.
|
|
96
|
+
console.log(` Host: ${host}`);
|
|
97
|
+
console.log(` Port: 22`);
|
|
98
|
+
console.log(` User: root`);
|
|
99
|
+
console.log(`\n ${colors.cyan}${details.command}${colors.reset}`);
|
|
100
|
+
console.log(`\n ${colors.dim}Add your key first: elestio ssh-keys add ${vmID} --name <n> --key "$(awk '{print $1" "$2}' ~/.ssh/id_ed25519.pub)"${colors.reset}`);
|
|
88
101
|
console.log('');
|
|
89
|
-
return
|
|
102
|
+
return details;
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
export async function getVSCode(vmID, projectId = null, json = false) {
|
package/src/commands/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadConfig, saveConfig, saveCredentials, getCredentials
|
|
1
|
+
import { loadConfig, saveConfig, saveCredentials, getCredentials } from '../config.js';
|
|
2
2
|
import { authenticate } from '../api.js';
|
|
3
3
|
import { log, colors, outputJson } from '../utils.js';
|
|
4
4
|
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { apiRequest } from '../api.js';
|
|
2
|
+
import { loadConfig } from '../config.js';
|
|
3
|
+
import { log, colors, outputJson, formatTable } from '../utils.js';
|
|
4
|
+
import { getTemplates } from './templates.js';
|
|
5
|
+
import { createSubstitutions, normalizeElestioConfig, repoFileMounts } from '../templates/elestio-config.js';
|
|
6
|
+
import { buildGitPipelinePayload, buildComposePipelinePayload } from '../payloads/pipeline.js';
|
|
7
|
+
import { TEMPLATE_REPO_OWNER, TEMPLATE_REPO_HOST } from '../constants.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `elestio cicd deploy-template` - deploy catalog software as a CI/CD pipeline.
|
|
11
|
+
*
|
|
12
|
+
* Every catalog entry has a matching repo under github.com/elestio-examples
|
|
13
|
+
* carrying an elestio.yml that describes how to run it: ports, environment
|
|
14
|
+
* variables, lifecycle hooks. Creating a pipeline without reading that file
|
|
15
|
+
* produces a pipeline that starts nothing, which is why this is a dedicated
|
|
16
|
+
* command rather than a flag on `cicd create`.
|
|
17
|
+
*
|
|
18
|
+
* Two routes:
|
|
19
|
+
* git - generate the template repo into the user's own Git account, then
|
|
20
|
+
* point the pipeline at it. Keeps the lifecycle scripts.
|
|
21
|
+
* compose - inline the template's docker-compose.yml. No Git account needed,
|
|
22
|
+
* but lifecycle scripts are lost because there is no checkout.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function templateRepoUrl(repoName) {
|
|
26
|
+
return `${TEMPLATE_REPO_HOST}/${TEMPLATE_REPO_OWNER}/${repoName}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Repos under elestio-examples are named after the catalog title, lowercased
|
|
31
|
+
* and hyphenated -- "N8N" -> n8n, "InvenTree" -> inventree. GitHub resolves
|
|
32
|
+
* repository names case-insensitively, so the casing does not have to match.
|
|
33
|
+
*
|
|
34
|
+
* Do not derive this from dockerhub_image: that is the upstream image
|
|
35
|
+
* ("n8nio/n8n"), not the template repo.
|
|
36
|
+
*/
|
|
37
|
+
export function templateRepoName(template) {
|
|
38
|
+
return String(template.title)
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
41
|
+
.replace(/^-+|-+$/g, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fetchGitContent(url, branch, file, projectId, authID = null) {
|
|
45
|
+
const response = await apiRequest('/api/cicd/getGitContent', 'POST', {
|
|
46
|
+
url, branch, file,
|
|
47
|
+
unAuthorized: !authID,
|
|
48
|
+
authID: authID || '',
|
|
49
|
+
projectID: String(projectId),
|
|
50
|
+
pipelineID: null,
|
|
51
|
+
contentPath: ''
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (response && response.status === 'KO') return null;
|
|
55
|
+
return response;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reads elestio.yml from a template repo. The backend parses the YAML, so the
|
|
60
|
+
* CLI stays dependency-free.
|
|
61
|
+
*/
|
|
62
|
+
export async function fetchElestioConfig(repoUrl, branch, projectId, email, authID = null) {
|
|
63
|
+
const raw = await fetchGitContent(repoUrl, branch, 'elestio', projectId, authID);
|
|
64
|
+
const subs = createSubstitutions(email);
|
|
65
|
+
return { config: normalizeElestioConfig(raw, subs), subs, raw };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function fetchCompose(repoUrl, branch, projectId, authID = null) {
|
|
69
|
+
const response = await fetchGitContent(repoUrl, branch, 'docker-compose', projectId, authID);
|
|
70
|
+
const compose = response?.data?.dockerCompose;
|
|
71
|
+
if (!compose) {
|
|
72
|
+
throw new Error(`No docker-compose file found in ${repoUrl} on branch "${branch}"`);
|
|
73
|
+
}
|
|
74
|
+
return compose;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function resolveCicdTarget(vmID, projectId) {
|
|
78
|
+
const response = await apiRequest('/api/cicd/getCICDServices', 'POST', { projectID: String(projectId) });
|
|
79
|
+
const services = Array.isArray(response) ? response : (response.data?.services || []);
|
|
80
|
+
const target = services.find(s => String(s.providerServerID) === String(vmID) || String(s.vmID) === String(vmID));
|
|
81
|
+
|
|
82
|
+
if (!target) {
|
|
83
|
+
const available = services.map(s => s.providerServerID || s.vmID).join(', ') || 'none';
|
|
84
|
+
throw new Error(
|
|
85
|
+
`CI/CD target ${vmID} not found in project ${projectId}. Available targets: ${available}. ` +
|
|
86
|
+
'Create one with: elestio deploy CI-CD-Target'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
displayName: target.displayName || target.name,
|
|
92
|
+
id: target.id || target.serverID,
|
|
93
|
+
serverName: target.serverName || '',
|
|
94
|
+
vmID: String(target.providerServerID || target.vmID),
|
|
95
|
+
vmProvider: target.vmProvider || target.provider || '',
|
|
96
|
+
vmRegion: target.vmRegion || target.datacenter || '',
|
|
97
|
+
levelName: target.levelName || 'Elestio-services',
|
|
98
|
+
projectID: String(projectId)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function findGitAuth(gitType, projectId) {
|
|
103
|
+
const response = await apiRequest('/api/cicd/getCICDUserAuthAccount', 'POST', {});
|
|
104
|
+
const accounts = response.data?.accounts || response.data || response.accounts || [];
|
|
105
|
+
if (!Array.isArray(accounts)) return null;
|
|
106
|
+
const match = accounts.find(a => (a.externalProviderName || a.gitType) === gitType);
|
|
107
|
+
return match ? String(match.id || match.authID) : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generates the template repo into the user's Git account.
|
|
112
|
+
* GitHub uses the template-generate API; GitLab imports the repo by URL.
|
|
113
|
+
*
|
|
114
|
+
* NOTE: /api/cicd/createRepoByTemplate currently returns 404 in production -
|
|
115
|
+
* the controller exists in the backend but is not registered in apiconfig.json,
|
|
116
|
+
* which is the route whitelist. Until that is fixed the Git route cannot work,
|
|
117
|
+
* so --no-git is the default and this path reports the gap precisely rather
|
|
118
|
+
* than surfacing a bare 404.
|
|
119
|
+
*/
|
|
120
|
+
async function generateRepoFromTemplate({ gitType, authID, owner, repoName, templateName, isPrivate, isNonOrg }) {
|
|
121
|
+
let response;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
response = await apiRequest('/api/cicd/createRepoByTemplate', 'POST', {
|
|
125
|
+
ownerName: owner,
|
|
126
|
+
repoName,
|
|
127
|
+
templateName,
|
|
128
|
+
gitType,
|
|
129
|
+
authID: String(authID),
|
|
130
|
+
isPrivate: String(!!isPrivate),
|
|
131
|
+
isNonOrg: !!isNonOrg
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (/non-JSON response \(HTTP 404\)|HTTP 404/.test(err.message)) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
'The Elestio API does not currently expose /api/cicd/createRepoByTemplate (404), ' +
|
|
137
|
+
'so the Git route is unavailable. Deploy with --no-git instead; the software will run, ' +
|
|
138
|
+
'but any lifecycle scripts it declares will be skipped.'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (response?.status === 'KO') {
|
|
145
|
+
throw new Error(response.message || response.details?.message || `Could not create "${repoName}" in ${owner}`);
|
|
146
|
+
}
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function listDeployableTemplates(query, json = false) {
|
|
151
|
+
const templates = await getTemplates();
|
|
152
|
+
const q = (query || '').toLowerCase();
|
|
153
|
+
const matches = templates
|
|
154
|
+
.filter(t => !q || t.title?.toLowerCase().includes(q) || String(t.id) === q)
|
|
155
|
+
.map(t => ({ id: t.id, title: t.title, category: t.category, repo: templateRepoName(t) }));
|
|
156
|
+
|
|
157
|
+
if (json) { outputJson(matches); return matches; }
|
|
158
|
+
|
|
159
|
+
console.log(`\n${colors.bold}Catalog software deployable as a pipeline (${matches.length})${colors.reset}\n`);
|
|
160
|
+
console.log(formatTable(matches.slice(0, 60), [
|
|
161
|
+
{ key: 'id', label: 'ID' },
|
|
162
|
+
{ key: 'title', label: 'Software' },
|
|
163
|
+
{ key: 'category', label: 'Category' },
|
|
164
|
+
{ key: 'repo', label: 'Template repo' }
|
|
165
|
+
]));
|
|
166
|
+
if (matches.length > 60) console.log(`\n ... and ${matches.length - 60} more. Narrow with: elestio cicd templates <query>`);
|
|
167
|
+
console.log('');
|
|
168
|
+
return matches;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} nameOrId catalog template, by name or ID
|
|
173
|
+
* @param {object} options see cli.js for the flag mapping
|
|
174
|
+
*/
|
|
175
|
+
export async function deployTemplate(nameOrId, options = {}) {
|
|
176
|
+
const config = loadConfig();
|
|
177
|
+
const projectId = options.project || config.defaultProject;
|
|
178
|
+
if (!projectId) throw new Error('Project ID required. Use --project or set a default.');
|
|
179
|
+
if (!options.target) throw new Error('A CI/CD target is required (--target <vmID>). List them with: elestio cicd targets');
|
|
180
|
+
|
|
181
|
+
const templates = await getTemplates();
|
|
182
|
+
const template = templates.find(t =>
|
|
183
|
+
String(t.id) === String(nameOrId) || t.title?.toLowerCase() === String(nameOrId).toLowerCase()
|
|
184
|
+
) || templates.find(t => t.title?.toLowerCase().includes(String(nameOrId).toLowerCase()));
|
|
185
|
+
|
|
186
|
+
if (!template) {
|
|
187
|
+
throw new Error(`Template "${nameOrId}" not found. Search with: elestio cicd templates ${nameOrId}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const repoName = options.repoName || templateRepoName(template);
|
|
191
|
+
const pipelineName = options.name || repoName;
|
|
192
|
+
const branch = options.branch || 'main';
|
|
193
|
+
const sourceRepoUrl = templateRepoUrl(repoName);
|
|
194
|
+
// Opt-in, not opt-out: the Git route depends on an endpoint the API does not
|
|
195
|
+
// currently expose (see generateRepoFromTemplate).
|
|
196
|
+
const useGit = options.git === true;
|
|
197
|
+
|
|
198
|
+
log('info', `Resolving CI/CD target ${options.target}...`);
|
|
199
|
+
const target = await resolveCicdTarget(options.target, projectId);
|
|
200
|
+
log('success', `Target: ${target.displayName} (${target.vmID})`);
|
|
201
|
+
|
|
202
|
+
log('info', `Reading elestio.yml from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
|
|
203
|
+
const { config: elestioConfig, subs } = await fetchElestioConfig(sourceRepoUrl, branch, projectId, config.email);
|
|
204
|
+
|
|
205
|
+
if (!elestioConfig.hasConfig) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`No elestio.yml found at ${sourceRepoUrl} (branch "${branch}"). ` +
|
|
208
|
+
`"${template.title}" may not be publishable as a pipeline; deploy it as a managed service instead: elestio deploy ${template.id}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
log('success', `Config loaded: runtime "${elestioConfig.config.runTime}", ${elestioConfig.variables ? elestioConfig.variables.split('\n').length : 0} env vars, ${elestioConfig.ports.length} port(s)`);
|
|
212
|
+
|
|
213
|
+
const payload = useGit
|
|
214
|
+
? await buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig })
|
|
215
|
+
: await buildComposeRoute({ projectId, target, repoName, pipelineName, branch, sourceRepoUrl, elestioConfig, force: options.force });
|
|
216
|
+
|
|
217
|
+
if (options.dryRun) {
|
|
218
|
+
if (options.json) { outputJson({ dryRun: true, route: useGit ? 'git' : 'compose', payload }); return payload; }
|
|
219
|
+
printPlan({ template, repoName, pipelineName, target, elestioConfig, useGit, branch });
|
|
220
|
+
log('info', 'To deploy, run the same command without --dry-run');
|
|
221
|
+
return payload;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
log('info', 'Creating pipeline...');
|
|
225
|
+
const response = await apiRequest('/api/cicd/createCiCdExistServer', 'POST', payload);
|
|
226
|
+
|
|
227
|
+
if (response.status !== 'OK' && !response.providerServerID && !response.pipelineID) {
|
|
228
|
+
throw new Error(response.message || JSON.stringify(response));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
log('success', `Pipeline "${pipelineName}" created on ${target.displayName}`);
|
|
232
|
+
|
|
233
|
+
if (options.json) {
|
|
234
|
+
outputJson({ status: 'OK', pipelineName, target: target.vmID, route: useGit ? 'git' : 'compose', webUI: elestioConfig.webUI, response });
|
|
235
|
+
return response;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
printAccess(elestioConfig, subs, target);
|
|
239
|
+
return response;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig }) {
|
|
243
|
+
const gitType = (options.gitType || 'GITHUB').toUpperCase();
|
|
244
|
+
|
|
245
|
+
let authID = options.authId ? String(options.authId) : await findGitAuth(gitType, projectId);
|
|
246
|
+
if (!authID) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`No ${gitType} account connected. Connect one in the dashboard (CI/CD > Git accounts), pass --auth-id, ` +
|
|
249
|
+
'or deploy without Git using --no-git (lifecycle scripts will be skipped).'
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const owner = options.owner;
|
|
254
|
+
if (!owner) {
|
|
255
|
+
throw new Error('The Git account or organisation to create the repo in is required (--owner <user-or-org>)');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
log('info', `Creating ${owner}/${repoName} from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
|
|
259
|
+
const repo = await generateRepoFromTemplate({
|
|
260
|
+
gitType, authID, owner, repoName,
|
|
261
|
+
templateName: repoName,
|
|
262
|
+
isPrivate: !!options.private,
|
|
263
|
+
isNonOrg: !!options.nonOrg
|
|
264
|
+
});
|
|
265
|
+
log('success', `Repo created: ${owner}/${repoName}`);
|
|
266
|
+
|
|
267
|
+
return buildGitPipelinePayload({
|
|
268
|
+
target,
|
|
269
|
+
projectId,
|
|
270
|
+
pipelineName,
|
|
271
|
+
gitType,
|
|
272
|
+
repo: `${owner}/${repoName}`,
|
|
273
|
+
branch,
|
|
274
|
+
repoID: repo?.id ?? repo?.content?.id ?? '',
|
|
275
|
+
elestioConfig,
|
|
276
|
+
authID,
|
|
277
|
+
isPublicGitRepo: !options.private,
|
|
278
|
+
isNeedToCreateRepo: true,
|
|
279
|
+
appType: 'docker',
|
|
280
|
+
overrides: collectOverrides(options)
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function buildComposeRoute({ projectId, target, repoName, pipelineName, branch, sourceRepoUrl, elestioConfig, force }) {
|
|
285
|
+
log('info', `Fetching docker-compose from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
|
|
286
|
+
const compose = await fetchCompose(sourceRepoUrl, branch, projectId);
|
|
287
|
+
log('success', 'Compose file loaded');
|
|
288
|
+
|
|
289
|
+
// Checked before anything is created: without these files the containers
|
|
290
|
+
// fail ~40s into the build with an opaque "not a directory" from runc.
|
|
291
|
+
const fileMounts = repoFileMounts(compose);
|
|
292
|
+
if (fileMounts.length > 0 && !force) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`"${repoName}" bind-mounts ${fileMounts.length} file(s) that live in the template repo ` +
|
|
295
|
+
`(${fileMounts.join(', ')}). The compose route has no checkout, so Docker creates each one as an ` +
|
|
296
|
+
'empty directory and the container fails to start. Use the Git route (--owner <git-user>) for this ' +
|
|
297
|
+
'software, or --force to deploy anyway.'
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (hasLifecycleHooks(elestioConfig)) {
|
|
302
|
+
log('warn', `"${repoName}" defines lifecycle scripts that need a repo checkout; they are skipped on the compose route.`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return buildComposePipelinePayload({
|
|
306
|
+
target, projectId, pipelineName, compose, elestioConfig
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function hasLifecycleHooks(elestioConfig) {
|
|
311
|
+
return Object.values(elestioConfig.lifeCycleCommand).some(v => v && v !== '');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function collectOverrides(options) {
|
|
315
|
+
const overrides = {};
|
|
316
|
+
if (options.buildCmd !== undefined) overrides.buildCmd = options.buildCmd;
|
|
317
|
+
if (options.runCmd !== undefined) overrides.runCmd = options.runCmd;
|
|
318
|
+
if (options.installCmd !== undefined) overrides.installCmd = options.installCmd;
|
|
319
|
+
if (options.buildDir !== undefined) overrides.buildDir = options.buildDir;
|
|
320
|
+
if (options.variables !== undefined) overrides.variables = options.variables;
|
|
321
|
+
return overrides;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function printPlan({ template, repoName, pipelineName, target, elestioConfig, useGit, branch }) {
|
|
325
|
+
console.log(`\n${colors.bold}Pipeline Preview (--dry-run)${colors.reset}\n`);
|
|
326
|
+
console.log(` Software: ${colors.cyan}${template.title}${colors.reset} (ID: ${template.id})`);
|
|
327
|
+
console.log(` Template: ${TEMPLATE_REPO_OWNER}/${repoName} @ ${branch}`);
|
|
328
|
+
console.log(` Pipeline: ${pipelineName}`);
|
|
329
|
+
console.log(` Target: ${target.displayName} (${target.vmID})`);
|
|
330
|
+
console.log(` Route: ${useGit ? 'git (repo generated in your account, lifecycle scripts kept)' : 'compose (inlined, lifecycle scripts skipped)'}`);
|
|
331
|
+
console.log(` Runtime: ${elestioConfig.config.runTime || 'N/A'}`);
|
|
332
|
+
console.log(` Ports: ${elestioConfig.ports.map(p => `${p.listeningPort}->${p.targetPort}`).join(', ') || 'default'}`);
|
|
333
|
+
console.log(` Env vars: ${elestioConfig.variables ? elestioConfig.variables.split('\n').map(v => v.split('=')[0]).join(', ') : 'none'}`);
|
|
334
|
+
if (hasLifecycleHooks(elestioConfig)) {
|
|
335
|
+
const hooks = Object.entries(elestioConfig.lifeCycleCommand).filter(([, v]) => v).map(([k]) => k);
|
|
336
|
+
console.log(` Lifecycle: ${hooks.join(', ')}`);
|
|
337
|
+
}
|
|
338
|
+
console.log('');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function printAccess(elestioConfig, subs, target) {
|
|
342
|
+
const vars = Object.fromEntries(
|
|
343
|
+
(elestioConfig.variables || '').split('\n').filter(Boolean).map(line => {
|
|
344
|
+
const eq = line.indexOf('=');
|
|
345
|
+
return [line.slice(0, eq), line.slice(eq + 1)];
|
|
346
|
+
})
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
if (elestioConfig.webUI.length > 0) {
|
|
350
|
+
console.log(`\n${colors.bold}Access${colors.reset}\n`);
|
|
351
|
+
for (const ui of elestioConfig.webUI) {
|
|
352
|
+
// webUI references env vars by name, e.g. password: "[ADMIN_PASSWORD]"
|
|
353
|
+
const resolve = (v) => String(v || '').replace(/\[([A-Z0-9_]+)\]/g, (m, key) => vars[key] ?? m);
|
|
354
|
+
console.log(` ${colors.bold}${ui.label || 'Web UI'}${colors.reset}`);
|
|
355
|
+
console.log(` URL: ${colors.cyan}${resolve(ui.url)}${colors.reset}`);
|
|
356
|
+
if (ui.login) console.log(` User: ${resolve(ui.login)}`);
|
|
357
|
+
if (ui.password) console.log(` Password: ${resolve(ui.password)}`);
|
|
358
|
+
}
|
|
359
|
+
console.log(`\n ${colors.dim}[CI_CD_DOMAIN] resolves once the pipeline has deployed.${colors.reset}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
console.log(`\n Follow the build: ${colors.cyan}elestio cicd pipelines ${target.vmID}${colors.reset}\n`);
|
|
363
|
+
}
|
package/src/commands/cicd.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { apiRequest } from '../api.js';
|
|
2
2
|
import { loadConfig } from '../config.js';
|
|
3
|
-
import { log, colors, formatTable,
|
|
4
|
-
import { getServiceDetails } from './services.js';
|
|
3
|
+
import { log, colors, formatTable, outputJson } from '../utils.js';
|
|
5
4
|
import fs from 'fs';
|
|
6
|
-
import path from 'path';
|
|
7
|
-
import os from 'os';
|
|
8
|
-
import { execSync } from 'child_process';
|
|
9
5
|
|
|
10
6
|
// ── List / Details ──
|
|
11
7
|
|
|
@@ -147,13 +143,29 @@ export async function getPipelineLogs(vmID, pipelineID, projectId) {
|
|
|
147
143
|
|
|
148
144
|
export async function getPipelineHistory(vmID, pipelineID, projectId, json = false) {
|
|
149
145
|
const result = await doActionOnPipeline(vmID, pipelineID, 'getHistory', {}, projectId);
|
|
150
|
-
|
|
146
|
+
// The action answers {data: [...]}, not the {data: {history: [...]}} shape
|
|
147
|
+
// the other endpoints use, so history never rendered.
|
|
148
|
+
const history = Array.isArray(result.data) ? result.data : (result.data?.history || result.history || []);
|
|
151
149
|
if (json) { outputJson(history); return history; }
|
|
152
150
|
if (history.length === 0) { log('info', 'No build history'); return []; }
|
|
153
151
|
|
|
152
|
+
const rows = history.map(h => ({
|
|
153
|
+
status: h.status || 'N/A',
|
|
154
|
+
action: h.action || 'N/A',
|
|
155
|
+
duration: h.duration !== undefined ? `${h.duration}s` : 'N/A',
|
|
156
|
+
startTime: h.startTime ? new Date(h.startTime).toISOString().replace('T', ' ').slice(0, 19) : 'N/A',
|
|
157
|
+
log: h.logID || h.filepath || h.file || 'N/A'
|
|
158
|
+
}));
|
|
159
|
+
|
|
154
160
|
console.log(`\n${colors.bold}Build History${colors.reset}\n`);
|
|
155
|
-
|
|
156
|
-
|
|
161
|
+
console.log(formatTable(rows, [
|
|
162
|
+
{ key: 'status', label: 'Status' },
|
|
163
|
+
{ key: 'action', label: 'Action' },
|
|
164
|
+
{ key: 'duration', label: 'Duration' },
|
|
165
|
+
{ key: 'startTime', label: 'Started' },
|
|
166
|
+
{ key: 'log', label: 'Log file' }
|
|
167
|
+
]));
|
|
168
|
+
console.log(`\n Read one with: ${colors.cyan}elestio cicd pipeline-log ${vmID} --pipeline ${pipelineID} --file <log file>${colors.reset}\n`);
|
|
157
169
|
return history;
|
|
158
170
|
}
|
|
159
171
|
|
|
@@ -203,7 +215,13 @@ export async function removePipelineDomain(vmID, pipelineID, domain, projectId)
|
|
|
203
215
|
export async function createPipeline(configFile) {
|
|
204
216
|
if (!configFile || !fs.existsSync(configFile)) throw new Error(`Config file not found: ${configFile}`);
|
|
205
217
|
|
|
206
|
-
const
|
|
218
|
+
const fileConfig = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
|
|
219
|
+
// The backend runs variables.trim(), so it must be a string, never an array.
|
|
220
|
+
const pipelineConfig = {
|
|
221
|
+
...fileConfig,
|
|
222
|
+
variables: typeof fileConfig.variables === 'string' ? fileConfig.variables : ''
|
|
223
|
+
};
|
|
224
|
+
|
|
207
225
|
log('info', 'Creating pipeline...');
|
|
208
226
|
const response = await apiRequest('/api/cicd/createCiCdExistServer', 'POST', pipelineConfig);
|
|
209
227
|
|
|
@@ -338,7 +356,7 @@ export async function autoCreatePipeline(options = {}) {
|
|
|
338
356
|
targetIP: '172.17.0.1', path: '/', isAuth: false,
|
|
339
357
|
login: '', password: '', loginTitle: ''
|
|
340
358
|
}],
|
|
341
|
-
variables: options.variables
|
|
359
|
+
variables: typeof options.variables === 'string' ? options.variables : '',
|
|
342
360
|
isPublicGitRepo: repoData ? !repoData.private : false,
|
|
343
361
|
exposedPorts: [{ protocol: 'HTTP', hostPort: preset.containerPort, containerPort: preset.containerPort, interface: '172.17.0.1' }],
|
|
344
362
|
gitVolumeConfig: [{}],
|
|
@@ -455,12 +473,14 @@ export function generatePipelineTemplate(mode = 'docker') {
|
|
|
455
473
|
|
|
456
474
|
// ── Docker Registries ──
|
|
457
475
|
|
|
458
|
-
export async function addDockerRegistry(projectId, identityName, username, password, url) {
|
|
476
|
+
export async function addDockerRegistry(projectId, identityName, username, password, url, registryType = 'docker.io', repoId = '', gitlabUrl = '') {
|
|
459
477
|
const config = loadConfig();
|
|
460
478
|
const pid = projectId || config.defaultProject;
|
|
461
479
|
|
|
462
|
-
const
|
|
463
|
-
|
|
480
|
+
const payload = { projectID: String(pid), identityName, username, password, url, registryType, repoID: repoId, gitlabUrl };
|
|
481
|
+
const response = await apiRequest('/api/cicd/addDockerRegistry', 'POST', payload);
|
|
482
|
+
if (response.status === 'KO') throw new Error(response.message || 'Failed');
|
|
483
|
+
if (!response.id) throw new Error(response.message || 'Failed');
|
|
464
484
|
|
|
465
485
|
log('success', `Docker registry "${identityName}" added`);
|
|
466
486
|
return response;
|
|
@@ -471,9 +491,14 @@ export async function getDockerRegistries(projectId = null, json = false) {
|
|
|
471
491
|
const pid = projectId || config.defaultProject;
|
|
472
492
|
|
|
473
493
|
const response = await apiRequest('/api/cicd/getDockerRegistry', 'GET', { projectID: String(pid) });
|
|
474
|
-
|
|
494
|
+
// This endpoint answers with a bare array, not the usual {status, data}
|
|
495
|
+
// envelope -- and an empty project gets a bare []. Demanding the envelope
|
|
496
|
+
// turned "no registries" into an error.
|
|
497
|
+
if (!Array.isArray(response) && response.status === 'KO') {
|
|
498
|
+
throw new Error(response.message || 'Failed to list Docker registries');
|
|
499
|
+
}
|
|
475
500
|
|
|
476
|
-
const registries = response.data?.registries || [];
|
|
501
|
+
const registries = Array.isArray(response) ? response : (response.data?.registries || []);
|
|
477
502
|
if (json) { outputJson(registries); return registries; }
|
|
478
503
|
if (registries.length === 0) { log('info', 'No Docker registries'); return []; }
|
|
479
504
|
|