@gambi97/keel-cli 0.2.0 → 0.2.1
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 +45 -11
- package/dist/bootstrap/github.js +84 -22
- package/dist/bootstrap/infisical.js +53 -19
- package/dist/bootstrap/scaleway.js +102 -17
- package/dist/config.js +7 -1
- package/dist/contracts.js +46 -0
- package/dist/generate.js +9 -3
- package/dist/index.js +213 -130
- package/dist/prompts.js +246 -66
- package/dist/ui.js +3 -1
- package/package.json +1 -1
- package/templates/.github/workflows/terraform-drift.yml +19 -1
- package/templates/.github/workflows/terraform-plan.yml +19 -1
- package/templates/README.md +6 -3
- package/templates/main.tf +10 -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:
|
|
@@ -155,7 +166,7 @@ local tooling or production credentials.
|
|
|
155
166
|
| Where | What |
|
|
156
167
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
157
168
|
| 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)
|
|
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) |
|
|
159
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` |
|
|
160
171
|
| Infisical | A project with one environment per selected environment, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (non-production, random password), a `DATABASE_URL` placeholder per environment, and `S3_*` placeholders when Object Storage is enabled |
|
|
161
172
|
|
|
@@ -283,10 +294,18 @@ creating anything**.
|
|
|
283
294
|
plan, production applies need manual approval.
|
|
284
295
|
- Terraform state lives in a private, versioned bucket with S3-native state
|
|
285
296
|
locking (`use_lockfile`), so concurrent applies cannot corrupt it.
|
|
297
|
+
- The state bucket is **restricted by a bucket policy** to the identity that
|
|
298
|
+
owns the API key you give keel — the same identity CI authenticates with,
|
|
299
|
+
so the pipeline keeps working. Terraform state contains the generated
|
|
300
|
+
credentials (`DATABASE_URL`, `S3_*`), so no other principal in the project —
|
|
301
|
+
including the app's own credentials — can read it. Note that this also
|
|
302
|
+
hides the bucket from other console users; manage or remove the policy with
|
|
303
|
+
that same API key if you need to open it up.
|
|
286
304
|
- The app connects to its database with a **dedicated least-privilege IAM
|
|
287
305
|
credential** (read/write on that database, nothing else), not with your
|
|
288
306
|
main API key. Object Storage, when enabled, gets its own separate dedicated
|
|
289
|
-
credential the
|
|
307
|
+
credential that can only use Object Storage (and, thanks to the bucket
|
|
308
|
+
policy above, cannot read the Terraform state).
|
|
290
309
|
- A weekly drift-detection plan opens an issue when the real infrastructure
|
|
291
310
|
no longer matches the code.
|
|
292
311
|
|
|
@@ -328,6 +347,17 @@ No. The CLI bootstraps via APIs; Terraform runs inside GitHub Actions.
|
|
|
328
347
|
Yes. The CLI asks for the name and the visibility; default is public (the
|
|
329
348
|
infra holds no secrets), or choose private interactively or with `--private`.
|
|
330
349
|
|
|
350
|
+
**Can I point keel at an existing repository?**
|
|
351
|
+
Only if it has no commits. keel pushes a brand-new history, so a repository
|
|
352
|
+
that already has commits (even just a README from the GitHub UI) would reject
|
|
353
|
+
the push; the CLI checks this up front and asks for another name. The easiest
|
|
354
|
+
path is to let keel create the repository for you.
|
|
355
|
+
|
|
356
|
+
**Can I reuse an existing Infisical project?**
|
|
357
|
+
Yes: pass its project ID (`--infisical-project-id` or interactively). The CLI
|
|
358
|
+
verifies the machine identity can access it before anything is created. Leave
|
|
359
|
+
it empty and keel creates a project named after your app instead.
|
|
360
|
+
|
|
331
361
|
**Why is the container not created on the first apply?**
|
|
332
362
|
A Serverless Container needs an image, and none exists yet. Registry and
|
|
333
363
|
database are created immediately; the container is gated on
|
|
@@ -343,9 +373,10 @@ You choose at creation: production only, staging + production (default), or
|
|
|
343
373
|
dev + staging + production — interactively or with `--environments`
|
|
344
374
|
(e.g. `--environments prod` or `--environments dev,staging,prod`). Production
|
|
345
375
|
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
|
-
|
|
376
|
+
Auth by default. To add one to an existing repo later: add a `<env>.tfvars`
|
|
377
|
+
(the plan and drift workflows discover environments from the tfvars files at
|
|
378
|
+
the repo root automatically), an Infisical environment with the same slug, and
|
|
379
|
+
a chained job in `terraform-apply.yml`.
|
|
349
380
|
|
|
350
381
|
**Can I store files, not just rows?**
|
|
351
382
|
Yes, opt in with `--object-storage` (or answer yes interactively). Each
|
|
@@ -366,7 +397,9 @@ off by default — many apps only need the database.
|
|
|
366
397
|
--infisical-host <url> or env INFISICAL_HOST (default: https://app.infisical.com)
|
|
367
398
|
--infisical-client-id <id> or env INFISICAL_CLIENT_ID
|
|
368
399
|
--infisical-client-secret <s> or env INFISICAL_CLIENT_SECRET
|
|
369
|
-
--infisical-project-
|
|
400
|
+
--infisical-project-id <id> Existing Infisical project ID to reuse
|
|
401
|
+
(or env INFISICAL_PROJECT_ID; default: create by name)
|
|
402
|
+
--infisical-project-name <n> Infisical project name (default: project name)
|
|
370
403
|
--github-token <token> or env GITHUB_TOKEN / GH_TOKEN (scopes: repo, workflow)
|
|
371
404
|
--repo-name <name> GitHub repository name (default: project name)
|
|
372
405
|
--private / --public Repository visibility (default: public)
|
|
@@ -374,10 +407,11 @@ off by default — many apps only need the database.
|
|
|
374
407
|
(or a list like "dev,staging,prod"; default staging+prod)
|
|
375
408
|
--object-storage Provision a per-environment Object Storage bucket
|
|
376
409
|
--no-object-storage Do not provision Object Storage (default)
|
|
410
|
+
--basic-auth Enable Basic Auth on non-production environments (default)
|
|
377
411
|
--no-basic-auth Disable Basic Auth on non-production environments
|
|
378
412
|
--dev-min-scale <n> Default 0 --dev-max-scale <n> Default 1
|
|
379
413
|
--staging-min-scale <n> Default 0 --staging-max-scale <n> Default 1
|
|
380
|
-
--prod-min-scale <n> Default 0 --prod-max-scale <n> Default
|
|
414
|
+
--prod-min-scale <n> Default 0 --prod-max-scale <n> Default 1
|
|
381
415
|
--config <file.json> Load answers from a JSON file
|
|
382
416
|
--advanced Also ask scaling questions interactively
|
|
383
417
|
--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 { 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);
|
|
@@ -101,7 +135,7 @@ export async function bootstrapInfisical(answers) {
|
|
|
101
135
|
}
|
|
102
136
|
await seedSecret(host, token, project.id, env.slug, 'DATABASE_URL', placeholder);
|
|
103
137
|
if (answers.objectStorage) {
|
|
104
|
-
for (const key of
|
|
138
|
+
for (const key of S3_SECRET_KEYS) {
|
|
105
139
|
await seedSecret(host, token, project.id, env.slug, key, placeholder);
|
|
106
140
|
}
|
|
107
141
|
}
|
|
@@ -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
|
}
|
package/dist/config.js
CHANGED
|
@@ -29,7 +29,9 @@ const ENV_DEFAULTS = {
|
|
|
29
29
|
production: true,
|
|
30
30
|
gated: true,
|
|
31
31
|
minScale: 0,
|
|
32
|
-
|
|
32
|
+
// Start small: one instance is enough to go live and costs the least;
|
|
33
|
+
// raising the ceiling later is a one-line prod.tfvars change.
|
|
34
|
+
maxScale: 1,
|
|
33
35
|
},
|
|
34
36
|
};
|
|
35
37
|
/** Default min/max scale for an environment, for prompts and summaries. */
|
|
@@ -134,6 +136,7 @@ export function fromEnv(env) {
|
|
|
134
136
|
host: env.INFISICAL_HOST,
|
|
135
137
|
clientId: env.INFISICAL_CLIENT_ID,
|
|
136
138
|
clientSecret: env.INFISICAL_CLIENT_SECRET,
|
|
139
|
+
projectId: env.INFISICAL_PROJECT_ID,
|
|
137
140
|
},
|
|
138
141
|
github: {
|
|
139
142
|
token: env.GITHUB_TOKEN ?? env.GH_TOKEN,
|
|
@@ -223,6 +226,9 @@ export function finalizeAnswers(partial) {
|
|
|
223
226
|
clientId: requireString(partial.infisical.clientId, 'Infisical client ID'),
|
|
224
227
|
clientSecret: requireString(partial.infisical.clientSecret, 'Infisical client secret'),
|
|
225
228
|
projectName: partial.infisical.projectName?.trim() || projectName,
|
|
229
|
+
...(partial.infisical.projectId?.trim()
|
|
230
|
+
? { projectId: partial.infisical.projectId.trim() }
|
|
231
|
+
: {}),
|
|
226
232
|
},
|
|
227
233
|
github: {
|
|
228
234
|
token: requireString(partial.github.token, 'GitHub token'),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names shared between the bootstrap code and the generated repository.
|
|
3
|
+
*
|
|
4
|
+
* The CLI configures the user's account by name (required status checks,
|
|
5
|
+
* Actions secrets/variables, seeded Infisical secrets) and the generated
|
|
6
|
+
* workflows and Terraform reference the same names. A rename on one side
|
|
7
|
+
* without the other passes CI here and breaks at runtime in the user's
|
|
8
|
+
* account — so both sides import these constants, and contracts.test.ts
|
|
9
|
+
* renders the templates and asserts they still agree.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Job name of the plan workflow for one environment; branch protection
|
|
13
|
+
* requires exactly these contexts before a PR can merge.
|
|
14
|
+
*/
|
|
15
|
+
export function planStatusCheckContext(environment) {
|
|
16
|
+
return `plan (${environment})`;
|
|
17
|
+
}
|
|
18
|
+
/** Root Terraform output consumed by .github/scripts/sync-secrets.sh. */
|
|
19
|
+
export const INFISICAL_SECRETS_OUTPUT = 'infisical_secrets';
|
|
20
|
+
/**
|
|
21
|
+
* Object Storage coordinates: seeded as placeholders by the CLI, produced by
|
|
22
|
+
* the infisical_secrets output, synced to Infisical by the pipeline.
|
|
23
|
+
*/
|
|
24
|
+
export const S3_SECRET_KEYS = [
|
|
25
|
+
'S3_BUCKET',
|
|
26
|
+
'S3_ENDPOINT',
|
|
27
|
+
'S3_REGION',
|
|
28
|
+
'S3_ACCESS_KEY',
|
|
29
|
+
'S3_SECRET_KEY',
|
|
30
|
+
];
|
|
31
|
+
/** Encrypted Actions secrets set by configureRepo, read by the workflows. */
|
|
32
|
+
export const CI_SECRET_NAMES = [
|
|
33
|
+
'SCW_ACCESS_KEY',
|
|
34
|
+
'SCW_SECRET_KEY',
|
|
35
|
+
'SCW_DEFAULT_PROJECT_ID',
|
|
36
|
+
'SCW_DEFAULT_ORGANIZATION_ID',
|
|
37
|
+
'INFISICAL_CLIENT_ID',
|
|
38
|
+
'INFISICAL_CLIENT_SECRET',
|
|
39
|
+
];
|
|
40
|
+
/** Plain Actions variables set by configureRepo, read by the workflows. */
|
|
41
|
+
export const CI_VARIABLE_NAMES = [
|
|
42
|
+
'TF_STATE_BUCKET',
|
|
43
|
+
'SCW_REGION',
|
|
44
|
+
'INFISICAL_PROJECT_ID',
|
|
45
|
+
'INFISICAL_HOST',
|
|
46
|
+
];
|
package/dist/generate.js
CHANGED
|
@@ -19,8 +19,6 @@ export function tokenMap(answers) {
|
|
|
19
19
|
__PROJECT_NAME__: answers.projectName,
|
|
20
20
|
__REGION__: answers.region,
|
|
21
21
|
__TF_STATE_BUCKET__: answers.stateBucket,
|
|
22
|
-
// YAML inline list for the plan/drift job matrix, e.g. [staging, prod].
|
|
23
|
-
__ENV_MATRIX__: `[${slugs.join(', ')}]`,
|
|
24
22
|
// HCL list for the `environment` variable validation in variables.tf.
|
|
25
23
|
__ENV_SLUGS_TF__: `[${slugs.map((s) => `"${s}"`).join(', ')}]`,
|
|
26
24
|
};
|
|
@@ -37,7 +35,15 @@ function envTfvarsTokens(answers, env) {
|
|
|
37
35
|
__MAX_SCALE__: String(env.maxScale),
|
|
38
36
|
};
|
|
39
37
|
}
|
|
40
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Templates rendered specially (per environment) instead of copied 1:1.
|
|
40
|
+
*
|
|
41
|
+
* Keep this set small: the templating here is plain token replacement by
|
|
42
|
+
* design. A conditional feature must become a Terraform variable rendered
|
|
43
|
+
* into tfvars (as enable_object_storage does), never a conditionally emitted
|
|
44
|
+
* file — the day a feature cannot be expressed that way is the day to adopt
|
|
45
|
+
* a real template engine, not to add another special case here.
|
|
46
|
+
*/
|
|
41
47
|
const SPECIAL_TEMPLATES = new Set(['env.tfvars', '.github/workflows/terraform-apply.yml']);
|
|
42
48
|
function isSpecial(rel) {
|
|
43
49
|
return SPECIAL_TEMPLATES.has(rel) || rel.startsWith('_partials/');
|