@gambi97/keel-cli 0.2.0 → 0.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 +70 -25
- package/dist/bootstrap/github.js +84 -22
- package/dist/bootstrap/infisical.js +59 -22
- package/dist/bootstrap/scaleway.js +102 -17
- package/dist/config.js +14 -3
- package/dist/contracts.js +80 -0
- package/dist/generate.js +37 -3
- package/dist/index.js +216 -142
- package/dist/meta.js +12 -0
- package/dist/prompts.js +246 -66
- package/dist/ui.js +3 -1
- package/package.json +1 -1
- package/templates/.github/scripts/sync-secrets.sh +13 -4
- package/templates/.github/workflows/terraform-drift.yml +19 -1
- package/templates/.github/workflows/terraform-plan.yml +19 -1
- package/templates/README.md +29 -5
- package/templates/_gitignore +1 -1
- package/templates/main.tf +10 -0
- package/templates/modules/app_stack/main.tf +3 -0
- package/templates/outputs.tf +12 -3
- package/templates/variables.tf +5 -0
package/README.md
CHANGED
|
@@ -28,6 +28,14 @@ provisioned by Terraform, deployed by GitHub Actions, with secrets in
|
|
|
28
28
|
Docker image, keel gives it a place to run. You answer a few questions, push
|
|
29
29
|
to `main`, and the infrastructure is live.
|
|
30
30
|
|
|
31
|
+
**keel is a starting point, not a control plane.** It runs once to lay the
|
|
32
|
+
foundation and hand you a repository you fully own. From then on, day-2 work —
|
|
33
|
+
scaling, new environments, custom domains, rotating secrets — happens in that
|
|
34
|
+
repository: edit a tfvars line, open a PR, merge. keel does not stay in the
|
|
35
|
+
loop, has nothing to update, and never manages your infrastructure after the
|
|
36
|
+
first setup. If you outgrow the generated layout, it is plain Terraform you
|
|
37
|
+
can take anywhere.
|
|
38
|
+
|
|
31
39
|
- **Near-free to start.** Compute and database scale to zero; an idle project
|
|
32
40
|
costs cents per month, with no expiring trial.
|
|
33
41
|
- **Scales with your product.** Pick your environments (production only, or
|
|
@@ -47,10 +55,13 @@ GitHub (see [Prerequisites](#prerequisites) for how to get each one).
|
|
|
47
55
|
npx @gambi97/keel-cli
|
|
48
56
|
```
|
|
49
57
|
|
|
50
|
-
The CLI asks for a project name, region
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
The CLI asks for a project name, region and environments, then walks one
|
|
59
|
+
provider at a time — GitHub, Infisical, Scaleway — verifying each set of
|
|
60
|
+
credentials with read-only calls the moment you enter it (a bad token or a
|
|
61
|
+
non-empty repository is reported immediately, and only that answer is asked
|
|
62
|
+
again). It picks up `SCW_*` / `INFISICAL_*` / `GITHUB_TOKEN` from your
|
|
63
|
+
environment as defaults, then shows a **complete summary of what it will
|
|
64
|
+
create and where**. Nothing is touched before you confirm. When it finishes, push to `main` (or merge the
|
|
54
65
|
first PR) and the pipeline provisions the infrastructure.
|
|
55
66
|
|
|
56
67
|
Non-interactive and dry-run:
|
|
@@ -152,12 +163,12 @@ local tooling or production credentials.
|
|
|
152
163
|
|
|
153
164
|
**Phase A: bootstrap (the CLI, via APIs, after your confirmation)**
|
|
154
165
|
|
|
155
|
-
| Where | What
|
|
156
|
-
| ------------ |
|
|
157
|
-
| Your machine | The generated repo: Terraform, workflows, README, initial git commit
|
|
158
|
-
| Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking)
|
|
159
|
-
| GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; one deployment environment per selected environment (`production` gated by manual approval); branch protection on `main`
|
|
160
|
-
| Infisical | A project with one environment per selected environment, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (non-production, random password),
|
|
166
|
+
| Where | What |
|
|
167
|
+
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
168
|
+
| Your machine | The generated repo: Terraform, workflows, README, initial git commit |
|
|
169
|
+
| Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking, restricted by a bucket policy to the identity behind your API key) |
|
|
170
|
+
| GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; one deployment environment per selected environment (`production` gated by manual approval); branch protection on `main` |
|
|
171
|
+
| Infisical | A project with one environment per selected environment, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (non-production, random password), `DATABASE_URL` / `APP_URL` placeholders per environment, and `S3_*` placeholders when Object Storage is enabled |
|
|
161
172
|
|
|
162
173
|
**Phase B: first deploy (Terraform in GitHub Actions, on push to `main`)**
|
|
163
174
|
|
|
@@ -181,6 +192,7 @@ my-app/
|
|
|
181
192
|
│ ├── terraform-plan.yml # PR: fmt + validate + plan (every environment)
|
|
182
193
|
│ ├── terraform-apply.yml # main: apply each env in order; production gated
|
|
183
194
|
│ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
|
|
195
|
+
├── .keel/manifest.json # committed record: keel version, contract version, options
|
|
184
196
|
├── versions.tf · providers.tf · backend.tf
|
|
185
197
|
├── backend.hcl.example # state bucket coordinates (backend.hcl is git-ignored)
|
|
186
198
|
├── variables.tf · main.tf · outputs.tf
|
|
@@ -192,15 +204,16 @@ Environments are separated with **Terraform workspaces**: same code, one
|
|
|
192
204
|
independent state per environment in one bucket, differences confined to the
|
|
193
205
|
per-environment `<env>.tfvars` files.
|
|
194
206
|
|
|
195
|
-
| Data | Lives in | Why
|
|
196
|
-
| ----------------------------------- | ------------------------ |
|
|
197
|
-
| Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform
|
|
198
|
-
| Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply
|
|
199
|
-
| Basic Auth user/password | Infisical (non-prod) | App secret, injected into the container, rotatable
|
|
200
|
-
| Database connection string | Infisical (each env) | Complete, ready-to-use value synced by the pipeline after each apply
|
|
201
|
-
|
|
|
202
|
-
|
|
|
203
|
-
|
|
|
207
|
+
| Data | Lives in | Why |
|
|
208
|
+
| ----------------------------------- | ------------------------ | ------------------------------------------------------------------------------------ |
|
|
209
|
+
| Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform |
|
|
210
|
+
| Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply |
|
|
211
|
+
| Basic Auth user/password | Infisical (non-prod) | App secret, injected into the container, rotatable |
|
|
212
|
+
| Database connection string | Infisical (each env) | Complete, ready-to-use value synced by the pipeline after each apply |
|
|
213
|
+
| App public URL (`APP_URL`) | Infisical (each env) | Synced by the pipeline once a container exists; for links, OAuth callbacks, webhooks |
|
|
214
|
+
| Object Storage coordinates (opt-in) | Infisical (each env) | `S3_*` values synced by the pipeline after each apply |
|
|
215
|
+
| Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
|
|
216
|
+
| Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
|
|
204
217
|
|
|
205
218
|
## After the bootstrap
|
|
206
219
|
|
|
@@ -283,10 +296,18 @@ creating anything**.
|
|
|
283
296
|
plan, production applies need manual approval.
|
|
284
297
|
- Terraform state lives in a private, versioned bucket with S3-native state
|
|
285
298
|
locking (`use_lockfile`), so concurrent applies cannot corrupt it.
|
|
299
|
+
- The state bucket is **restricted by a bucket policy** to the identity that
|
|
300
|
+
owns the API key you give keel — the same identity CI authenticates with,
|
|
301
|
+
so the pipeline keeps working. Terraform state contains the generated
|
|
302
|
+
credentials (`DATABASE_URL`, `S3_*`), so no other principal in the project —
|
|
303
|
+
including the app's own credentials — can read it. Note that this also
|
|
304
|
+
hides the bucket from other console users; manage or remove the policy with
|
|
305
|
+
that same API key if you need to open it up.
|
|
286
306
|
- The app connects to its database with a **dedicated least-privilege IAM
|
|
287
307
|
credential** (read/write on that database, nothing else), not with your
|
|
288
308
|
main API key. Object Storage, when enabled, gets its own separate dedicated
|
|
289
|
-
credential the
|
|
309
|
+
credential that can only use Object Storage (and, thanks to the bucket
|
|
310
|
+
policy above, cannot read the Terraform state).
|
|
290
311
|
- A weekly drift-detection plan opens an issue when the real infrastructure
|
|
291
312
|
no longer matches the code.
|
|
292
313
|
|
|
@@ -328,6 +349,17 @@ No. The CLI bootstraps via APIs; Terraform runs inside GitHub Actions.
|
|
|
328
349
|
Yes. The CLI asks for the name and the visibility; default is public (the
|
|
329
350
|
infra holds no secrets), or choose private interactively or with `--private`.
|
|
330
351
|
|
|
352
|
+
**Can I point keel at an existing repository?**
|
|
353
|
+
Only if it has no commits. keel pushes a brand-new history, so a repository
|
|
354
|
+
that already has commits (even just a README from the GitHub UI) would reject
|
|
355
|
+
the push; the CLI checks this up front and asks for another name. The easiest
|
|
356
|
+
path is to let keel create the repository for you.
|
|
357
|
+
|
|
358
|
+
**Can I reuse an existing Infisical project?**
|
|
359
|
+
Yes: pass its project ID (`--infisical-project-id` or interactively). The CLI
|
|
360
|
+
verifies the machine identity can access it before anything is created. Leave
|
|
361
|
+
it empty and keel creates a project named after your app instead.
|
|
362
|
+
|
|
331
363
|
**Why is the container not created on the first apply?**
|
|
332
364
|
A Serverless Container needs an image, and none exists yet. Registry and
|
|
333
365
|
database are created immediately; the container is gated on
|
|
@@ -343,9 +375,19 @@ You choose at creation: production only, staging + production (default), or
|
|
|
343
375
|
dev + staging + production — interactively or with `--environments`
|
|
344
376
|
(e.g. `--environments prod` or `--environments dev,staging,prod`). Production
|
|
345
377
|
is always gated by a manual approval; non-production environments enable Basic
|
|
346
|
-
Auth by default. To add one to an existing repo later: add a `<env>.tfvars
|
|
347
|
-
|
|
348
|
-
|
|
378
|
+
Auth by default. To add one to an existing repo later: add a `<env>.tfvars`
|
|
379
|
+
(the plan and drift workflows discover environments from the tfvars files at
|
|
380
|
+
the repo root automatically), an Infisical environment with the same slug, and
|
|
381
|
+
a chained job in `terraform-apply.yml`.
|
|
382
|
+
|
|
383
|
+
**Can I extend the generated repo with my own Terraform?**
|
|
384
|
+
Yes, and additions never edit generated files: drop a new `.tf` file at the
|
|
385
|
+
repo root (Terraform merges all root files), put per-env values in the
|
|
386
|
+
existing tfvars, and expose an output named `infisical_secrets_<name>` if your
|
|
387
|
+
module produces secrets the app should read — the pipeline collects every
|
|
388
|
+
output matching that prefix and syncs it to Infisical automatically. The
|
|
389
|
+
committed `.keel/manifest.json` records the contract version your repo was
|
|
390
|
+
generated with.
|
|
349
391
|
|
|
350
392
|
**Can I store files, not just rows?**
|
|
351
393
|
Yes, opt in with `--object-storage` (or answer yes interactively). Each
|
|
@@ -366,7 +408,9 @@ off by default — many apps only need the database.
|
|
|
366
408
|
--infisical-host <url> or env INFISICAL_HOST (default: https://app.infisical.com)
|
|
367
409
|
--infisical-client-id <id> or env INFISICAL_CLIENT_ID
|
|
368
410
|
--infisical-client-secret <s> or env INFISICAL_CLIENT_SECRET
|
|
369
|
-
--infisical-project-
|
|
411
|
+
--infisical-project-id <id> Existing Infisical project ID to reuse
|
|
412
|
+
(or env INFISICAL_PROJECT_ID; default: create by name)
|
|
413
|
+
--infisical-project-name <n> Infisical project name (default: project name)
|
|
370
414
|
--github-token <token> or env GITHUB_TOKEN / GH_TOKEN (scopes: repo, workflow)
|
|
371
415
|
--repo-name <name> GitHub repository name (default: project name)
|
|
372
416
|
--private / --public Repository visibility (default: public)
|
|
@@ -374,10 +418,11 @@ off by default — many apps only need the database.
|
|
|
374
418
|
(or a list like "dev,staging,prod"; default staging+prod)
|
|
375
419
|
--object-storage Provision a per-environment Object Storage bucket
|
|
376
420
|
--no-object-storage Do not provision Object Storage (default)
|
|
421
|
+
--basic-auth Enable Basic Auth on non-production environments (default)
|
|
377
422
|
--no-basic-auth Disable Basic Auth on non-production environments
|
|
378
423
|
--dev-min-scale <n> Default 0 --dev-max-scale <n> Default 1
|
|
379
424
|
--staging-min-scale <n> Default 0 --staging-max-scale <n> Default 1
|
|
380
|
-
--prod-min-scale <n> Default 0 --prod-max-scale <n> Default
|
|
425
|
+
--prod-min-scale <n> Default 0 --prod-max-scale <n> Default 1
|
|
381
426
|
--config <file.json> Load answers from a JSON file
|
|
382
427
|
--advanced Also ask scaling questions interactively
|
|
383
428
|
--yes Accept defaults, skip the confirmation prompt
|
package/dist/bootstrap/github.js
CHANGED
|
@@ -4,47 +4,104 @@ import { createRequire } from 'node:module';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { Octokit } from '@octokit/rest';
|
|
7
|
+
import { planStatusCheckContext } from '../contracts.js';
|
|
7
8
|
// The ESM build of libsodium-wrappers is broken (missing libsodium.mjs), so
|
|
8
9
|
// load the CommonJS build explicitly.
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
const sodium = require('libsodium-wrappers');
|
|
11
12
|
export class GitHubError extends Error {
|
|
13
|
+
field;
|
|
14
|
+
constructor(message, field = 'token') {
|
|
15
|
+
super(message);
|
|
16
|
+
this.field = field;
|
|
17
|
+
}
|
|
12
18
|
}
|
|
13
|
-
export async function createContext(
|
|
14
|
-
|
|
19
|
+
export async function createContext(github) {
|
|
20
|
+
// Silence Octokit's own request logging: expected non-2xx responses (a 404
|
|
21
|
+
// for a repo that does not exist yet, a 401 for a bad token) are handled
|
|
22
|
+
// here and would otherwise scribble over the prompt spinner.
|
|
23
|
+
const octokit = new Octokit({
|
|
24
|
+
auth: github.token,
|
|
25
|
+
log: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
|
|
26
|
+
});
|
|
15
27
|
let login;
|
|
16
28
|
let ownerId;
|
|
29
|
+
let scopes;
|
|
17
30
|
try {
|
|
18
|
-
const { data } = await octokit.users.getAuthenticated();
|
|
31
|
+
const { data, headers } = await octokit.users.getAuthenticated();
|
|
19
32
|
login = data.login;
|
|
20
33
|
ownerId = data.id;
|
|
34
|
+
scopes = headers['x-oauth-scopes'];
|
|
21
35
|
}
|
|
22
36
|
catch {
|
|
23
37
|
throw new GitHubError('GitHub rejected the token. It needs the "repo" and "workflow" scopes ' +
|
|
24
38
|
'(classic PAT) or equivalent fine-grained permissions.');
|
|
25
39
|
}
|
|
40
|
+
// Classic PATs advertise their scopes in a header; fine-grained tokens do
|
|
41
|
+
// not, so an absent/empty header only means "cannot check here".
|
|
42
|
+
if (scopes) {
|
|
43
|
+
const granted = new Set(scopes.split(',').map((s) => s.trim()));
|
|
44
|
+
for (const required of ['repo', 'workflow']) {
|
|
45
|
+
if (!granted.has(required)) {
|
|
46
|
+
throw new GitHubError(`The GitHub token is missing the "${required}" scope (it has: ${scopes}). ` +
|
|
47
|
+
'Create a classic PAT with the "repo" and "workflow" scopes.');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
26
51
|
return {
|
|
27
52
|
octokit,
|
|
28
53
|
owner: login,
|
|
29
54
|
ownerId,
|
|
30
|
-
repo:
|
|
31
|
-
repoPrivate:
|
|
55
|
+
repo: github.repoName,
|
|
56
|
+
repoPrivate: github.repoPrivate,
|
|
32
57
|
};
|
|
33
58
|
}
|
|
34
|
-
/**
|
|
35
|
-
export async function
|
|
59
|
+
/** Read-only look at the target repository: existence, push access, emptiness. */
|
|
60
|
+
export async function inspectRepo(ctx) {
|
|
61
|
+
let url;
|
|
36
62
|
try {
|
|
37
63
|
const { data } = await ctx.octokit.repos.get({ owner: ctx.owner, repo: ctx.repo });
|
|
38
|
-
if (!data.permissions?.push)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return { created: false, url: data.html_url };
|
|
64
|
+
if (!data.permissions?.push)
|
|
65
|
+
return { state: 'no-push', url: data.html_url };
|
|
66
|
+
url = data.html_url;
|
|
42
67
|
}
|
|
43
68
|
catch (error) {
|
|
44
|
-
if (error
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
69
|
+
if (error.status === 404)
|
|
70
|
+
return { state: 'not-found' };
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const { data } = await ctx.octokit.repos.listCommits({
|
|
75
|
+
owner: ctx.owner,
|
|
76
|
+
repo: ctx.repo,
|
|
77
|
+
per_page: 1,
|
|
78
|
+
});
|
|
79
|
+
return { state: data.length > 0 ? 'non-empty' : 'empty', url };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// GitHub answers 409 "Git Repository is empty" for a repo with no commits.
|
|
83
|
+
if (error.status === 409)
|
|
84
|
+
return { state: 'empty', url };
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Turn a blocking repo state into a GitHubError; pass on 'not-found'/'empty'. */
|
|
89
|
+
export function assertRepoUsable(ctx, state) {
|
|
90
|
+
if (state === 'no-push') {
|
|
91
|
+
throw new GitHubError(`Repository ${ctx.owner}/${ctx.repo} exists but the token cannot push to it.`, 'repo');
|
|
92
|
+
}
|
|
93
|
+
if (state === 'non-empty') {
|
|
94
|
+
throw new GitHubError(`Repository ${ctx.owner}/${ctx.repo} already has commits. keel pushes a brand-new ` +
|
|
95
|
+
'history, so the push would be rejected: use a new repository name (keel creates ' +
|
|
96
|
+
'it for you) or an existing repository with no commits.', 'repo');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Create the repository, or reuse it when it already exists and is empty. */
|
|
100
|
+
export async function ensureRepo(ctx) {
|
|
101
|
+
const inspected = await inspectRepo(ctx);
|
|
102
|
+
assertRepoUsable(ctx, inspected.state);
|
|
103
|
+
if (inspected.state !== 'not-found') {
|
|
104
|
+
return { created: false, url: inspected.url };
|
|
48
105
|
}
|
|
49
106
|
const { data } = await ctx.octokit.repos.createForAuthenticatedUser({
|
|
50
107
|
name: ctx.repo,
|
|
@@ -100,21 +157,25 @@ export function pushRepo(ctx, token, targetDir) {
|
|
|
100
157
|
/** Encrypt and upload every CI secret, then set the plain variables. */
|
|
101
158
|
export async function configureRepo(ctx, answers, infisicalProjectId) {
|
|
102
159
|
// The workflows map AWS_* (state backend) from the same SCW_* secrets, so
|
|
103
|
-
// each credential is stored once and rotated in one place.
|
|
104
|
-
|
|
160
|
+
// each credential is stored once and rotated in one place. The Record types
|
|
161
|
+
// force these names to stay in lockstep with the contracts the generated
|
|
162
|
+
// workflows are tested against.
|
|
163
|
+
const secrets = {
|
|
105
164
|
SCW_ACCESS_KEY: answers.scaleway.accessKey,
|
|
106
165
|
SCW_SECRET_KEY: answers.scaleway.secretKey,
|
|
107
166
|
SCW_DEFAULT_PROJECT_ID: answers.scaleway.projectId,
|
|
108
167
|
SCW_DEFAULT_ORGANIZATION_ID: answers.scaleway.organizationId,
|
|
109
168
|
INFISICAL_CLIENT_ID: answers.infisical.clientId,
|
|
110
169
|
INFISICAL_CLIENT_SECRET: answers.infisical.clientSecret,
|
|
111
|
-
}
|
|
112
|
-
await
|
|
170
|
+
};
|
|
171
|
+
await setSecrets(ctx, secrets);
|
|
172
|
+
const variables = {
|
|
113
173
|
TF_STATE_BUCKET: answers.stateBucket,
|
|
114
174
|
SCW_REGION: answers.region,
|
|
115
175
|
INFISICAL_PROJECT_ID: infisicalProjectId,
|
|
116
176
|
INFISICAL_HOST: answers.infisical.host,
|
|
117
|
-
}
|
|
177
|
+
};
|
|
178
|
+
await setVariables(ctx, variables);
|
|
118
179
|
await configureEnvironments(ctx, answers);
|
|
119
180
|
await protectMainBranch(ctx, answers);
|
|
120
181
|
}
|
|
@@ -176,8 +237,9 @@ async function protectMainBranch(ctx, answers) {
|
|
|
176
237
|
branch: 'main',
|
|
177
238
|
required_status_checks: {
|
|
178
239
|
strict: false,
|
|
179
|
-
//
|
|
180
|
-
|
|
240
|
+
// Must match the job names produced by the plan workflow; the shared
|
|
241
|
+
// format lives in contracts.ts and is tested against the template.
|
|
242
|
+
contexts: answers.environments.map((env) => planStatusCheckContext(env.slug)),
|
|
181
243
|
},
|
|
182
244
|
enforce_admins: false,
|
|
183
245
|
required_pull_request_reviews: null,
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { BASE_SYNCED_KEYS, BASIC_AUTH_SECRET_KEYS, S3_SECRET_KEYS } from '../contracts.js';
|
|
2
3
|
export class InfisicalError extends Error {
|
|
4
|
+
field;
|
|
5
|
+
constructor(message, field = 'credentials') {
|
|
6
|
+
super(message);
|
|
7
|
+
this.field = field;
|
|
8
|
+
}
|
|
3
9
|
}
|
|
4
10
|
async function api(host, path, options = {}) {
|
|
5
11
|
const response = await fetch(`${host}${path}`, {
|
|
@@ -20,8 +26,8 @@ async function api(host, path, options = {}) {
|
|
|
20
26
|
}
|
|
21
27
|
return { status: response.status, data };
|
|
22
28
|
}
|
|
23
|
-
export async function login(
|
|
24
|
-
const { host, clientId, clientSecret } =
|
|
29
|
+
export async function login(infisical) {
|
|
30
|
+
const { host, clientId, clientSecret } = infisical;
|
|
25
31
|
const { status, data } = await api(host, '/api/v1/auth/universal-auth/login', { method: 'POST', body: { clientId, clientSecret } });
|
|
26
32
|
if (status !== 200 || !data.accessToken) {
|
|
27
33
|
throw new InfisicalError(`Infisical Universal Auth login failed (HTTP ${status}${data.message ? `: ${data.message}` : ''}). ` +
|
|
@@ -29,11 +35,18 @@ export async function login(answers) {
|
|
|
29
35
|
}
|
|
30
36
|
return data.accessToken;
|
|
31
37
|
}
|
|
32
|
-
async function
|
|
38
|
+
async function listProjects(host, token) {
|
|
33
39
|
const { status, data } = await api(host, '/api/v1/workspace', { token });
|
|
34
40
|
if (status !== 200)
|
|
35
|
-
return
|
|
36
|
-
return data.workspaces
|
|
41
|
+
return [];
|
|
42
|
+
return data.workspaces ?? [];
|
|
43
|
+
}
|
|
44
|
+
async function findProject(host, token, name) {
|
|
45
|
+
return (await listProjects(host, token)).find((w) => w.name === name);
|
|
46
|
+
}
|
|
47
|
+
/** Look an existing project up by ID among those the identity can access. */
|
|
48
|
+
export async function findProjectById(host, token, id) {
|
|
49
|
+
return (await listProjects(host, token)).find((w) => w.id === id);
|
|
37
50
|
}
|
|
38
51
|
async function createProject(host, token, name) {
|
|
39
52
|
const { status, data } = await api(host, '/api/v2/workspace', { method: 'POST', token, body: { projectName: name } });
|
|
@@ -71,22 +84,43 @@ async function seedSecret(host, token, projectId, environment, name, value) {
|
|
|
71
84
|
throw new InfisicalError(`Could not seed secret ${name} in ${environment} (HTTP ${status}${data.message ? `: ${data.message}` : ''}).`);
|
|
72
85
|
}
|
|
73
86
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
function inaccessibleProject(projectId) {
|
|
88
|
+
return new InfisicalError(`Infisical project "${projectId}" was not found or the machine identity has no access to it. ` +
|
|
89
|
+
'Check the project ID and that the identity is a member of the project.', 'project');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Read-only validation: Universal Auth login works and, when a project ID was
|
|
93
|
+
* given, that project is accessible. Returns the project name when found.
|
|
94
|
+
*/
|
|
95
|
+
export async function validateInfisical(infisical) {
|
|
96
|
+
const token = await login(infisical);
|
|
97
|
+
if (infisical.projectId) {
|
|
98
|
+
const project = await findProjectById(infisical.host, token, infisical.projectId);
|
|
99
|
+
if (!project)
|
|
100
|
+
throw inaccessibleProject(infisical.projectId);
|
|
101
|
+
return { projectName: project.name };
|
|
102
|
+
}
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
81
105
|
/** Create/reuse the project, ensure every environment, seed placeholder secrets. */
|
|
82
106
|
export async function bootstrapInfisical(answers) {
|
|
83
|
-
const { host, projectName } = answers.infisical;
|
|
84
|
-
const token = await login(answers);
|
|
107
|
+
const { host, projectName, projectId } = answers.infisical;
|
|
108
|
+
const token = await login(answers.infisical);
|
|
85
109
|
let createdProject = false;
|
|
86
|
-
let project
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
110
|
+
let project;
|
|
111
|
+
if (projectId) {
|
|
112
|
+
// An explicit ID is never created implicitly: fail loudly if unreachable.
|
|
113
|
+
project = await findProjectById(host, token, projectId);
|
|
114
|
+
if (!project)
|
|
115
|
+
throw inaccessibleProject(projectId);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// Find-by-name keeps re-runs idempotent even without a recorded ID.
|
|
119
|
+
project = await findProject(host, token, projectName);
|
|
120
|
+
if (!project) {
|
|
121
|
+
project = await createProject(host, token, projectName);
|
|
122
|
+
createdProject = true;
|
|
123
|
+
}
|
|
90
124
|
}
|
|
91
125
|
for (const env of answers.environments) {
|
|
92
126
|
await ensureEnvironment(host, token, project, env.slug, env.displayName);
|
|
@@ -96,12 +130,15 @@ export async function bootstrapInfisical(answers) {
|
|
|
96
130
|
const placeholder = 'placeholder-updated-by-pipeline-after-first-apply';
|
|
97
131
|
for (const env of answers.environments) {
|
|
98
132
|
if (env.basicAuth) {
|
|
99
|
-
|
|
100
|
-
await seedSecret(host, token, project.id, env.slug,
|
|
133
|
+
const [userKey, passwordKey] = BASIC_AUTH_SECRET_KEYS;
|
|
134
|
+
await seedSecret(host, token, project.id, env.slug, userKey, env.slug);
|
|
135
|
+
await seedSecret(host, token, project.id, env.slug, passwordKey, randomBytes(18).toString('base64url'));
|
|
136
|
+
}
|
|
137
|
+
for (const key of BASE_SYNCED_KEYS) {
|
|
138
|
+
await seedSecret(host, token, project.id, env.slug, key, placeholder);
|
|
101
139
|
}
|
|
102
|
-
await seedSecret(host, token, project.id, env.slug, 'DATABASE_URL', placeholder);
|
|
103
140
|
if (answers.objectStorage) {
|
|
104
|
-
for (const key of
|
|
141
|
+
for (const key of S3_SECRET_KEYS) {
|
|
105
142
|
await seedSecret(host, token, project.id, env.slug, key, placeholder);
|
|
106
143
|
}
|
|
107
144
|
}
|
|
@@ -1,30 +1,36 @@
|
|
|
1
|
-
import { CreateBucketCommand, HeadBucketCommand, PutBucketVersioningCommand, S3Client, } from '@aws-sdk/client-s3';
|
|
1
|
+
import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, PutBucketPolicyCommand, PutBucketVersioningCommand, S3Client, } from '@aws-sdk/client-s3';
|
|
2
2
|
export class ScalewayError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(message, code = 'api') {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
3
8
|
}
|
|
4
9
|
const API_BASE = 'https://api.scaleway.com';
|
|
5
10
|
/**
|
|
6
11
|
* Validate credentials with a harmless read call before creating anything.
|
|
7
|
-
* Also confirms the project belongs to the given organization.
|
|
12
|
+
* 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.
|
|
8
14
|
*/
|
|
9
|
-
export async function validateScalewayCredentials(
|
|
10
|
-
const { secretKey, projectId, organizationId } =
|
|
15
|
+
export async function validateScalewayCredentials(scaleway) {
|
|
16
|
+
const { secretKey, projectId, organizationId } = scaleway;
|
|
11
17
|
const response = await fetch(`${API_BASE}/account/v3/projects/${projectId}`, {
|
|
12
18
|
headers: { 'X-Auth-Token': secretKey },
|
|
13
19
|
});
|
|
14
20
|
if (response.status === 401 || response.status === 403) {
|
|
15
21
|
throw new ScalewayError('Scaleway rejected the API key (401/403). Check SCW_ACCESS_KEY / SCW_SECRET_KEY and ' +
|
|
16
|
-
'make sure the key can read the project and manage Object Storage, Containers and Serverless SQL.');
|
|
22
|
+
'make sure the key can read the project and manage Object Storage, Containers and Serverless SQL.', 'auth');
|
|
17
23
|
}
|
|
18
24
|
if (response.status === 404) {
|
|
19
|
-
throw new ScalewayError(`Scaleway project ${projectId} not found with this API key
|
|
25
|
+
throw new ScalewayError(`Scaleway project ${projectId} not found with this API key.`, 'project');
|
|
20
26
|
}
|
|
21
27
|
if (!response.ok) {
|
|
22
|
-
throw new ScalewayError(`Scaleway API error while validating credentials: HTTP ${response.status}
|
|
28
|
+
throw new ScalewayError(`Scaleway API error while validating credentials: HTTP ${response.status}.`, 'api');
|
|
23
29
|
}
|
|
24
30
|
const project = (await response.json());
|
|
25
31
|
if (project.organization_id && project.organization_id !== organizationId) {
|
|
26
32
|
throw new ScalewayError(`Scaleway project ${projectId} belongs to organization ${project.organization_id}, ` +
|
|
27
|
-
`not ${organizationId}. Check --scw-organization-id
|
|
33
|
+
`not ${organizationId}. Check --scw-organization-id.`, 'organization');
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
36
|
function s3Client(answers) {
|
|
@@ -37,13 +43,90 @@ function s3Client(answers) {
|
|
|
37
43
|
},
|
|
38
44
|
});
|
|
39
45
|
}
|
|
40
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the IAM identity (user or application) that owns the API key, in
|
|
48
|
+
* the `<kind>:<uuid>` principal form bucket policies expect. Returns
|
|
49
|
+
* undefined when the key cannot read IAM; the caller degrades to a warning.
|
|
50
|
+
*/
|
|
51
|
+
async function apiKeyPrincipal(scaleway) {
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(`${API_BASE}/iam/v1alpha1/api-keys/${scaleway.accessKey}`, {
|
|
54
|
+
headers: { 'X-Auth-Token': scaleway.secretKey },
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
return undefined;
|
|
58
|
+
const key = (await response.json());
|
|
59
|
+
if (key.application_id)
|
|
60
|
+
return `application_id:${key.application_id}`;
|
|
61
|
+
if (key.user_id)
|
|
62
|
+
return `user_id:${key.user_id}`;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Restrict the state bucket to the identity that owns the bootstrap API key —
|
|
71
|
+
* the same identity CI authenticates with, so the pipeline keeps working.
|
|
72
|
+
* Terraform state contains the generated credentials (DATABASE_URL, S3 keys),
|
|
73
|
+
* and the per-app IAM policies are project-wide for Object Storage: without
|
|
74
|
+
* this policy the app's own storage credential could read the state. An
|
|
75
|
+
* existing policy is never overwritten. Failures degrade to a warning: a
|
|
76
|
+
* missed lockdown must not strand a half-finished bootstrap.
|
|
77
|
+
*/
|
|
78
|
+
async function lockDownStateBucket(client, bucket, answers) {
|
|
79
|
+
try {
|
|
80
|
+
await client.send(new GetBucketPolicyCommand({ Bucket: bucket }));
|
|
81
|
+
return undefined; // A policy is already in place: leave it alone.
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const status = error.$metadata?.httpStatusCode;
|
|
85
|
+
if (status !== 404) {
|
|
86
|
+
return (`Could not read the policy of bucket "${bucket}" (HTTP ${status ?? 'error'}); ` +
|
|
87
|
+
'the state bucket was NOT restricted to your identity.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const principal = await apiKeyPrincipal(answers.scaleway);
|
|
91
|
+
if (!principal) {
|
|
92
|
+
return ('Could not resolve the IAM identity behind the API key, so the state bucket was NOT ' +
|
|
93
|
+
'restricted: any Object Storage credential in the project can read the Terraform state. ' +
|
|
94
|
+
'Grant the key IAM read access and re-run, or add a bucket policy manually.');
|
|
95
|
+
}
|
|
96
|
+
const policy = {
|
|
97
|
+
Version: '2023-04-17',
|
|
98
|
+
Id: 'keel-state-bucket-bootstrap-identity-only',
|
|
99
|
+
Statement: [
|
|
100
|
+
{
|
|
101
|
+
Sid: 'OnlyBootstrapIdentity',
|
|
102
|
+
Effect: 'Allow',
|
|
103
|
+
Principal: { SCW: principal },
|
|
104
|
+
Action: ['*'],
|
|
105
|
+
Resource: [bucket, `${bucket}/*`],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
await client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) }));
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return (`Could not apply a policy to bucket "${bucket}" ` +
|
|
115
|
+
`(${error instanceof Error ? error.message : String(error)}); ` +
|
|
116
|
+
'the state bucket was NOT restricted to your identity.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Create the Terraform state bucket if it does not exist yet (idempotent) and
|
|
121
|
+
* restrict it to the bootstrap identity via a bucket policy.
|
|
122
|
+
*/
|
|
41
123
|
export async function ensureStateBucket(answers) {
|
|
42
124
|
const client = s3Client(answers);
|
|
43
125
|
const bucket = answers.stateBucket;
|
|
126
|
+
let created = false;
|
|
44
127
|
try {
|
|
45
128
|
await client.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
46
|
-
|
|
129
|
+
// Already exists and we own it (resume): still ensure the policy below.
|
|
47
130
|
}
|
|
48
131
|
catch (error) {
|
|
49
132
|
const status = error.$metadata?.httpStatusCode;
|
|
@@ -54,12 +137,14 @@ export async function ensureStateBucket(answers) {
|
|
|
54
137
|
if (status !== 404 && status !== 301) {
|
|
55
138
|
throw error;
|
|
56
139
|
}
|
|
140
|
+
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
141
|
+
// Versioning protects the state history against accidental overwrites.
|
|
142
|
+
await client.send(new PutBucketVersioningCommand({
|
|
143
|
+
Bucket: bucket,
|
|
144
|
+
VersioningConfiguration: { Status: 'Enabled' },
|
|
145
|
+
}));
|
|
146
|
+
created = true;
|
|
57
147
|
}
|
|
58
|
-
await client
|
|
59
|
-
|
|
60
|
-
await client.send(new PutBucketVersioningCommand({
|
|
61
|
-
Bucket: bucket,
|
|
62
|
-
VersioningConfiguration: { Status: 'Enabled' },
|
|
63
|
-
}));
|
|
64
|
-
return { created: true };
|
|
148
|
+
const policyWarning = await lockDownStateBucket(client, bucket, answers);
|
|
149
|
+
return policyWarning ? { created, policyWarning } : { created };
|
|
65
150
|
}
|