elestio 1.2.0 → 1.2.3

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 CHANGED
@@ -355,16 +355,30 @@ domain.
355
355
  | Repo files the compose mounts | Unavailable | Available |
356
356
  | Works for | Templates that need no files from the repo | Every template |
357
357
 
358
- The compose route has no checkout, so a template whose `docker-compose.yml`
359
- bind-mounts a file from its repo cannot work: Docker creates the missing source
360
- as an empty directory and the container fails to start. The CLI detects this
361
- from the compose file and refuses upfront, naming the files, rather than
362
- letting the build fail a minute later with a `runc` error. `--force` overrides.
358
+ The compose route has no checkout and runs no install script, so a template
359
+ whose `docker-compose.yml` bind-mounts a file cannot work: Docker creates the
360
+ missing source as an empty directory and the container fails to start. The file
361
+ may ship in the template repo (n8n's `./n8n-task-runners.json`) or be generated
362
+ by an install script (Kafka writes its `./jaas/*.conf` in `preInstall`) - either
363
+ way it is absent here. The CLI detects this from the compose file and refuses
364
+ upfront, naming the files, rather than letting the build fail a minute later
365
+ with a `runc` error. `--force` overrides, and will most likely still fail.
366
+
367
+ Quoted mounts (`- "./.env:/app/.env"`) and the long `type: bind` syntax count
368
+ too; missing them is how Chromadb once deployed into a broken container.
363
369
 
364
370
  This affects more templates than you would expect - n8n
365
- (`./n8n-task-runners.json`), Rybbit (four files under `./configs/`) and
366
- WordPress (`./php.ini`) are all in this category. Vaultwarden, Redis and
367
- Metabase deploy cleanly on the compose route.
371
+ (`./n8n-task-runners.json`), Chromadb (`./.env`, `./overrides/system.js`),
372
+ Rybbit (four files under `./configs/`) and WordPress (`./php.ini`) are all in
373
+ this category. Vaultwarden, Redis and Metabase deploy cleanly on the compose
374
+ route.
375
+
376
+ **Every template also declares install scripts** (`preInstallCommand`,
377
+ `postInstallCommand`) that the compose route cannot run, and what they do
378
+ cannot be read from outside Elestio. Most software does not need them -
379
+ vaultwarden, redis and metabase were verified to deploy and run - so the CLI
380
+ warns and continues rather than refusing. If the software comes up without its
381
+ admin account or initial data, deploy it as a managed service instead.
368
382
 
369
383
  > **The git route is currently unavailable.** It needs
370
384
  > `POST /api/cicd/createRepoByTemplate`, which the Elestio API returns 404 for:
@@ -383,7 +397,8 @@ Metabase deploy cleanly on the compose route.
383
397
  | `elestio cicd pipeline-delete <vmID> <pipelineID> --force` | Delete pipeline |
384
398
  | `elestio cicd pipeline-resync <vmID> <pipelineID>` | Re-sync pipeline |
385
399
  | `elestio cicd pipeline-logs <vmID> <pipelineID>` | View pipeline logs |
386
- | `elestio cicd pipeline-history <vmID> <pipelineID>` | Build history |
400
+ | `elestio cicd pipeline-history <vmID> <pipelineID>` | Build history, with each build's log file |
401
+ | `elestio cicd pipeline-log <vmID> --pipeline <id> [--file <logID>]` | Open a build log (latest, or one from the history) |
387
402
  | `elestio cicd create --auto --target <vmID> --name X --repo owner/repo` | Pipeline from your own repo |
388
403
  | `elestio cicd create <config.json>` | Create from config file |
389
404
  | `elestio cicd template [mode]` | Generate config template |
@@ -407,7 +422,7 @@ Metabase deploy cleanly on the compose route.
407
422
  | `--build-cmd`, `--run-cmd`, `--install-cmd`, `--build-dir` | Override the values from `elestio.yml` (git route only) |
408
423
  | `--variables <KEY=VALUE...>` | Override the environment variables, newline-separated (git route only) |
409
424
  | `--force` | Deploy on the compose route even when the compose mounts a repo file |
410
- | `--dry-run` | Print the plan, create nothing |
425
+ | `--dry-run` | Print the plan, create nothing - including on the git route, which never creates the repository |
411
426
 
412
427
  #### Pipelines from your own repository
413
428
 
@@ -488,7 +503,8 @@ you to supply all of that yourself.
488
503
 
489
504
  **The software starts, then exits, or the build fails.**
490
505
  Check the build log: `elestio cicd pipeline-history <vmID> <pipelineID>`, then
491
- `elestio cicd pipeline-log <vmID> --pipeline <id> --file <log>`. On the compose
506
+ `elestio cicd pipeline-log <vmID> --pipeline <id> --file <logID>` (or without
507
+ `--file` for the latest build). It answers a temporary URL tailing the log. On the compose
492
508
  route the usual causes are a repo file the compose mounts (the CLI refuses
493
509
  these upfront unless you passed `--force`) or a template that genuinely needs
494
510
  its lifecycle scripts. Both need the git route, which is currently
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elestio",
3
- "version": "1.2.0",
3
+ "version": "1.2.3",
4
4
  "description": "Elestio CLI - Deploy and manage services on the Elestio DevOps platform",
5
5
  "type": "module",
6
6
  "main": "src/cli.js",
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') ||
@@ -4,6 +4,7 @@ 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
6
  import { buildGitPipelinePayload, buildComposePipelinePayload } 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
  /**
@@ -99,12 +100,9 @@ async function resolveCicdTarget(vmID, projectId) {
99
100
  };
100
101
  }
101
102
 
102
- async function findGitAuth(gitType, projectId) {
103
+ async function findGitAuth(gitType) {
103
104
  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;
105
+ return parseGitAuthId(response, gitType);
108
106
  }
109
107
 
110
108
  /**
@@ -211,7 +209,7 @@ export async function deployTemplate(nameOrId, options = {}) {
211
209
  log('success', `Config loaded: runtime "${elestioConfig.config.runTime}", ${elestioConfig.variables ? elestioConfig.variables.split('\n').length : 0} env vars, ${elestioConfig.ports.length} port(s)`);
212
210
 
213
211
  const payload = useGit
214
- ? await buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig })
212
+ ? await buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig, dryRun: !!options.dryRun })
215
213
  : await buildComposeRoute({ projectId, target, repoName, pipelineName, branch, sourceRepoUrl, elestioConfig, force: options.force });
216
214
 
217
215
  if (options.dryRun) {
@@ -239,10 +237,10 @@ export async function deployTemplate(nameOrId, options = {}) {
239
237
  return response;
240
238
  }
241
239
 
242
- async function buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig }) {
240
+ async function buildGitRoute({ options, projectId, target, template, repoName, pipelineName, branch, elestioConfig, dryRun = false }) {
243
241
  const gitType = (options.gitType || 'GITHUB').toUpperCase();
244
242
 
245
- let authID = options.authId ? String(options.authId) : await findGitAuth(gitType, projectId);
243
+ let authID = options.authId ? String(options.authId) : await findGitAuth(gitType);
246
244
  if (!authID) {
247
245
  throw new Error(
248
246
  `No ${gitType} account connected. Connect one in the dashboard (CI/CD > Git accounts), pass --auth-id, ` +
@@ -255,14 +253,21 @@ async function buildGitRoute({ options, projectId, target, template, repoName, p
255
253
  throw new Error('The Git account or organisation to create the repo in is required (--owner <user-or-org>)');
256
254
  }
257
255
 
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}`);
256
+ // A dry run must create nothing, and this step creates a repository in the
257
+ // user's Git account.
258
+ let repo = null;
259
+ if (dryRun) {
260
+ log('info', `Would create ${owner}/${repoName} from ${TEMPLATE_REPO_OWNER}/${repoName}`);
261
+ } else {
262
+ log('info', `Creating ${owner}/${repoName} from ${TEMPLATE_REPO_OWNER}/${repoName}...`);
263
+ repo = await generateRepoFromTemplate({
264
+ gitType, authID, owner, repoName,
265
+ templateName: repoName,
266
+ isPrivate: !!options.private,
267
+ isNonOrg: !!options.nonOrg
268
+ });
269
+ log('success', `Repo created: ${owner}/${repoName}`);
270
+ }
266
271
 
267
272
  return buildGitPipelinePayload({
268
273
  target,
@@ -291,16 +296,17 @@ async function buildComposeRoute({ projectId, target, repoName, pipelineName, br
291
296
  const fileMounts = repoFileMounts(compose);
292
297
  if (fileMounts.length > 0 && !force) {
293
298
  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.'
299
+ `"${repoName}" bind-mounts ${fileMounts.length} file(s) that will not exist on the compose route ` +
300
+ `(${fileMounts.join(', ')}): either they ship in the template repo, or its install scripts create them ` +
301
+ '(Kafka generates its jaas/ files in preInstall). This route has no checkout and runs no install ' +
302
+ 'script, so Docker creates each path as an empty directory and the container fails to start. The Git ' +
303
+ 'route would handle both, but the Elestio API does not expose it today: deploy this software from the ' +
304
+ 'dashboard, or as a managed service. --force deploys anyway, and will most likely fail.'
298
305
  );
299
306
  }
300
307
 
301
- if (hasLifecycleHooks(elestioConfig)) {
302
- log('warn', `"${repoName}" defines lifecycle scripts that need a repo checkout; they are skipped on the compose route.`);
303
- }
308
+ const hooks = installHooks(elestioConfig);
309
+ if (hooks.length > 0) log('warn', lifecycleWarning(repoName, hooks));
304
310
 
305
311
  return buildComposePipelinePayload({
306
312
  target, projectId, pipelineName, compose, elestioConfig
@@ -311,6 +317,25 @@ function hasLifecycleHooks(elestioConfig) {
311
317
  return Object.values(elestioConfig.lifeCycleCommand).some(v => v && v !== '');
312
318
  }
313
319
 
320
+ /**
321
+ * Install hooks live in the template repo, which the compose route never
322
+ * checks out, so they are skipped. Every catalog template declares them, and
323
+ * plenty deploy perfectly without them (vaultwarden, redis and metabase were
324
+ * verified live), so this reports rather than refuses: what the scripts
325
+ * actually do cannot be read, the repos are private to Elestio.
326
+ */
327
+ export function installHooks(elestioConfig) {
328
+ return Object.entries(elestioConfig.lifeCycleCommand || {})
329
+ .filter(([name, value]) => value && value !== '' && /^(pre|post)Install/.test(name))
330
+ .map(([name, value]) => `${name}=${value}`);
331
+ }
332
+
333
+ export function lifecycleWarning(repoName, hooks) {
334
+ return `"${repoName}" declares install scripts the compose route cannot run (${hooks.join(', ')}). ` +
335
+ 'Whatever they set up is skipped: an admin account, initial data or a sign-up lockdown may be missing. ' +
336
+ 'Check the software once deployed; if it needs them, deploy it as a managed service instead.';
337
+ }
338
+
314
339
  function collectOverrides(options) {
315
340
  const overrides = {};
316
341
  if (options.buildCmd !== undefined) overrides.buildCmd = options.buildCmd;
@@ -169,16 +169,70 @@ export async function getPipelineHistory(vmID, pipelineID, projectId, json = fal
169
169
  return history;
170
170
  }
171
171
 
172
- export async function viewPipelineLog(vmID, pipelineID, filepath, projectId) {
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
- vmID: String(vmID), projectID: String(pid), pipelineID: parseInt(pipelineID), filepath
179
- });
180
- if (response.status !== 'OK') throw new Error(response.message || 'Failed');
181
- console.log(response.data?.content || response.content || '');
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/registry.js CHANGED
@@ -654,7 +654,7 @@ export const registry = {
654
654
  '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
655
  '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
656
  '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: 'Print one build log file', usage: 'pipeline-log <vmID> --pipeline <id> --file <path>', async run({ args }) { await (await load.cicd()).viewPipelineLog(requireArg(args._[2], 'cicd pipeline-log <vmID> --pipeline <id> --file <path>'), args.pipeline, args.file, args.project); } },
657
+ '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
658
  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
659
  '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
660
  '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,22 @@ export function repoFileMounts(compose) {
179
179
  if (typeof compose !== 'string') return [];
180
180
 
181
181
  const mounts = new Set();
182
- for (const match of compose.matchAll(/^\s*-\s+(\.{1,2}\/[^\s:]+):[^\s]+/gm)) {
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
+ // Many templates quote their mounts (Chromadb among them), and the
189
+ // unquoted-only pattern let those through silently.
190
+ for (const match of compose.matchAll(/^\s*-\s+["']?(\.{1,2}\/[^\s:"']+)["']?\s*:/gm)) {
191
+ add(match[1]);
186
192
  }
193
+
194
+ // Long syntax: - type: bind / source: ./file / target: /in/container
195
+ for (const match of compose.matchAll(/^\s*source:\s*["']?(\.{1,2}\/[^\s"']+)["']?\s*$/gm)) {
196
+ add(match[1]);
197
+ }
198
+
187
199
  return [...mounts];
188
200
  }