elestio 1.2.0 → 1.3.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 +44 -21
- package/package.json +1 -1
- package/src/api.js +28 -0
- package/src/commands/cicd-template.js +92 -36
- package/src/commands/cicd.js +68 -6
- package/src/payloads/pipeline.js +50 -1
- package/src/registry.js +2 -1
- package/src/templates/elestio-config.js +26 -2
package/README.md
CHANGED
|
@@ -345,26 +345,47 @@ written as `random_password` are generated, `[EMAIL]` becomes your account
|
|
|
345
345
|
email, and `[CI_CD_DOMAIN]` is resolved by the platform once the pipeline has a
|
|
346
346
|
domain.
|
|
347
347
|
|
|
348
|
-
**
|
|
349
|
-
|
|
350
|
-
| |
|
|
351
|
-
|
|
352
|
-
| What it does | Inlines the template's `docker-compose.yml` | Generates the template repo into your Git account, then builds from it |
|
|
353
|
-
| Needs a Git account | No | Yes, connected in the dashboard |
|
|
354
|
-
|
|
|
355
|
-
|
|
|
356
|
-
| Works for | Templates that need no
|
|
357
|
-
|
|
358
|
-
The
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
348
|
+
**Three routes. The default needs nothing from you:**
|
|
349
|
+
|
|
350
|
+
| | repo (default) | inline (`--inline-compose`) | git (`--owner <git-user>`) |
|
|
351
|
+
|---|---|---|---|
|
|
352
|
+
| What it does | Points the pipeline at the `elestio-examples` repo, which Elestio clones on the VM | Inlines the template's `docker-compose.yml` only | Generates the template repo into your Git account, then builds from it |
|
|
353
|
+
| Needs a Git account | No | No | Yes, connected in the dashboard |
|
|
354
|
+
| Install scripts | **Run** | Skipped | Run |
|
|
355
|
+
| Files the compose mounts or reads | **Present** | Missing | Present |
|
|
356
|
+
| Works for | The whole catalogue | Templates that need no file from the repo | Every template |
|
|
357
|
+
|
|
358
|
+
The default route sends `imageData.dockerExample` (the template repo URL),
|
|
359
|
+
`branch` and `repoName`. The backend stores them as the pipeline's
|
|
360
|
+
`gitConfig.repoURL`, and the deployment agent clones that repo with Elestio's
|
|
361
|
+
own Git token - the same thing the dashboard does. Nothing is copied into your
|
|
362
|
+
account, and no Git account is needed.
|
|
363
|
+
|
|
364
|
+
`--inline-compose` sends only the compose file, which is what you want for a
|
|
365
|
+
compose of your own. That route has no checkout and runs no install script, so a template
|
|
366
|
+
whose `docker-compose.yml` bind-mounts a file cannot work: Docker creates the
|
|
367
|
+
missing source as an empty directory and the container fails to start. The file
|
|
368
|
+
may ship in the template repo (n8n's `./n8n-task-runners.json`) or be generated
|
|
369
|
+
by an install script (Kafka writes its `./jaas/*.conf` in `preInstall`) - either
|
|
370
|
+
way it is absent here. The CLI detects this from the compose file and refuses
|
|
371
|
+
upfront, naming the files, rather than letting the build fail a minute later
|
|
372
|
+
with a `runc` error. `--force` overrides, and will most likely still fail.
|
|
373
|
+
|
|
374
|
+
Quoted mounts (`- "./.env:/app/.env"`) and the long `type: bind` syntax count
|
|
375
|
+
too; missing them is how Chromadb once deployed into a broken container.
|
|
363
376
|
|
|
364
377
|
This affects more templates than you would expect - n8n
|
|
365
|
-
(`./n8n-task-runners.json`),
|
|
366
|
-
WordPress (`./php.ini`) are all in
|
|
367
|
-
Metabase deploy cleanly on the compose
|
|
378
|
+
(`./n8n-task-runners.json`), Chromadb (`./.env`, `./overrides/system.js`),
|
|
379
|
+
Rybbit (four files under `./configs/`) and WordPress (`./php.ini`) are all in
|
|
380
|
+
this category. Vaultwarden, Redis and Metabase deploy cleanly on the compose
|
|
381
|
+
route.
|
|
382
|
+
|
|
383
|
+
**Every template also declares install scripts** (`preInstallCommand`,
|
|
384
|
+
`postInstallCommand`) that the compose route cannot run, and what they do
|
|
385
|
+
cannot be read from outside Elestio. Most software does not need them -
|
|
386
|
+
vaultwarden, redis and metabase were verified to deploy and run - so the CLI
|
|
387
|
+
warns and continues rather than refusing. If the software comes up without its
|
|
388
|
+
admin account or initial data, deploy it as a managed service instead.
|
|
368
389
|
|
|
369
390
|
> **The git route is currently unavailable.** It needs
|
|
370
391
|
> `POST /api/cicd/createRepoByTemplate`, which the Elestio API returns 404 for:
|
|
@@ -383,7 +404,8 @@ Metabase deploy cleanly on the compose route.
|
|
|
383
404
|
| `elestio cicd pipeline-delete <vmID> <pipelineID> --force` | Delete pipeline |
|
|
384
405
|
| `elestio cicd pipeline-resync <vmID> <pipelineID>` | Re-sync pipeline |
|
|
385
406
|
| `elestio cicd pipeline-logs <vmID> <pipelineID>` | View pipeline logs |
|
|
386
|
-
| `elestio cicd pipeline-history <vmID> <pipelineID>` | Build history |
|
|
407
|
+
| `elestio cicd pipeline-history <vmID> <pipelineID>` | Build history, with each build's log file |
|
|
408
|
+
| `elestio cicd pipeline-log <vmID> --pipeline <id> [--file <logID>]` | Open a build log (latest, or one from the history) |
|
|
387
409
|
| `elestio cicd create --auto --target <vmID> --name X --repo owner/repo` | Pipeline from your own repo |
|
|
388
410
|
| `elestio cicd create <config.json>` | Create from config file |
|
|
389
411
|
| `elestio cicd template [mode]` | Generate config template |
|
|
@@ -407,7 +429,7 @@ Metabase deploy cleanly on the compose route.
|
|
|
407
429
|
| `--build-cmd`, `--run-cmd`, `--install-cmd`, `--build-dir` | Override the values from `elestio.yml` (git route only) |
|
|
408
430
|
| `--variables <KEY=VALUE...>` | Override the environment variables, newline-separated (git route only) |
|
|
409
431
|
| `--force` | Deploy on the compose route even when the compose mounts a repo file |
|
|
410
|
-
| `--dry-run` | Print the plan, create nothing |
|
|
432
|
+
| `--dry-run` | Print the plan, create nothing - including on the git route, which never creates the repository |
|
|
411
433
|
|
|
412
434
|
#### Pipelines from your own repository
|
|
413
435
|
|
|
@@ -488,7 +510,8 @@ you to supply all of that yourself.
|
|
|
488
510
|
|
|
489
511
|
**The software starts, then exits, or the build fails.**
|
|
490
512
|
Check the build log: `elestio cicd pipeline-history <vmID> <pipelineID>`, then
|
|
491
|
-
`elestio cicd pipeline-log <vmID> --pipeline <id> --file <
|
|
513
|
+
`elestio cicd pipeline-log <vmID> --pipeline <id> --file <logID>` (or without
|
|
514
|
+
`--file` for the latest build). It answers a temporary URL tailing the log. On the compose
|
|
492
515
|
route the usual causes are a repo file the compose mounts (the CLI refuses
|
|
493
516
|
these upfront unless you passed `--force`) or a template that genuinely needs
|
|
494
517
|
its lifecycle scripts. Both need the git route, which is currently
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -50,6 +50,28 @@ async function parseJson(response, endpoint) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The API throttles bursts and then answers "Access temporarily restricted"
|
|
55
|
+
* for a while, on authentication as well as on any endpoint. Saying so plainly
|
|
56
|
+
* matters: it reads like a credentials or permissions problem otherwise, and a
|
|
57
|
+
* script that treats it as "no data" silently produces wrong results.
|
|
58
|
+
*/
|
|
59
|
+
export function rateLimitMessage(message) {
|
|
60
|
+
if (!/temporarily restricted|too many requests|rate limit/i.test(String(message ?? ''))) return null;
|
|
61
|
+
return 'The Elestio API has temporarily restricted this account for sending too many requests. ' +
|
|
62
|
+
'Wait a few minutes before retrying, and space out bulk loops (one request at a time). ' +
|
|
63
|
+
`API said: "${String(message).trim()}"`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function raiseIfRateLimited(data) {
|
|
67
|
+
const message = rateLimitMessage(data?.message);
|
|
68
|
+
if (message) {
|
|
69
|
+
const error = new Error(message);
|
|
70
|
+
error.rateLimited = true;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
async function authenticate(email, token) {
|
|
54
76
|
const response = await httpRequest(`${BASE_URL}/api/auth/checkAPIToken`, {
|
|
55
77
|
method: 'POST',
|
|
@@ -59,6 +81,8 @@ async function authenticate(email, token) {
|
|
|
59
81
|
|
|
60
82
|
const data = await parseJson(response, '/api/auth/checkAPIToken');
|
|
61
83
|
|
|
84
|
+
raiseIfRateLimited(data);
|
|
85
|
+
|
|
62
86
|
if (data.status !== 'OK' || !data.jwt) {
|
|
63
87
|
throw new Error(data.message || 'Authentication failed');
|
|
64
88
|
}
|
|
@@ -118,6 +142,10 @@ export async function apiRequest(endpoint, method = 'POST', body = {}, retried =
|
|
|
118
142
|
|
|
119
143
|
const data = await parseJson(response, endpoint);
|
|
120
144
|
|
|
145
|
+
// Before the auth check: a throttled account answers like a bad token, and
|
|
146
|
+
// clearing the JWT then re-authenticating only adds requests.
|
|
147
|
+
raiseIfRateLimited(data);
|
|
148
|
+
|
|
121
149
|
const isAuthError = !retried && (
|
|
122
150
|
(data.status === 'error' && data.message?.toLowerCase().includes('auth')) ||
|
|
123
151
|
(data.code === 'InvalidToken') ||
|
|
@@ -3,7 +3,8 @@ import { loadConfig } from '../config.js';
|
|
|
3
3
|
import { log, colors, outputJson, formatTable } from '../utils.js';
|
|
4
4
|
import { getTemplates } from './templates.js';
|
|
5
5
|
import { createSubstitutions, normalizeElestioConfig, repoFileMounts } from '../templates/elestio-config.js';
|
|
6
|
-
import { buildGitPipelinePayload, buildComposePipelinePayload } from '../payloads/pipeline.js';
|
|
6
|
+
import { buildGitPipelinePayload, buildComposePipelinePayload, buildTemplateRepoPipelinePayload } from '../payloads/pipeline.js';
|
|
7
|
+
import { parseGitAuthId } from './cicd.js';
|
|
7
8
|
import { TEMPLATE_REPO_OWNER, TEMPLATE_REPO_HOST } from '../constants.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -15,11 +16,18 @@ import { TEMPLATE_REPO_OWNER, TEMPLATE_REPO_HOST } from '../constants.js';
|
|
|
15
16
|
* produces a pipeline that starts nothing, which is why this is a dedicated
|
|
16
17
|
* command rather than a flag on `cicd create`.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* Three routes:
|
|
20
|
+
* repo (default) - point the pipeline at the elestio-examples repo itself.
|
|
21
|
+
* Elestio clones it on the VM with its own Git token, so the repo's
|
|
22
|
+
* files exist and its install scripts run. No Git account needed,
|
|
23
|
+
* nothing copied into the user's account. This is what the
|
|
24
|
+
* dashboard does.
|
|
25
|
+
* inline - inline the docker-compose.yml only (--inline-compose). No
|
|
26
|
+
* checkout: install scripts are skipped and any file the compose
|
|
27
|
+
* mounts or reads (env_file) is missing. Half the catalogue cannot
|
|
28
|
+
* work this way.
|
|
29
|
+
* git - generate the template repo into the user's own Git account
|
|
30
|
+
* (--owner). Needs an endpoint the API does not expose (404).
|
|
23
31
|
*/
|
|
24
32
|
|
|
25
33
|
function templateRepoUrl(repoName) {
|
|
@@ -99,12 +107,9 @@ async function resolveCicdTarget(vmID, projectId) {
|
|
|
99
107
|
};
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
async function findGitAuth(gitType
|
|
110
|
+
async function findGitAuth(gitType) {
|
|
103
111
|
const response = await apiRequest('/api/cicd/getCICDUserAuthAccount', 'POST', {});
|
|
104
|
-
|
|
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;
|
|
112
|
+
return parseGitAuthId(response, gitType);
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
/**
|
|
@@ -210,13 +215,16 @@ export async function deployTemplate(nameOrId, options = {}) {
|
|
|
210
215
|
}
|
|
211
216
|
log('success', `Config loaded: runtime "${elestioConfig.config.runTime}", ${elestioConfig.variables ? elestioConfig.variables.split('\n').length : 0} env vars, ${elestioConfig.ports.length} port(s)`);
|
|
212
217
|
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
218
|
+
const route = useGit ? 'git' : (options.inlineCompose ? 'inline' : 'repo');
|
|
219
|
+
const payload = route === 'git'
|
|
220
|
+
? await buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig, dryRun: !!options.dryRun })
|
|
221
|
+
: route === 'inline'
|
|
222
|
+
? await buildComposeRoute({ projectId, target, repoName, pipelineName, branch, sourceRepoUrl, elestioConfig, force: options.force })
|
|
223
|
+
: await buildTemplateRepoRoute({ projectId, target, repoName, pipelineName, branch, sourceRepoUrl, elestioConfig });
|
|
216
224
|
|
|
217
225
|
if (options.dryRun) {
|
|
218
|
-
if (options.json) { outputJson({ dryRun: true, route
|
|
219
|
-
printPlan({ template, repoName, pipelineName, target, elestioConfig,
|
|
226
|
+
if (options.json) { outputJson({ dryRun: true, route, payload }); return payload; }
|
|
227
|
+
printPlan({ template, repoName, pipelineName, target, elestioConfig, route, branch });
|
|
220
228
|
log('info', 'To deploy, run the same command without --dry-run');
|
|
221
229
|
return payload;
|
|
222
230
|
}
|
|
@@ -231,7 +239,7 @@ export async function deployTemplate(nameOrId, options = {}) {
|
|
|
231
239
|
log('success', `Pipeline "${pipelineName}" created on ${target.displayName}`);
|
|
232
240
|
|
|
233
241
|
if (options.json) {
|
|
234
|
-
outputJson({ status: 'OK', pipelineName, target: target.vmID, route
|
|
242
|
+
outputJson({ status: 'OK', pipelineName, target: target.vmID, route, webUI: elestioConfig.webUI, response });
|
|
235
243
|
return response;
|
|
236
244
|
}
|
|
237
245
|
|
|
@@ -239,10 +247,10 @@ export async function deployTemplate(nameOrId, options = {}) {
|
|
|
239
247
|
return response;
|
|
240
248
|
}
|
|
241
249
|
|
|
242
|
-
async function buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig }) {
|
|
250
|
+
async function buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig, dryRun = false }) {
|
|
243
251
|
const gitType = (options.gitType || 'GITHUB').toUpperCase();
|
|
244
252
|
|
|
245
|
-
let authID = options.authId ? String(options.authId) : await findGitAuth(gitType
|
|
253
|
+
let authID = options.authId ? String(options.authId) : await findGitAuth(gitType);
|
|
246
254
|
if (!authID) {
|
|
247
255
|
throw new Error(
|
|
248
256
|
`No ${gitType} account connected. Connect one in the dashboard (CI/CD > Git accounts), pass --auth-id, ` +
|
|
@@ -255,14 +263,21 @@ async function buildGitRoute({ options, projectId, target, template, repoName, p
|
|
|
255
263
|
throw new Error('The Git account or organisation to create the repo in is required (--owner <user-or-org>)');
|
|
256
264
|
}
|
|
257
265
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
+
// A dry run must create nothing, and this step creates a repository in the
|
|
267
|
+
// user's Git account.
|
|
268
|
+
let repo = null;
|
|
269
|
+
if (dryRun) {
|
|
270
|
+
log('info', `Would create ${owner}/${repoName} from ${TEMPLATE_REPO_OWNER}/${repoName}`);
|
|
271
|
+
} else {
|
|
272
|
+
log('info', `Creating ${owner}/${repoName} from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
|
|
273
|
+
repo = await generateRepoFromTemplate({
|
|
274
|
+
gitType, authID, owner, repoName,
|
|
275
|
+
templateName: repoName,
|
|
276
|
+
isPrivate: !!options.private,
|
|
277
|
+
isNonOrg: !!options.nonOrg
|
|
278
|
+
});
|
|
279
|
+
log('success', `Repo created: ${owner}/${repoName}`);
|
|
280
|
+
}
|
|
266
281
|
|
|
267
282
|
return buildGitPipelinePayload({
|
|
268
283
|
target,
|
|
@@ -281,6 +296,22 @@ async function buildGitRoute({ options, projectId, target, template, repoName, p
|
|
|
281
296
|
});
|
|
282
297
|
}
|
|
283
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Default route: the pipeline points at the template repo, which Elestio clones
|
|
301
|
+
* on the VM. Nothing to refuse -- the repo's files are there and its install
|
|
302
|
+
* scripts run.
|
|
303
|
+
*/
|
|
304
|
+
async function buildTemplateRepoRoute({ projectId, target, repoName, pipelineName, branch, sourceRepoUrl, elestioConfig }) {
|
|
305
|
+
log('info', `Fetching docker-compose from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
|
|
306
|
+
const compose = await fetchCompose(sourceRepoUrl, branch, projectId);
|
|
307
|
+
log('success', 'Compose file loaded');
|
|
308
|
+
|
|
309
|
+
const hooks = installHooks(elestioConfig);
|
|
310
|
+
if (hooks.length > 0) log('info', `Install scripts run from the cloned repo: ${hooks.join(', ')}`);
|
|
311
|
+
|
|
312
|
+
return buildTemplateRepoPipelinePayload({ target, projectId, pipelineName, compose, repoName, branch, elestioConfig });
|
|
313
|
+
}
|
|
314
|
+
|
|
284
315
|
async function buildComposeRoute({ projectId, target, repoName, pipelineName, branch, sourceRepoUrl, elestioConfig, force }) {
|
|
285
316
|
log('info', `Fetching docker-compose from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
|
|
286
317
|
const compose = await fetchCompose(sourceRepoUrl, branch, projectId);
|
|
@@ -291,16 +322,17 @@ async function buildComposeRoute({ projectId, target, repoName, pipelineName, br
|
|
|
291
322
|
const fileMounts = repoFileMounts(compose);
|
|
292
323
|
if (fileMounts.length > 0 && !force) {
|
|
293
324
|
throw new Error(
|
|
294
|
-
`"${repoName}" bind-mounts ${fileMounts.length} file(s) that
|
|
295
|
-
`(${fileMounts.join(', ')})
|
|
296
|
-
'
|
|
297
|
-
'
|
|
325
|
+
`"${repoName}" bind-mounts ${fileMounts.length} file(s) that will not exist on the compose route ` +
|
|
326
|
+
`(${fileMounts.join(', ')}): either they ship in the template repo, or its install scripts create them ` +
|
|
327
|
+
'(Kafka generates its jaas/ files in preInstall). This route has no checkout and runs no install ' +
|
|
328
|
+
'script, so Docker creates each path as an empty directory and the container fails to start. The Git ' +
|
|
329
|
+
'route would handle both, but the Elestio API does not expose it today: deploy this software from the ' +
|
|
330
|
+
'dashboard, or as a managed service. --force deploys anyway, and will most likely fail.'
|
|
298
331
|
);
|
|
299
332
|
}
|
|
300
333
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
334
|
+
const hooks = installHooks(elestioConfig);
|
|
335
|
+
if (hooks.length > 0) log('warn', lifecycleWarning(repoName, hooks));
|
|
304
336
|
|
|
305
337
|
return buildComposePipelinePayload({
|
|
306
338
|
target, projectId, pipelineName, compose, elestioConfig
|
|
@@ -311,6 +343,25 @@ function hasLifecycleHooks(elestioConfig) {
|
|
|
311
343
|
return Object.values(elestioConfig.lifeCycleCommand).some(v => v && v !== '');
|
|
312
344
|
}
|
|
313
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Install hooks live in the template repo, which the compose route never
|
|
348
|
+
* checks out, so they are skipped. Every catalog template declares them, and
|
|
349
|
+
* plenty deploy perfectly without them (vaultwarden, redis and metabase were
|
|
350
|
+
* verified live), so this reports rather than refuses: what the scripts
|
|
351
|
+
* actually do cannot be read, the repos are private to Elestio.
|
|
352
|
+
*/
|
|
353
|
+
export function installHooks(elestioConfig) {
|
|
354
|
+
return Object.entries(elestioConfig.lifeCycleCommand || {})
|
|
355
|
+
.filter(([name, value]) => value && value !== '' && /^(pre|post)Install/.test(name))
|
|
356
|
+
.map(([name, value]) => `${name}=${value}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function lifecycleWarning(repoName, hooks) {
|
|
360
|
+
return `"${repoName}" declares install scripts the compose route cannot run (${hooks.join(', ')}). ` +
|
|
361
|
+
'Whatever they set up is skipped: an admin account, initial data or a sign-up lockdown may be missing. ' +
|
|
362
|
+
'Check the software once deployed; if it needs them, deploy it as a managed service instead.';
|
|
363
|
+
}
|
|
364
|
+
|
|
314
365
|
function collectOverrides(options) {
|
|
315
366
|
const overrides = {};
|
|
316
367
|
if (options.buildCmd !== undefined) overrides.buildCmd = options.buildCmd;
|
|
@@ -321,13 +372,18 @@ function collectOverrides(options) {
|
|
|
321
372
|
return overrides;
|
|
322
373
|
}
|
|
323
374
|
|
|
324
|
-
function printPlan({ template, repoName, pipelineName, target, elestioConfig,
|
|
375
|
+
function printPlan({ template, repoName, pipelineName, target, elestioConfig, route, branch }) {
|
|
325
376
|
console.log(`\n${colors.bold}Pipeline Preview (--dry-run)${colors.reset}\n`);
|
|
326
377
|
console.log(` Software: ${colors.cyan}${template.title}${colors.reset} (ID: ${template.id})`);
|
|
327
378
|
console.log(` Template: ${TEMPLATE_REPO_OWNER}/${repoName} @ ${branch}`);
|
|
328
379
|
console.log(` Pipeline: ${pipelineName}`);
|
|
329
380
|
console.log(` Target: ${target.displayName} (${target.vmID})`);
|
|
330
|
-
|
|
381
|
+
const routes = {
|
|
382
|
+
repo: 'repo (Elestio clones the template repo on the VM, install scripts run)',
|
|
383
|
+
inline: 'inline compose (no checkout: install scripts skipped, mounted files missing)',
|
|
384
|
+
git: 'git (repo generated in your account, install scripts run)'
|
|
385
|
+
};
|
|
386
|
+
console.log(` Route: ${routes[route]}`);
|
|
331
387
|
console.log(` Runtime: ${elestioConfig.config.runTime || 'N/A'}`);
|
|
332
388
|
console.log(` Ports: ${elestioConfig.ports.map(p => `${p.listeningPort}->${p.targetPort}`).join(', ') || 'default'}`);
|
|
333
389
|
console.log(` Env vars: ${elestioConfig.variables ? elestioConfig.variables.split('\n').map(v => v.split('=')[0]).join(', ') : 'none'}`);
|
package/src/commands/cicd.js
CHANGED
|
@@ -169,16 +169,70 @@ export async function getPipelineHistory(vmID, pipelineID, projectId, json = fal
|
|
|
169
169
|
return history;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
/**
|
|
173
|
+
* getCICDUserAuthAccount answers an object keyed by provider:
|
|
174
|
+
* {data: {GITHUB: [{name, value, type}], GITLAB: [], GITLAB_SELF_HOSTED: []}}
|
|
175
|
+
* The auth ID is the account's `value`. Reading it as an array found nothing,
|
|
176
|
+
* so the git route reported no connected account even when there was one.
|
|
177
|
+
*/
|
|
178
|
+
export function parseGitAuthId(response, gitType) {
|
|
179
|
+
const data = response?.data ?? response ?? {};
|
|
180
|
+
|
|
181
|
+
const byProvider = data[gitType];
|
|
182
|
+
if (Array.isArray(byProvider) && byProvider.length > 0) {
|
|
183
|
+
const account = byProvider[0];
|
|
184
|
+
const id = account.value ?? account.id ?? account.authID;
|
|
185
|
+
return id === undefined || id === null ? null : String(id);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Older shape: a flat list of accounts carrying their provider.
|
|
189
|
+
const flat = Array.isArray(data) ? data : (Array.isArray(data.accounts) ? data.accounts : null);
|
|
190
|
+
if (flat) {
|
|
191
|
+
const match = flat.find(a => (a.externalProviderName || a.gitType || a.type) === gitType);
|
|
192
|
+
if (match) {
|
|
193
|
+
const id = match.value ?? match.id ?? match.authID;
|
|
194
|
+
return id === undefined || id === null ? null : String(id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* viewPipelineLog requires isLatest, and spells the file `filePath`. Sending
|
|
203
|
+
* `filepath` without isLatest was rejected as a missing parameter, so no build
|
|
204
|
+
* log could be read. The file name is the logID from pipeline-history; it is
|
|
205
|
+
* resolved inside the pipeline's own log directory, so a full path is trimmed.
|
|
206
|
+
*/
|
|
207
|
+
export function buildViewLogPayload(vmID, pipelineID, projectId, file = null) {
|
|
208
|
+
const payload = {
|
|
209
|
+
vmID: String(vmID),
|
|
210
|
+
projectID: String(projectId),
|
|
211
|
+
pipelineID: parseInt(pipelineID),
|
|
212
|
+
isLatest: !file
|
|
213
|
+
};
|
|
214
|
+
if (file) payload.filePath = String(file).slice(String(file).lastIndexOf('/') + 1);
|
|
215
|
+
return payload;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Opens a temporary web view tailing one build log, like the dashboard does.
|
|
220
|
+
* The endpoint answers a URL, not the log's content.
|
|
221
|
+
*/
|
|
222
|
+
export async function viewPipelineLog(vmID, pipelineID, filepath, projectId, json = false) {
|
|
173
223
|
const config = loadConfig();
|
|
174
224
|
const pid = projectId || config.defaultProject;
|
|
175
225
|
if (!pid) throw new Error('Project ID required');
|
|
226
|
+
if (!pipelineID) throw new Error('Pipeline ID required (--pipeline <id>)');
|
|
176
227
|
|
|
177
|
-
const response = await apiRequest('/api/cicd/viewPipelineLog', 'POST',
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
|
|
228
|
+
const response = await apiRequest('/api/cicd/viewPipelineLog', 'POST', buildViewLogPayload(vmID, pipelineID, pid, filepath));
|
|
229
|
+
if (!response.url) throw new Error(response.message || response.details || 'Failed to open the build log');
|
|
230
|
+
|
|
231
|
+
if (json) { outputJson({ url: response.url }); return response; }
|
|
232
|
+
|
|
233
|
+
console.log(`\n${colors.bold}Build log${filepath ? ` (${filepath})` : ' (latest)'}${colors.reset}\n`);
|
|
234
|
+
console.log(` URL: ${colors.cyan}${response.url}${colors.reset}`);
|
|
235
|
+
console.log(`\n ${colors.dim}Temporary page tailing the log on the target.${colors.reset}\n`);
|
|
182
236
|
return response;
|
|
183
237
|
}
|
|
184
238
|
|
|
@@ -238,6 +292,14 @@ async function findGitAuthID(gitType, projectId) {
|
|
|
238
292
|
const config = loadConfig();
|
|
239
293
|
const pid = projectId || config.defaultProject;
|
|
240
294
|
|
|
295
|
+
// The connected accounts are the authoritative source; the scan below only
|
|
296
|
+
// covers accounts that are not listed but are in use by a pipeline.
|
|
297
|
+
try {
|
|
298
|
+
const accounts = await apiRequest('/api/cicd/getCICDUserAuthAccount', 'POST', {});
|
|
299
|
+
const authID = parseGitAuthId(accounts, gitType);
|
|
300
|
+
if (authID) return authID;
|
|
301
|
+
} catch { /* fall through to the scan */ }
|
|
302
|
+
|
|
241
303
|
try {
|
|
242
304
|
const cicdResp = await apiRequest('/api/cicd/getCICDServices', 'POST', { projectID: String(pid) });
|
|
243
305
|
const cicdServices = Array.isArray(cicdResp) ? cicdResp : (cicdResp.data?.services || []);
|
package/src/payloads/pipeline.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RUNTIME_PRESETS } from '../constants.js';
|
|
1
|
+
import { RUNTIME_PRESETS, TEMPLATE_REPO_OWNER, TEMPLATE_REPO_HOST } from '../constants.js';
|
|
2
2
|
import { EMPTY_LIFECYCLE } from '../templates/elestio-config.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -147,6 +147,55 @@ export function buildGitPipelinePayload({
|
|
|
147
147
|
});
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Pipeline running catalog software from its own template repo.
|
|
152
|
+
*
|
|
153
|
+
* This is what the dashboard sends: `imageData.dockerExample` carries the
|
|
154
|
+
* elestio-examples URL, and the backend then stores it as the pipeline's
|
|
155
|
+
* gitConfig.repoURL (createCiCdExistServer). The deployment agent clones that
|
|
156
|
+
* repo on the VM with Elestio's own Git token, so the repo's files exist and
|
|
157
|
+
* its lifecycle scripts run -- without copying anything into the user's Git
|
|
158
|
+
* account, and without needing a connected Git account at all.
|
|
159
|
+
*
|
|
160
|
+
* The inline route below cannot do either, which is why half the catalogue
|
|
161
|
+
* (Kafka, Chromadb, Weaviate, ClickHouse, WordPress...) is undeployable
|
|
162
|
+
* through it.
|
|
163
|
+
*/
|
|
164
|
+
export function buildTemplateRepoPipelinePayload({
|
|
165
|
+
target, projectId, pipelineName, compose, repoName, branch = 'main', elestioConfig, overrides = {}
|
|
166
|
+
}) {
|
|
167
|
+
if (!compose) throw new Error('A docker-compose file is required');
|
|
168
|
+
if (!repoName) throw new Error('A template repo name is required');
|
|
169
|
+
const fromFile = elestioConfig && elestioConfig.hasConfig ? elestioConfig : null;
|
|
170
|
+
|
|
171
|
+
return buildPipelinePayload({
|
|
172
|
+
target, projectId, pipelineName,
|
|
173
|
+
cicdMode: 'DockerCompose',
|
|
174
|
+
gitData: {},
|
|
175
|
+
imageData: {
|
|
176
|
+
isPrivate: false,
|
|
177
|
+
compose,
|
|
178
|
+
// The three fields that turn this into a cloned-repo pipeline.
|
|
179
|
+
dockerExample: `${TEMPLATE_REPO_HOST}/${TEMPLATE_REPO_OWNER}/${repoName}`,
|
|
180
|
+
branch,
|
|
181
|
+
repoName
|
|
182
|
+
},
|
|
183
|
+
configData: {
|
|
184
|
+
runTime: 'NodeJs', framework: 'NoFramework', version: '20',
|
|
185
|
+
buildDir: '/', rootDir: '/', buildCmd: '', runCmd: '', installCmd: ''
|
|
186
|
+
},
|
|
187
|
+
ports: overrides.ports || fromFile?.ports,
|
|
188
|
+
exposedPorts: overrides.exposedPorts || fromFile?.exposedPorts,
|
|
189
|
+
variables: overrides.variables ?? fromFile?.variables ?? '',
|
|
190
|
+
// Kept, unlike the inline route: the repo is checked out, so they can run.
|
|
191
|
+
lifeCycleCommand: fromFile?.lifeCycleCommand,
|
|
192
|
+
copyCommandConfig: fromFile?.copyCommandConfig || [],
|
|
193
|
+
authID: null,
|
|
194
|
+
isPublicGitRepo: false,
|
|
195
|
+
isNeedToCreateRepo: false
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
150
199
|
/**
|
|
151
200
|
* Pipeline running an inline docker-compose file, with no Git repo attached.
|
|
152
201
|
*
|
package/src/registry.js
CHANGED
|
@@ -632,6 +632,7 @@ export const registry = {
|
|
|
632
632
|
owner: args.owner, branch: args.branch, repoName: args['repo-name'],
|
|
633
633
|
gitType: args['git-type'], authId: args['auth-id'],
|
|
634
634
|
git: !args['no-git'] && (args.git === true || args.git === 'true' || !!args.owner),
|
|
635
|
+
inlineCompose: !!args['inline-compose'],
|
|
635
636
|
private: !!args.private, nonOrg: !!args['non-org'],
|
|
636
637
|
buildCmd: args['build-cmd'], runCmd: args['run-cmd'],
|
|
637
638
|
installCmd: args['install-cmd'], buildDir: args['build-dir'],
|
|
@@ -654,7 +655,7 @@ export const registry = {
|
|
|
654
655
|
'pipeline-resync': { summary: 'Re-sync a pipeline', usage: 'pipeline-resync <vmID> <pipelineID>', async run({ args }) { await (await load.cicd()).resyncPipeline(requireArg(args._[2], 'cicd pipeline-resync <vmID> <pipelineID>'), positional(args, 3, 'pipeline'), args.project); } },
|
|
655
656
|
'pipeline-logs': { summary: 'Tail the running pipeline logs', usage: 'pipeline-logs <vmID> <pipelineID>', async run({ args }) { await (await load.cicd()).getPipelineLogs(requireArg(args._[2], 'cicd pipeline-logs <vmID> <pipelineID>'), positional(args, 3, 'pipeline'), args.project); } },
|
|
656
657
|
'pipeline-history': { summary: 'Show the build history', usage: 'pipeline-history <vmID> <pipelineID>', async run({ args, json }) { await (await load.cicd()).getPipelineHistory(requireArg(args._[2], 'cicd pipeline-history <vmID> <pipelineID>'), positional(args, 3, 'pipeline'), args.project, json); } },
|
|
657
|
-
'pipeline-log': { summary: '
|
|
658
|
+
'pipeline-log': { summary: 'Open a build log (latest, or --file <logID> from pipeline-history)', usage: 'pipeline-log <vmID> --pipeline <id> [--file <logID>]', async run({ args, json }) { await (await load.cicd()).viewPipelineLog(requireArg(args._[2], 'cicd pipeline-log <vmID> --pipeline <id> [--file <logID>]'), args.pipeline, args.file, args.project, json); } },
|
|
658
659
|
domains: { summary: 'List pipeline domains', usage: 'domains <vmID> <pipelineID>', async run({ args, json }) { await (await load.cicd()).listPipelineDomains(requireArg(args._[2], 'cicd domains <vmID> <pipelineID>'), positional(args, 3, 'pipeline'), args.project, json); } },
|
|
659
660
|
'domain-add': { summary: 'Add a pipeline domain', usage: 'domain-add <vmID> --pipeline <id> --domain <d>', async run({ args }) { await (await load.cicd()).addPipelineDomain(requireArg(args._[2], 'cicd domain-add <vmID> --pipeline <id> --domain <d>'), args.pipeline, args.domain || args._[3], args.project); } },
|
|
660
661
|
'domain-remove': { summary: 'Remove a pipeline domain', usage: 'domain-remove <vmID> --pipeline <id> --domain <d>', async run({ args }) { await (await load.cicd()).removePipelineDomain(requireArg(args._[2], 'cicd domain-remove <vmID> --pipeline <id> --domain <d>'), args.pipeline, args.domain || args._[3], args.project); } },
|
|
@@ -179,10 +179,34 @@ export function repoFileMounts(compose) {
|
|
|
179
179
|
if (typeof compose !== 'string') return [];
|
|
180
180
|
|
|
181
181
|
const mounts = new Set();
|
|
182
|
-
|
|
183
|
-
const source = match[1];
|
|
182
|
+
const add = (source) => {
|
|
184
183
|
const lastSegment = source.slice(source.lastIndexOf('/') + 1);
|
|
185
184
|
if (/\.[A-Za-z0-9]{1,10}$/.test(lastSegment)) mounts.add(source);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Short syntax, quoted or not: - ./file:/in/container[:ro]
|
|
188
|
+
for (const match of compose.matchAll(/^\s*-\s+["']?(\.{1,2}\/[^\s:"']+)["']?\s*:/gm)) {
|
|
189
|
+
add(match[1]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Long syntax: - type: bind / source: ./file / target: /in/container
|
|
193
|
+
for (const match of compose.matchAll(/^\s*source:\s*["']?(\.{1,2}\/[^\s"']+)["']?\s*$/gm)) {
|
|
194
|
+
add(match[1]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// env_file, inline or as a list. Docker refuses to start when the file is
|
|
198
|
+
// missing, and 8 of the 22 catalogue templates sampled read one.
|
|
199
|
+
for (const match of compose.matchAll(/^\s*env_file:\s*(?:\[([^\]]*)\]|["']?([^\s"'\[]+)["']?)\s*$/gm)) {
|
|
200
|
+
for (const entry of (match[1] ?? match[2] ?? '').split(',')) {
|
|
201
|
+
const file = entry.trim().replace(/^["']|["']$/g, '');
|
|
202
|
+
if (file.startsWith('.')) add(file);
|
|
203
|
+
}
|
|
186
204
|
}
|
|
205
|
+
for (const block of compose.matchAll(/^\s*env_file:\s*$((?:\n\s*-\s+.*)+)/gm)) {
|
|
206
|
+
for (const item of block[1].matchAll(/^\s*-\s+["']?([^\s"']+)["']?\s*$/gm)) {
|
|
207
|
+
if (item[1].startsWith('.')) add(item[1]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
187
211
|
return [...mounts];
|
|
188
212
|
}
|