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 CHANGED
@@ -29,6 +29,18 @@ elestio deploy PostgreSQL --project 12345 --name my-db
29
29
  elestio services --project 12345
30
30
  ```
31
31
 
32
+ ### Three ways to run software
33
+
34
+ | | Command | You get |
35
+ |---|---|---|
36
+ | **Managed service** | `elestio deploy postgresql` | One VM running the software, fully managed |
37
+ | **Cluster** | `elestio deploy postgresql --cluster --nodes 3` | Several VMs with replication |
38
+ | **Pipeline** | `elestio cicd deploy-template n8n --target <vmID>` | Software running on a shared CI/CD target, rebuilt from a repo |
39
+
40
+ Pipelines are cheaper (several pipelines share one target VM) and let you edit
41
+ the software's compose file; managed services get backups, monitoring and
42
+ support. See [CI/CD](#cicd) for the pipeline route.
43
+
32
44
  ## Commands
33
45
 
34
46
  ### Auth & Config
@@ -72,6 +84,7 @@ elestio services --project 12345
72
84
  | `elestio services` | List services in default project |
73
85
  | `elestio service <vmID>` | Show service details |
74
86
  | `elestio deploy <template>` | Deploy a new service |
87
+ | `elestio deploy <template> --cluster` | Deploy as a cluster (see [Clusters](#clusters)) |
75
88
  | `elestio deploy <template> --dry-run` | Preview deployment |
76
89
  | `elestio delete-service <vmID> --force` | Delete a service |
77
90
  | `elestio move-service <vmID> <targetProjectId>` | Move to another project |
@@ -90,6 +103,63 @@ elestio deploy PostgreSQL \
90
103
  --support level1
91
104
  ```
92
105
 
106
+ ### Clusters
107
+
108
+ Clustering is available for 19 templates - PostgreSQL, MySQL, Redis, ClickHouse,
109
+ RabbitMQ, OpenSearch, Keycloak and others. Run `elestio clusters templates` for
110
+ the current list, which is read from the catalog rather than hardcoded.
111
+
112
+ ```bash
113
+ # What can be clustered, and each one's minimum node count
114
+ elestio clusters templates
115
+
116
+ # 1 primary + 2 replicas. Always check the VM count first: billing is per VM.
117
+ elestio deploy postgresql --cluster --nodes 3 --dry-run
118
+ elestio deploy postgresql --cluster --nodes 3
119
+
120
+ # MySQL is the only software that supports writes on several nodes
121
+ elestio deploy mysql --cluster --cluster-mode multi-master
122
+ ```
123
+
124
+ | Option | Description |
125
+ |--------|-------------|
126
+ | `--cluster` | Deploy a cluster instead of a single node |
127
+ | `--nodes <n>` | **Total** nodes, primary included. Defaults to the template minimum, caps at 15 |
128
+ | `--cluster-mode <mode>` | `primary-replica` (default) or `multi-master` |
129
+
130
+ `--nodes 3` means 1 primary and 2 replicas, and bills 3 VMs.
131
+
132
+ ClickHouse, Vault, OpenSearch, RabbitMQ, rke2 and Nats elect a leader by
133
+ quorum and need at least 3 nodes; everything else starts at 2. The CLI checks
134
+ this before calling the API, because a cluster the API rejects has often
135
+ already started billing its VMs.
136
+
137
+ | Command | Description |
138
+ |---------|-------------|
139
+ | `elestio clusters` | List clusters in the project |
140
+ | `elestio clusters info <clusterID>` | Cluster details and its nodes |
141
+ | `elestio clusters nodes <clusterID>` | List the active nodes |
142
+ | `elestio clusters templates` | Software that supports clustering |
143
+ | `elestio clusters promote <clusterID> <vmID> --force` | Promote a replica to primary |
144
+ | `elestio clusters failover <clusterID> on\|off` | Turn automatic failover on or off |
145
+ | `elestio clusters resync <clusterID> --force` | Re-sync replicas from the primary |
146
+ | `elestio clusters lock <clusterID>` | Enable termination protection |
147
+ | `elestio clusters unlock <clusterID>` | Disable termination protection |
148
+ | `elestio clusters delete <clusterID> --force` | Delete the cluster and all its nodes |
149
+
150
+ `promote`, `resync` and `delete` require `--force`: promotion demotes the
151
+ current primary, re-sync **erases all data on the replicas** and replaces it
152
+ with a copy of the primary, and delete removes every node. A locked cluster
153
+ must be unlocked before it can be deleted.
154
+
155
+ `failover` does not switch the primary itself. It turns on or off the automatic
156
+ failover that promotes a replica when the primary goes down; use `promote` to
157
+ switch by hand. Its state shows in `clusters info`.
158
+
159
+ Replicas are read-only and, unlike the primary, do not accept SSL connections:
160
+ a client with `sslmode=require` can write to the primary but cannot read from a
161
+ replica.
162
+
93
163
  ### Server Actions
94
164
 
95
165
  | Command | Description |
@@ -213,8 +283,77 @@ S3 options: `--key`, `--secret`, `--bucket`, `--endpoint`, `--prefix`
213
283
 
214
284
  ### CI/CD
215
285
 
286
+ There are two very different things you can put on a CI/CD target, and picking
287
+ the wrong one is the usual reason a pipeline comes up empty:
288
+
289
+ | You want to run | Use | Why |
290
+ |---|---|---|
291
+ | Software from the Elestio catalog (n8n, Rybbit, Plausible...) | `cicd deploy-template` | Reads the template's `elestio.yml` for ports, env vars and lifecycle hooks |
292
+ | Your own application from your own repo | `cicd create --auto` | You supply the build and run commands |
293
+
294
+ #### Deploying catalog software as a pipeline
295
+
296
+ ```bash
297
+ # 1. Create a CI/CD target if you do not have one (this is a VM)
298
+ elestio deploy CI-CD-Target --name my-target
299
+
300
+ # 2. Find the software
301
+ elestio cicd templates n8n
302
+
303
+ # 3. See exactly what will be created, without creating it
304
+ elestio cicd deploy-template n8n --target <vmID> --dry-run
305
+
306
+ # 4. Deploy
307
+ elestio cicd deploy-template n8n --target <vmID>
308
+ ```
309
+
310
+ Every catalog entry has a companion repo at
311
+ `github.com/elestio-examples/<software>` containing a `docker-compose.yml` and
312
+ an `elestio.yml`. The `elestio.yml` is what makes the software actually run:
313
+
314
+ ```yaml
315
+ config: { runTime, version, buildCommand, runCommand, buildDir }
316
+ environments: [{ key, value }] # becomes the pipeline's env vars
317
+ ports: [{ protocol, targetPort, public, path }]
318
+ lifeCycleConfig: { preInstallCommand, postInstallCommand, ... }
319
+ webUI: [{ url, label, login, password }] # credentials printed on success
320
+ ```
321
+
322
+ `deploy-template` reads that file and builds the pipeline from it. Passwords
323
+ written as `random_password` are generated, `[EMAIL]` becomes your account
324
+ email, and `[CI_CD_DOMAIN]` is resolved by the platform once the pipeline has a
325
+ domain.
326
+
327
+ **Two routes:**
328
+
329
+ | | compose (default) | git (`--owner <git-user>`) |
330
+ |---|---|---|
331
+ | What it does | Inlines the template's `docker-compose.yml` | Generates the template repo into your Git account, then builds from it |
332
+ | Needs a Git account | No | Yes, connected in the dashboard |
333
+ | Lifecycle scripts | Skipped | Run |
334
+ | Repo files the compose mounts | Unavailable | Available |
335
+ | Works for | Templates that need no files from the repo | Every template |
336
+
337
+ The compose route has no checkout, so a template whose `docker-compose.yml`
338
+ bind-mounts a file from its repo cannot work: Docker creates the missing source
339
+ as an empty directory and the container fails to start. The CLI detects this
340
+ from the compose file and refuses upfront, naming the files, rather than
341
+ letting the build fail a minute later with a `runc` error. `--force` overrides.
342
+
343
+ This affects more templates than you would expect - n8n
344
+ (`./n8n-task-runners.json`), Rybbit (four files under `./configs/`) and
345
+ WordPress (`./php.ini`) are all in this category. Vaultwarden, Redis and
346
+ Metabase deploy cleanly on the compose route.
347
+
348
+ > **The git route is currently unavailable.** It needs
349
+ > `POST /api/cicd/createRepoByTemplate`, which the Elestio API returns 404 for:
350
+ > the controller exists in the backend but is not registered in its route
351
+ > whitelist. The CLI reports this explicitly instead of surfacing a bare 404.
352
+
216
353
  | Command | Description |
217
354
  |---------|-------------|
355
+ | `elestio cicd templates [query]` | List catalog software deployable as a pipeline |
356
+ | `elestio cicd deploy-template <software> --target <vmID>` | Deploy catalog software as a pipeline |
218
357
  | `elestio cicd targets` | List CI/CD targets |
219
358
  | `elestio cicd pipelines <vmID>` | List pipelines |
220
359
  | `elestio cicd pipeline-info <vmID> <pipelineID>` | Pipeline details |
@@ -224,12 +363,58 @@ S3 options: `--key`, `--secret`, `--bucket`, `--endpoint`, `--prefix`
224
363
  | `elestio cicd pipeline-resync <vmID> <pipelineID>` | Re-sync pipeline |
225
364
  | `elestio cicd pipeline-logs <vmID> <pipelineID>` | View pipeline logs |
226
365
  | `elestio cicd pipeline-history <vmID> <pipelineID>` | Build history |
227
- | `elestio cicd create --auto --target <vmID> --name X --repo owner/repo` | Auto-create pipeline |
366
+ | `elestio cicd create --auto --target <vmID> --name X --repo owner/repo` | Pipeline from your own repo |
228
367
  | `elestio cicd create <config.json>` | Create from config file |
229
368
  | `elestio cicd template [mode]` | Generate config template |
230
369
  | `elestio cicd domains <vmID> <pipelineID>` | List pipeline domains |
231
370
  | `elestio cicd registries` | List Docker registries |
232
- | `elestio cicd registry-add --name X --username U --password P --url URL` | Add registry |
371
+
372
+ **`deploy-template` options:**
373
+
374
+ | Option | Description |
375
+ |--------|-------------|
376
+ | `--target <vmID>` | **Required.** CI/CD target to deploy onto (`elestio cicd targets`) |
377
+ | `--owner <user-or-org>` | Switches to the git route and names the account to create the repo in |
378
+ | `--no-git` | Force the compose route even when `--owner` is given |
379
+ | `--name <name>` | Pipeline name (defaults to the software name) |
380
+ | `--branch <branch>` | Template branch (default `main`) |
381
+ | `--private` | Create the generated repo as private |
382
+ | `--non-org` | The owner is a personal account, not an organisation |
383
+ | `--auth-id <id>` | Git auth ID, when you have more than one account connected |
384
+ | `--git-type <type>` | `GITHUB` (default) or `GITLAB` |
385
+ | `--repo-name <name>` | Template repo to use, when it differs from the software name |
386
+ | `--build-cmd`, `--run-cmd`, `--install-cmd`, `--build-dir` | Override the values from `elestio.yml` (git route only) |
387
+ | `--variables <KEY=VALUE...>` | Override the environment variables, newline-separated (git route only) |
388
+ | `--force` | Deploy on the compose route even when the compose mounts a repo file |
389
+ | `--dry-run` | Print the plan, create nothing |
390
+
391
+ #### Pipelines from your own repository
392
+
393
+ ```bash
394
+ elestio cicd create --auto --target <vmID> --name my-app --repo acme/my-app \
395
+ --mode github --build-cmd "npm run build" --run-cmd "npm start"
396
+ ```
397
+
398
+ Modes: `github`, `github-fullstack`, `gitlab`, `gitlab-fullstack`, `docker`.
399
+
400
+ #### Docker registries
401
+
402
+ | Command | Description |
403
+ |---------|-------------|
404
+ | `elestio cicd registry-add --name X --username U --password P --url REPO` | Docker Hub |
405
+ | `... --registry-type registry.gitlab.com --repo-id ID` | GitLab.com |
406
+ | `... --registry-type gitlab-self-hosted --repo-id ID --gitlab-url gitlab.company.com` | Self-hosted GitLab |
407
+ | `... --registry-type ghcr.io` | GitHub Container Registry |
408
+
409
+ | Option | Description |
410
+ |--------|-------------|
411
+ | `--name` | Unique identity nickname for the registry credential |
412
+ | `--username` | Registry username |
413
+ | `--password` | Registry password or access token |
414
+ | `--url` | Repository path (e.g. `myuser/myrepo`) - **not** the registry host |
415
+ | `--registry-type` | `docker.io` (default), `registry.gitlab.com`, `gitlab-self-hosted`, `ghcr.io` |
416
+ | `--repo-id` | GitLab project/repo ID - required for both GitLab types |
417
+ | `--gitlab-url` | Self-hosted GitLab hostname - required for `gitlab-self-hosted` |
233
418
 
234
419
  ### Billing
235
420
 
@@ -248,15 +433,68 @@ S3 options: `--key`, `--secret`, `--bucket`, `--endpoint`, `--prefix`
248
433
  | `--version`, `-v` | Show version |
249
434
  | `--debug` | Show full error stack traces |
250
435
 
436
+ ### Passing values that start with `-`
437
+
438
+ `--flag value` treats a value like `-p` or `--port` as the next flag. For any
439
+ value that begins with a dash - passwords, negative numbers - use the
440
+ `--flag=value` form instead:
441
+
442
+ ```bash
443
+ elestio cicd registry-add --name ghcr --username me --password='-Xk9secret'
444
+ ```
445
+
446
+ Everything after a bare `--` is treated as a positional argument.
447
+
251
448
  ## Configuration
252
449
 
253
- Credentials and config are stored in `~/.elestio/`:
450
+ Credentials and config are stored in `~/.elestio/` (directory mode 0700):
254
451
 
255
452
  - `~/.elestio/credentials` - Email and API token (mode 0600)
256
- - `~/.elestio/config.json` - JWT cache, default project, provider defaults
453
+ - `~/.elestio/config.json` - JWT cache, default project, provider defaults (mode 0600; the cached JWT is a bearer credential)
454
+
455
+ Requests to the API time out after 60s rather than hanging.
257
456
 
258
457
  Get your API token from [Elestio Dashboard > Security](https://dash.elest.io/account/security).
259
458
 
459
+ ## Troubleshooting
460
+
461
+ **My pipeline deployed but nothing is running.**
462
+ The pipeline was created without the template's configuration. Use
463
+ `elestio cicd deploy-template <software>` rather than `cicd create` for catalog
464
+ software: it reads the template's `elestio.yml` for ports, environment
465
+ variables and lifecycle hooks. `cicd create` builds a bare pipeline and expects
466
+ you to supply all of that yourself.
467
+
468
+ **The software starts, then exits, or the build fails.**
469
+ Check the build log: `elestio cicd pipeline-history <vmID> <pipelineID>`, then
470
+ `elestio cicd pipeline-log <vmID> --pipeline <id> --file <log>`. On the compose
471
+ route the usual causes are a repo file the compose mounts (the CLI refuses
472
+ these upfront unless you passed `--force`) or a template that genuinely needs
473
+ its lifecycle scripts. Both need the git route, which is currently
474
+ unavailable (see above), so for now there is no workaround for those
475
+ templates.
476
+
477
+ **`variables.trim is not a function` (500 Pipeline.CreateFailed).**
478
+ The `variables` field must be a newline-separated string, never an array. The
479
+ CLI enforces this from 1.1.0; if you are on an older version, upgrade with
480
+ `npm install -g elestio@latest`.
481
+
482
+ **`does not support clustering`.**
483
+ Only some templates can be clustered. Run `elestio clusters templates` for the
484
+ current list.
485
+
486
+ **`needs at least 3 nodes`.**
487
+ ClickHouse, Vault, OpenSearch, RabbitMQ, rke2 and Nats elect a leader by
488
+ quorum and cannot run on two nodes.
489
+
490
+ **Authentication keeps failing.**
491
+ API tokens can be revoked or expire. Get a new one from
492
+ [Dashboard > Security](https://dash.elest.io/account/security) and re-run
493
+ `elestio login`. Check what is stored with `elestio config`.
494
+
495
+ **A value starting with `-` is ignored.**
496
+ Use `--flag=value`. See [Global Options](#global-options).
497
+
260
498
  ## Documentation
261
499
  https://docs.elest.io/books/elestio-cli-skill/page/overview
262
500
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elestio",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Elestio CLI - Deploy and manage services on the Elestio DevOps platform",
5
5
  "type": "module",
6
6
  "main": "src/cli.js",
@@ -8,7 +8,10 @@
8
8
  "elestio": "./bin/elestio.js"
9
9
  },
10
10
  "scripts": {
11
- "start": "node bin/elestio.js"
11
+ "start": "node bin/elestio.js",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest",
14
+ "test:coverage": "vitest run --coverage"
12
15
  },
13
16
  "keywords": [
14
17
  "elestio",
@@ -38,5 +41,9 @@
38
41
  "homepage": "https://elest.io",
39
42
  "bugs": {
40
43
  "url": "https://github.com/elestio/elestio-cli/issues"
44
+ },
45
+ "devDependencies": {
46
+ "@vitest/coverage-v8": "^2.1.8",
47
+ "vitest": "^2.1.8"
41
48
  }
42
49
  }
package/src/api.js CHANGED
@@ -1,8 +1,10 @@
1
- import { loadConfig, saveConfig, getCredentials } from './config.js';
2
- import { log } from './utils.js';
1
+ import { loadConfig, saveConfig } from './config.js';
3
2
 
4
3
  const BASE_URL = 'https://api.elest.io';
5
4
 
5
+ // Requests that exceed this are aborted rather than hanging the CLI forever.
6
+ const REQUEST_TIMEOUT_MS = 60000;
7
+
6
8
  // ── JWT management ──
7
9
 
8
10
  function isJwtExpired(config) {
@@ -10,14 +12,52 @@ function isJwtExpired(config) {
10
12
  return Date.now() > (config.jwtExpiry - 300000); // 5 min buffer
11
13
  }
12
14
 
15
+ /**
16
+ * Wraps fetch with a timeout and turns transport failures into readable errors.
17
+ */
18
+ async function httpRequest(url, options) {
19
+ let response;
20
+
21
+ try {
22
+ response = await fetch(url, { ...options, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
23
+ } catch (err) {
24
+ if (err.name === 'TimeoutError' || err.name === 'AbortError') {
25
+ throw new Error(`Request to ${BASE_URL} timed out after ${REQUEST_TIMEOUT_MS / 1000}s`);
26
+ }
27
+ throw new Error(`Cannot reach ${BASE_URL}: ${err.message}`);
28
+ }
29
+
30
+ return response;
31
+ }
32
+
33
+ /**
34
+ * The API returns HTML on gateway errors, so response.json() alone would surface
35
+ * "Unexpected token <" instead of the real problem.
36
+ */
37
+ async function parseJson(response, endpoint) {
38
+ const text = await response.text();
39
+
40
+ if (text === '') {
41
+ if (response.ok) return {};
42
+ throw new Error(`${endpoint} failed: HTTP ${response.status} ${response.statusText} (empty response)`);
43
+ }
44
+
45
+ try {
46
+ return JSON.parse(text);
47
+ } catch {
48
+ const snippet = text.replace(/\s+/g, ' ').trim().slice(0, 200);
49
+ throw new Error(`${endpoint} returned a non-JSON response (HTTP ${response.status}): ${snippet}`);
50
+ }
51
+ }
52
+
13
53
  async function authenticate(email, token) {
14
- const response = await fetch(`${BASE_URL}/api/auth/checkAPIToken`, {
54
+ const response = await httpRequest(`${BASE_URL}/api/auth/checkAPIToken`, {
15
55
  method: 'POST',
16
56
  headers: { 'Content-Type': 'application/json' },
17
57
  body: JSON.stringify({ email, token })
18
58
  });
19
59
 
20
- const data = await response.json();
60
+ const data = await parseJson(response, '/api/auth/checkAPIToken');
21
61
 
22
62
  if (data.status !== 'OK' || !data.jwt) {
23
63
  throw new Error(data.message || 'Authentication failed');
@@ -38,9 +78,8 @@ export async function getJwt() {
38
78
 
39
79
  if (isJwtExpired(config)) {
40
80
  const auth = await authenticate(config.email, config.apiToken);
41
- config.jwt = auth.jwt;
42
- config.jwtExpiry = auth.jwtExpiry;
43
- saveConfig(config);
81
+ saveConfig({ ...config, jwt: auth.jwt, jwtExpiry: auth.jwtExpiry });
82
+ return auth.jwt;
44
83
  }
45
84
 
46
85
  return config.jwt;
@@ -48,6 +87,11 @@ export async function getJwt() {
48
87
 
49
88
  // ── API requests ──
50
89
 
90
+ function clearJwt() {
91
+ const config = loadConfig();
92
+ saveConfig({ ...config, jwt: null, jwtExpiry: null });
93
+ }
94
+
51
95
  export async function apiRequest(endpoint, method = 'POST', body = {}, retried = false) {
52
96
  const jwt = await getJwt();
53
97
 
@@ -60,21 +104,19 @@ export async function apiRequest(endpoint, method = 'POST', body = {}, retried =
60
104
  options.body = JSON.stringify({ jwt, ...body });
61
105
  }
62
106
 
63
- const url = method === 'GET' && Object.keys(body).length > 0
107
+ // GET routes read parameters from the query string only (backend requirement).
108
+ const url = method === 'GET'
64
109
  ? `${BASE_URL}${endpoint}?${new URLSearchParams({ jwt, ...body })}`
65
110
  : `${BASE_URL}${endpoint}`;
66
111
 
67
- const response = await fetch(url, options);
112
+ const response = await httpRequest(url, options);
68
113
 
69
114
  if (response.status === 401 && !retried) {
70
- const config = loadConfig();
71
- config.jwt = null;
72
- config.jwtExpiry = null;
73
- saveConfig(config);
115
+ clearJwt();
74
116
  return apiRequest(endpoint, method, body, true);
75
117
  }
76
118
 
77
- const data = await response.json();
119
+ const data = await parseJson(response, endpoint);
78
120
 
79
121
  const isAuthError = !retried && (
80
122
  (data.status === 'error' && data.message?.toLowerCase().includes('auth')) ||
@@ -83,10 +125,7 @@ export async function apiRequest(endpoint, method = 'POST', body = {}, retried =
83
125
  );
84
126
 
85
127
  if (isAuthError) {
86
- const config = loadConfig();
87
- config.jwt = null;
88
- config.jwtExpiry = null;
89
- saveConfig(config);
128
+ clearJwt();
90
129
  return apiRequest(endpoint, method, body, true);
91
130
  }
92
131
 
@@ -94,12 +133,11 @@ export async function apiRequest(endpoint, method = 'POST', body = {}, retried =
94
133
  }
95
134
 
96
135
  export async function apiRequestNoAuth(endpoint, method = 'GET') {
97
- const url = `${BASE_URL}${endpoint}`;
98
- const response = await fetch(url, {
136
+ const response = await httpRequest(`${BASE_URL}${endpoint}`, {
99
137
  method,
100
138
  headers: { 'Content-Type': 'application/json' }
101
139
  });
102
- return response.json();
140
+ return parseJson(response, endpoint);
103
141
  }
104
142
 
105
143
  export { BASE_URL, authenticate };