@gambi97/keel-cli 0.3.6 → 0.4.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 +19 -11
- package/dist/bootstrap/infisical.js +9 -2
- package/dist/bootstrap/scaleway.js +31 -1
- package/dist/contracts.js +15 -0
- package/dist/generate.js +2 -1
- package/dist/index.js +4 -1
- package/dist/prompts.js +6 -1
- package/dist/ui.js +6 -6
- package/package.json +1 -1
- package/templates/README.md +17 -7
- package/templates/env.tfvars +5 -0
- package/templates/modules/app_stack/main.tf +12 -6
package/README.md
CHANGED
|
@@ -207,16 +207,16 @@ Environments are separated with **Terraform workspaces**: same code, one
|
|
|
207
207
|
independent state per environment in one bucket, differences confined to the
|
|
208
208
|
per-environment `<env>.tfvars` files.
|
|
209
209
|
|
|
210
|
-
| Data | Lives in | Why
|
|
211
|
-
| ----------------------------------- | ------------------------ |
|
|
212
|
-
| Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform
|
|
213
|
-
| Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply
|
|
214
|
-
| Basic Auth user/password | Infisical (non-prod) | App secret, injected into the container, rotatable
|
|
215
|
-
| Database connection string | Infisical (each env) | Complete, ready-to-use value synced by the pipeline after each apply
|
|
216
|
-
| App public URL (`APP_URL`) | Infisical (each env) | Synced by the pipeline
|
|
217
|
-
| Object Storage coordinates (opt-in) | Infisical (each env) | `S3_*` values synced by the pipeline after each apply
|
|
218
|
-
| Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place
|
|
219
|
-
| Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets
|
|
210
|
+
| Data | Lives in | Why |
|
|
211
|
+
| ----------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
212
|
+
| Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform |
|
|
213
|
+
| Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply |
|
|
214
|
+
| Basic Auth user/password | Infisical (non-prod) | App secret, injected into the container, rotatable |
|
|
215
|
+
| Database connection string | Infisical (each env) | Complete, ready-to-use value synced by the pipeline after each apply |
|
|
216
|
+
| App public URL (`APP_URL`) | Infisical (each env) | Synced by the pipeline after each apply (real from the first one — the container starts on keel's placeholder page); for links, OAuth callbacks, webhooks |
|
|
217
|
+
| Object Storage coordinates (opt-in) | Infisical (each env) | `S3_*` values synced by the pipeline after each apply |
|
|
218
|
+
| Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
|
|
219
|
+
| Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
|
|
220
220
|
|
|
221
221
|
## After the bootstrap
|
|
222
222
|
|
|
@@ -263,6 +263,12 @@ creating anything**.
|
|
|
263
263
|
`ContainersFullAccess`, `ServerlessSQLDatabaseFullAccess`,
|
|
264
264
|
`ContainerRegistryFullAccess`, plus `IAMManager` so Terraform can create
|
|
265
265
|
the app's dedicated least-privilege database credential).
|
|
266
|
+
5. **Organization security settings — API-key expiration must be unlimited**
|
|
267
|
+
(Console → Organization → Security → API keys). The app's database and
|
|
268
|
+
Object Storage credentials are non-expiring service credentials by design
|
|
269
|
+
(the container reads them at runtime); if your organization forces API
|
|
270
|
+
keys to expire, the first CI apply fails creating them. keel checks this
|
|
271
|
+
setting during validation and warns before anything is created.
|
|
266
272
|
|
|
267
273
|
</details>
|
|
268
274
|
|
|
@@ -273,7 +279,9 @@ creating anything**.
|
|
|
273
279
|
1. Create an account at [app.infisical.com](https://app.infisical.com) (or
|
|
274
280
|
use a self-hosted instance).
|
|
275
281
|
2. Create a **Machine Identity** with **Universal Auth**: you need its
|
|
276
|
-
**client ID** and **client secret**.
|
|
282
|
+
**client ID** and **client secret**. Create the client secret with
|
|
283
|
+
**unlimited uses** (Max Number of Uses = 0): CI logs in with it on every
|
|
284
|
+
plan/apply, so any finite usage limit eventually runs out mid-pipeline.
|
|
277
285
|
3. Give the identity permission to create and manage projects.
|
|
278
286
|
|
|
279
287
|
</details>
|
|
@@ -30,8 +30,15 @@ export async function login(infisical) {
|
|
|
30
30
|
const { host, clientId, clientSecret } = infisical;
|
|
31
31
|
const { status, data } = await api(host, '/api/v1/auth/universal-auth/login', { method: 'POST', body: { clientId, clientSecret } });
|
|
32
32
|
if (status !== 200 || !data.accessToken) {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// A usage-limited client secret is a trap for this setup: besides the
|
|
34
|
+
// bootstrap, CI logs in with it on every plan/apply, so any finite limit
|
|
35
|
+
// eventually runs out. Name the fix instead of a generic "check secret".
|
|
36
|
+
const remediation = /usage limit/i.test(data.message ?? '')
|
|
37
|
+
? 'This client secret was created with a usage limit and it is spent. Create a new one ' +
|
|
38
|
+
'with unlimited uses (Max Number of Uses = 0): CI logs in with it on every plan/apply, ' +
|
|
39
|
+
'so a limited secret will always run out.'
|
|
40
|
+
: 'Check the machine identity client ID/secret and that Universal Auth is enabled.';
|
|
41
|
+
throw new InfisicalError(`Infisical Universal Auth login failed (HTTP ${status}${data.message ? `: ${data.message}` : ''}). ${remediation}`);
|
|
35
42
|
}
|
|
36
43
|
return data.accessToken;
|
|
37
44
|
}
|
|
@@ -7,10 +7,38 @@ export class ScalewayError extends Error {
|
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
const API_BASE = 'https://api.scaleway.com';
|
|
10
|
+
/**
|
|
11
|
+
* The generated stack creates non-expiring service credentials (the app's
|
|
12
|
+
* database and Object Storage API keys are read by the container at runtime).
|
|
13
|
+
* An organization security setting that forces API-key expiration makes the
|
|
14
|
+
* very first CI apply fail — hours after the bootstrap looked green. Read the
|
|
15
|
+
* setting here (read-only) so the run can warn upfront, while it is still a
|
|
16
|
+
* 30-second console fix. Returns undefined when the key cannot read it.
|
|
17
|
+
*/
|
|
18
|
+
async function apiKeyExpirationPolicyWarning(secretKey, organizationId) {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(`${API_BASE}/iam/v1alpha1/organizations/${organizationId}/security-settings`, { headers: { 'X-Auth-Token': secretKey } });
|
|
21
|
+
if (!response.ok)
|
|
22
|
+
return undefined;
|
|
23
|
+
const settings = (await response.json());
|
|
24
|
+
const seconds = Number.parseInt(settings.max_api_key_expiration_duration ?? '0', 10);
|
|
25
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
26
|
+
return undefined; // "0s" = unlimited
|
|
27
|
+
return ('Your Scaleway organization requires API keys to expire ' +
|
|
28
|
+
`(max ${Math.round(seconds / 86400)} days). The database/storage credentials the ` +
|
|
29
|
+
'generated stack creates are non-expiring service credentials, so the first CI apply ' +
|
|
30
|
+
'WILL fail on them. Disable the requirement first (Console → Organization → Security → ' +
|
|
31
|
+
'API keys), then merge or re-run the apply.');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
10
37
|
/**
|
|
11
38
|
* Validate credentials with a harmless read call before creating anything.
|
|
12
39
|
* Also confirms the project belongs to the given organization. Read-only: it
|
|
13
|
-
* proves the key can see the project, not that it can create resources.
|
|
40
|
+
* proves the key can see the project, not that it can create resources. The
|
|
41
|
+
* returned warning flags an org policy that would break the first CI apply.
|
|
14
42
|
*/
|
|
15
43
|
export async function validateScalewayCredentials(scaleway) {
|
|
16
44
|
const { secretKey, projectId, organizationId } = scaleway;
|
|
@@ -32,6 +60,8 @@ export async function validateScalewayCredentials(scaleway) {
|
|
|
32
60
|
throw new ScalewayError(`Scaleway project ${projectId} belongs to organization ${project.organization_id}, ` +
|
|
33
61
|
`not ${organizationId}. Check --scw-organization-id.`, 'organization');
|
|
34
62
|
}
|
|
63
|
+
const warning = await apiKeyExpirationPolicyWarning(secretKey, organizationId);
|
|
64
|
+
return warning ? { warning } : {};
|
|
35
65
|
}
|
|
36
66
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
67
|
/**
|
package/dist/contracts.js
CHANGED
|
@@ -43,6 +43,21 @@ export const BASE_SYNCED_KEYS = ['DATABASE_URL', 'APP_URL'];
|
|
|
43
43
|
*/
|
|
44
44
|
export const BASIC_AUTH_SECRET_KEYS = ['BASIC_AUTH_USER', 'BASIC_AUTH_PASSWORD'];
|
|
45
45
|
export const BASIC_AUTH_FLAG = 'BASIC_AUTH_ENABLED';
|
|
46
|
+
/**
|
|
47
|
+
* Public image rendered as the default container_image in every generated
|
|
48
|
+
* <env>.tfvars: the very first apply brings a keel-branded page up, so
|
|
49
|
+
* APP_URL is real from day zero. Built and pushed by the Placeholder image
|
|
50
|
+
* workflow from placeholder/ in this repo; it is also the reference
|
|
51
|
+
* implementation of the env contract (PROJECT_NAME, APP_ENVIRONMENT,
|
|
52
|
+
* BASIC_AUTH_*). Users replace it by editing container_image — or set ""
|
|
53
|
+
* to skip the container entirely.
|
|
54
|
+
*/
|
|
55
|
+
export const PLACEHOLDER_IMAGE = 'ghcr.io/gambi97/keel-placeholder:v1';
|
|
56
|
+
/**
|
|
57
|
+
* Plain environment variables the generated app_stack injects into the
|
|
58
|
+
* container, so any image (the placeholder first) knows what it runs as.
|
|
59
|
+
*/
|
|
60
|
+
export const CONTAINER_ENV_KEYS = ['PROJECT_NAME', 'APP_ENVIRONMENT'];
|
|
46
61
|
/**
|
|
47
62
|
* Naming convention for per-environment resources (registry, namespaces,
|
|
48
63
|
* database, buckets…): mirrored by `local.name` in the app_stack template,
|
package/dist/generate.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { CONTRACT_VERSION } from './contracts.js';
|
|
5
|
+
import { CONTRACT_VERSION, PLACEHOLDER_IMAGE } from './contracts.js';
|
|
6
6
|
import { toolVersion } from './meta.js';
|
|
7
7
|
import { STATE_FILE } from './state.js';
|
|
8
8
|
export class GenerateError extends Error {
|
|
@@ -85,6 +85,7 @@ function envTfvarsTokens(answers, env) {
|
|
|
85
85
|
__ENABLE_OBJECT_STORAGE__: String(answers.objectStorage),
|
|
86
86
|
__MIN_SCALE__: String(env.minScale),
|
|
87
87
|
__MAX_SCALE__: String(env.maxScale),
|
|
88
|
+
__CONTAINER_IMAGE__: PLACEHOLDER_IMAGE,
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
/**
|
package/dist/index.js
CHANGED
|
@@ -298,8 +298,9 @@ async function runBootstrap(answers, state, options) {
|
|
|
298
298
|
else {
|
|
299
299
|
// All three credentials are checked before anything is created anywhere,
|
|
300
300
|
// so a bad token cannot leave a half-bootstrapped account behind.
|
|
301
|
+
let scalewayWarning;
|
|
301
302
|
await withSpinner('Validating Scaleway, Infisical and GitHub credentials', async () => {
|
|
302
|
-
await validateScalewayCredentials(answers.scaleway);
|
|
303
|
+
scalewayWarning = (await validateScalewayCredentials(answers.scaleway)).warning;
|
|
303
304
|
await validateInfisical(answers.infisical);
|
|
304
305
|
ctx = await createContext(answers.github);
|
|
305
306
|
// A repo with commits would make the push fail after the bucket and the
|
|
@@ -310,6 +311,8 @@ async function runBootstrap(answers, state, options) {
|
|
|
310
311
|
assertRepoUsable(ctx, repoState);
|
|
311
312
|
}
|
|
312
313
|
});
|
|
314
|
+
if (scalewayWarning)
|
|
315
|
+
log.warn(scalewayWarning);
|
|
313
316
|
}
|
|
314
317
|
for (const step of BOOTSTRAP_STEPS) {
|
|
315
318
|
if (isDone(state, step.name)) {
|
package/dist/prompts.js
CHANGED
|
@@ -358,11 +358,16 @@ async function askScaleway(out) {
|
|
|
358
358
|
out.scaleway.organizationId = await text('Scaleway organization ID');
|
|
359
359
|
}
|
|
360
360
|
try {
|
|
361
|
-
await validateScalewayCredentials({
|
|
361
|
+
const { warning } = await validateScalewayCredentials({
|
|
362
362
|
secretKey: out.scaleway.secretKey,
|
|
363
363
|
projectId: out.scaleway.projectId,
|
|
364
364
|
organizationId: out.scaleway.organizationId,
|
|
365
365
|
});
|
|
366
|
+
// e.g. an org policy that would make the first CI apply fail: better a
|
|
367
|
+
// loud heads-up now, while it is a 30-second console fix, than a red
|
|
368
|
+
// pipeline hours after everything here looked green.
|
|
369
|
+
if (warning)
|
|
370
|
+
log.warn(warning);
|
|
366
371
|
p.outro('Scaleway connected — credentials valid and project reachable.');
|
|
367
372
|
return;
|
|
368
373
|
}
|
package/dist/ui.js
CHANGED
|
@@ -75,14 +75,14 @@ export function renderNextSteps(answers, repoUrl, containerUrlHint) {
|
|
|
75
75
|
return [
|
|
76
76
|
'Next steps:',
|
|
77
77
|
'',
|
|
78
|
-
` 1.
|
|
79
|
-
`
|
|
78
|
+
` 1. Push to main (or merge a PR) to deploy the non-production environments:`,
|
|
79
|
+
` ${repoUrl}/actions — the first apply already serves keel's placeholder`,
|
|
80
|
+
` page, so APP_URL is live from day zero.`,
|
|
80
81
|
` 2. Replace the placeholder secrets in Infisical (${answers.infisical.host})`,
|
|
81
82
|
` project "${answers.infisical.projectName}" with real values.`,
|
|
82
|
-
` 3.
|
|
83
|
-
` ${
|
|
84
|
-
` 4.
|
|
85
|
-
` 5. Release to production with a version tag: git tag v1.0.0 && git push --tags`,
|
|
83
|
+
` 3. Ship your app: push its image to the registry (${containerUrlHint}),`,
|
|
84
|
+
` then point container_image at it in ${answers.environments.map((e) => `${e.slug}.tfvars`).join(' / ')}.`,
|
|
85
|
+
` 4. Release to production with a version tag: git tag v1.0.0 && git push --tags`,
|
|
86
86
|
'',
|
|
87
87
|
`Repository: ${repoUrl}`,
|
|
88
88
|
].join('\n');
|
package/package.json
CHANGED
package/templates/README.md
CHANGED
|
@@ -65,11 +65,13 @@ tag vX.Y.Z -> apply production (the commit the tag points at)
|
|
|
65
65
|
|
|
66
66
|
## First deploy
|
|
67
67
|
|
|
68
|
-
1. **Merge or push to `main`.** The first pipeline run creates registry
|
|
69
|
-
|
|
70
|
-
bucket, if enabled). The
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
1. **Merge or push to `main`.** The first pipeline run creates the registry,
|
|
69
|
+
the database and a Serverless Container for each non-production
|
|
70
|
+
environment (and the Object Storage bucket, if enabled). The container
|
|
71
|
+
starts on **keel's placeholder page** (`container_image` in the tfvars), so
|
|
72
|
+
`terraform output container_url` — and the `APP_URL` secret — are real from
|
|
73
|
+
day zero. Production waits for a version tag (step 5). Setting
|
|
74
|
+
`container_image = ""` skips the container entirely.
|
|
73
75
|
|
|
74
76
|
2. **Build and push the app image** (any Dockerfile, listening on port 8080
|
|
75
77
|
by default), replacing `<env>` with your target environment (e.g. staging):
|
|
@@ -91,7 +93,7 @@ tag vX.Y.Z -> apply production (the commit the tag points at)
|
|
|
91
93
|
| Variable | Where | Notes |
|
|
92
94
|
|---|---|---|
|
|
93
95
|
| `DATABASE_URL` | every environment | Complete connection string, auto-updated by the pipeline after each apply. No action needed |
|
|
94
|
-
| `APP_URL` | every environment | Public URL of the app, auto-updated by the pipeline
|
|
96
|
+
| `APP_URL` | every environment | Public URL of the app, auto-updated by the pipeline after each apply (real from the first one — the placeholder counts). Useful for links, OAuth callbacks, webhooks |
|
|
95
97
|
| `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` | non-production | The app must enforce these when `BASIC_AUTH_ENABLED=true` |
|
|
96
98
|
| `S3_BUCKET` / `S3_ENDPOINT` / `S3_REGION` / `S3_ACCESS_KEY` / `S3_SECRET_KEY` | every environment (if Object Storage enabled) | Auto-updated by the pipeline after each apply |
|
|
97
99
|
|
|
@@ -209,7 +211,15 @@ Avoid local applies: they race against CI on the same state.
|
|
|
209
211
|
match a Scaleway key with Object Storage access.
|
|
210
212
|
- **Plan fails reading Infisical secrets**: check `INFISICAL_PROJECT_ID` and
|
|
211
213
|
that the machine identity has access to the project and every environment.
|
|
212
|
-
|
|
214
|
+
If the error mentions a **usage limit**, the machine identity's client
|
|
215
|
+
secret was created with a maximum number of uses and it is spent: create a
|
|
216
|
+
new client secret with unlimited uses (the pipeline logs in on every
|
|
217
|
+
plan/apply) and update the `INFISICAL_CLIENT_SECRET` Actions secret.
|
|
218
|
+
- **Apply green but no container**: expected only when `container_image` is
|
|
219
|
+
`""`; by default every environment serves keel's placeholder page.
|
|
220
|
+
- **The app answers 401**: non-production environments enforce Basic Auth by
|
|
221
|
+
default — the credentials are `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` in
|
|
222
|
+
that environment's Infisical secrets.
|
|
213
223
|
- **App can't reach the database**: `DATABASE_URL` is only synced after an
|
|
214
224
|
apply; check the value in Infisical for that environment. It must contain
|
|
215
225
|
a username and password (the dedicated IAM credential), not placeholders.
|
package/templates/env.tfvars
CHANGED
|
@@ -6,3 +6,8 @@ enable_basic_auth = __ENABLE_BASIC_AUTH__
|
|
|
6
6
|
enable_object_storage = __ENABLE_OBJECT_STORAGE__
|
|
7
7
|
min_scale = __MIN_SCALE__
|
|
8
8
|
max_scale = __MAX_SCALE__
|
|
9
|
+
|
|
10
|
+
# The image this environment runs. Starts as keel's placeholder page so the
|
|
11
|
+
# first apply brings a container up and APP_URL is real; replace it with your
|
|
12
|
+
# application's image when ready ("" skips the container entirely).
|
|
13
|
+
container_image = "__CONTAINER_IMAGE__"
|
|
@@ -81,9 +81,9 @@ resource "scaleway_iam_api_key" "storage" {
|
|
|
81
81
|
description = "Object Storage credential for ${local.name} (managed by Terraform)"
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
# The container
|
|
85
|
-
#
|
|
86
|
-
#
|
|
84
|
+
# The container runs whatever image the tfvars point at — keel's placeholder
|
|
85
|
+
# page at first, the application once its image is pushed. Setting
|
|
86
|
+
# container_image = "" skips the container entirely.
|
|
87
87
|
resource "scaleway_container" "this" {
|
|
88
88
|
count = var.container_image == "" ? 0 : 1
|
|
89
89
|
|
|
@@ -97,9 +97,15 @@ resource "scaleway_container" "this" {
|
|
|
97
97
|
max_scale = var.max_scale
|
|
98
98
|
privacy = "public"
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
# Plain identity variables any image can rely on; the placeholder renders
|
|
101
|
+
# them, real apps are free to ignore them.
|
|
102
|
+
environment_variables = merge(
|
|
103
|
+
{
|
|
104
|
+
PROJECT_NAME = var.project_name
|
|
105
|
+
APP_ENVIRONMENT = var.environment
|
|
106
|
+
},
|
|
107
|
+
var.enable_basic_auth ? { BASIC_AUTH_ENABLED = "true" } : {},
|
|
108
|
+
)
|
|
103
109
|
|
|
104
110
|
# BASIC_AUTH_USER / BASIC_AUTH_PASSWORD / DATABASE_URL arrive here from
|
|
105
111
|
# Infisical; the app is responsible for enforcing Basic Auth when enabled.
|