@gambi97/keel-cli 0.1.1 → 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 +107 -48
- package/dist/bootstrap/github.js +97 -37
- package/dist/bootstrap/infisical.js +70 -21
- package/dist/bootstrap/scaleway.js +102 -17
- package/dist/config.js +124 -14
- package/dist/contracts.js +46 -0
- package/dist/generate.js +60 -4
- package/dist/index.js +239 -128
- package/dist/prompts.js +270 -63
- package/dist/ui.js +18 -6
- package/package.json +1 -1
- package/templates/.github/scripts/sync-secrets.sh +65 -0
- package/templates/.github/workflows/terraform-drift.yml +19 -1
- package/templates/.github/workflows/terraform-plan.yml +19 -1
- package/templates/README.md +56 -42
- package/templates/_partials/apply-header.yml +33 -0
- package/templates/_partials/apply-job.yml +34 -0
- package/templates/env.tfvars +8 -0
- package/templates/main.tf +12 -1
- package/templates/modules/app_stack/main.tf +34 -0
- package/templates/modules/app_stack/outputs.tf +18 -0
- package/templates/modules/app_stack/variables.tf +5 -0
- package/templates/outputs.tf +22 -0
- package/templates/variables.tf +8 -2
- package/templates/.github/scripts/sync-database-url.sh +0 -49
- package/templates/.github/workflows/terraform-apply.yml +0 -103
- package/templates/prod.tfvars +0 -7
- package/templates/staging.tfvars +0 -7
package/README.md
CHANGED
|
@@ -28,10 +28,19 @@ 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
|
-
- **Scales with your product.**
|
|
34
|
-
|
|
41
|
+
- **Scales with your product.** Pick your environments (production only, or
|
|
42
|
+
add staging and dev) and, if you need it, an application file store; growing
|
|
43
|
+
is a one-line tfvars change reviewed in a PR.
|
|
35
44
|
- **Nothing to install.** No Terraform, no cloud CLI. Node and git, done.
|
|
36
45
|
- **No secrets in the repo, ever.** Encrypted CI secrets and a dedicated
|
|
37
46
|
secret manager, wired for you.
|
|
@@ -46,10 +55,13 @@ GitHub (see [Prerequisites](#prerequisites) for how to get each one).
|
|
|
46
55
|
npx @gambi97/keel-cli
|
|
47
56
|
```
|
|
48
57
|
|
|
49
|
-
The CLI asks for a project name, region
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
53
65
|
first PR) and the pipeline provisions the infrastructure.
|
|
54
66
|
|
|
55
67
|
Non-interactive and dry-run:
|
|
@@ -151,21 +163,22 @@ local tooling or production credentials.
|
|
|
151
163
|
|
|
152
164
|
**Phase A: bootstrap (the CLI, via APIs, after your confirmation)**
|
|
153
165
|
|
|
154
|
-
| Where | What
|
|
155
|
-
| ------------ |
|
|
156
|
-
| Your machine | The generated repo: Terraform, workflows, README, initial git commit
|
|
157
|
-
| Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking)
|
|
158
|
-
| GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables;
|
|
159
|
-
| Infisical | A project with
|
|
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), a `DATABASE_URL` placeholder per environment, and `S3_*` placeholders when Object Storage is enabled |
|
|
160
172
|
|
|
161
173
|
**Phase B: first deploy (Terraform in GitHub Actions, on push to `main`)**
|
|
162
174
|
|
|
163
|
-
| Scaleway resource
|
|
164
|
-
|
|
|
165
|
-
| Registry namespace
|
|
166
|
-
| Container namespace + Serverless Container
|
|
167
|
-
| Serverless SQL Database
|
|
168
|
-
| IAM application + API key
|
|
175
|
+
| Scaleway resource | Notes |
|
|
176
|
+
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
177
|
+
| Registry namespace | Private, one per environment |
|
|
178
|
+
| Container namespace + Serverless Container | The container appears once you set `container_image` in the tfvars; registry and database are created right away |
|
|
179
|
+
| Serverless SQL Database | One per environment; after each apply the pipeline writes a ready-to-use connection string to Infisical as `DATABASE_URL` |
|
|
180
|
+
| IAM application + API key | Dedicated credential that can only read/write the database (least privilege), embedded in `DATABASE_URL` |
|
|
181
|
+
| Object Storage bucket + credential (opt-in) | Only with `--object-storage`: one bucket per environment plus a dedicated least-privilege credential; coordinates synced to Infisical as `S3_*` |
|
|
169
182
|
|
|
170
183
|
No custom domain is configured: the app gets an auto-generated Scaleway URL.
|
|
171
184
|
Add one later with a single `scaleway_container_domain` resource.
|
|
@@ -176,28 +189,29 @@ Add one later with a single `scaleway_container_domain` resource.
|
|
|
176
189
|
my-app/
|
|
177
190
|
├── README.md # operating manual for the repo
|
|
178
191
|
├── .github/workflows/
|
|
179
|
-
│ ├── terraform-plan.yml # PR: fmt + validate + plan (
|
|
180
|
-
│ ├── terraform-apply.yml # main: apply
|
|
192
|
+
│ ├── terraform-plan.yml # PR: fmt + validate + plan (every environment)
|
|
193
|
+
│ ├── terraform-apply.yml # main: apply each env in order; production gated
|
|
181
194
|
│ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
|
|
182
195
|
├── versions.tf · providers.tf · backend.tf
|
|
183
196
|
├── backend.hcl.example # state bucket coordinates (backend.hcl is git-ignored)
|
|
184
197
|
├── variables.tf · main.tf · outputs.tf
|
|
185
|
-
├──
|
|
186
|
-
└── modules/app_stack/ # registry + container + database +
|
|
198
|
+
├── <env>.tfvars # one per environment, non-sensitive config, committed
|
|
199
|
+
└── modules/app_stack/ # registry + container + database (+ object storage) + credentials
|
|
187
200
|
```
|
|
188
201
|
|
|
189
|
-
Environments are separated with **Terraform workspaces**: same code,
|
|
190
|
-
independent
|
|
191
|
-
files.
|
|
202
|
+
Environments are separated with **Terraform workspaces**: same code, one
|
|
203
|
+
independent state per environment in one bucket, differences confined to the
|
|
204
|
+
per-environment `<env>.tfvars` files.
|
|
192
205
|
|
|
193
|
-
| Data
|
|
194
|
-
|
|
|
195
|
-
| Scaleway API keys
|
|
196
|
-
| Infisical machine identity
|
|
197
|
-
| Basic Auth user/password
|
|
198
|
-
| Database connection string
|
|
199
|
-
|
|
|
200
|
-
|
|
|
206
|
+
| Data | Lives in | Why |
|
|
207
|
+
| ----------------------------------- | ------------------------ | -------------------------------------------------------------------- |
|
|
208
|
+
| Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform |
|
|
209
|
+
| Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply |
|
|
210
|
+
| Basic Auth user/password | Infisical (non-prod) | App secret, injected into the container, rotatable |
|
|
211
|
+
| Database connection string | Infisical (each env) | Complete, ready-to-use value synced by the pipeline after each apply |
|
|
212
|
+
| Object Storage coordinates (opt-in) | Infisical (each env) | `S3_*` values synced by the pipeline after each apply |
|
|
213
|
+
| Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
|
|
214
|
+
| Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
|
|
201
215
|
|
|
202
216
|
## After the bootstrap
|
|
203
217
|
|
|
@@ -206,19 +220,21 @@ day-2 work (scaling, rotating secrets, custom domains, troubleshooting). The
|
|
|
206
220
|
short version of the first deploy:
|
|
207
221
|
|
|
208
222
|
1. **Push to `main`** (or merge a PR): the pipeline provisions registry and
|
|
209
|
-
databases
|
|
223
|
+
databases (and an Object Storage bucket, if enabled). Approve the
|
|
224
|
+
`production` gate when prompted.
|
|
210
225
|
2. **Build and push your app image** to the registry endpoint from the apply
|
|
211
|
-
output:
|
|
226
|
+
output (replace `<env>` with your target environment):
|
|
212
227
|
```sh
|
|
213
|
-
docker login rg.fr-par.scw.cloud/my-app
|
|
214
|
-
docker build -t rg.fr-par.scw.cloud/my-app
|
|
215
|
-
docker push rg.fr-par.scw.cloud/my-app
|
|
228
|
+
docker login rg.fr-par.scw.cloud/my-app-<env> -u nologin --password-stdin <<< "$SCW_SECRET_KEY"
|
|
229
|
+
docker build -t rg.fr-par.scw.cloud/my-app-<env>/app:latest .
|
|
230
|
+
docker push rg.fr-par.scw.cloud/my-app-<env>/app:latest
|
|
216
231
|
```
|
|
217
232
|
3. **Set `container_image`** in the tfvars and open a PR: the next apply
|
|
218
233
|
creates the containers.
|
|
219
234
|
4. **Replace the placeholder secrets** in Infisical with real values. The app
|
|
220
|
-
reads `DATABASE_URL` and `
|
|
221
|
-
|
|
235
|
+
reads `DATABASE_URL`, `BASIC_AUTH_*` and (if enabled) `S3_*` from its
|
|
236
|
+
environment; non-production environments also receive `BASIC_AUTH_ENABLED=true`
|
|
237
|
+
and enforce it.
|
|
222
238
|
|
|
223
239
|
## Prerequisites
|
|
224
240
|
|
|
@@ -278,9 +294,18 @@ creating anything**.
|
|
|
278
294
|
plan, production applies need manual approval.
|
|
279
295
|
- Terraform state lives in a private, versioned bucket with S3-native state
|
|
280
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.
|
|
281
304
|
- The app connects to its database with a **dedicated least-privilege IAM
|
|
282
305
|
credential** (read/write on that database, nothing else), not with your
|
|
283
|
-
main API key.
|
|
306
|
+
main API key. Object Storage, when enabled, gets its own separate dedicated
|
|
307
|
+
credential that can only use Object Storage (and, thanks to the bucket
|
|
308
|
+
policy above, cannot read the Terraform state).
|
|
284
309
|
- A weekly drift-detection plan opens an issue when the real infrastructure
|
|
285
310
|
no longer matches the code.
|
|
286
311
|
|
|
@@ -296,6 +321,7 @@ included (Scaleway `fr-par` list prices, excl. VAT):
|
|
|
296
321
|
| Serverless SQL (both envs) | idle most of the time | ~€0.20 storage + a few cents of compute |
|
|
297
322
|
| Object Storage (Terraform state) | a few MB | ~€0 |
|
|
298
323
|
| Container Registry | 1-2 image versions | ~€0.05 (€0 if the registry is public) |
|
|
324
|
+
| Object Storage (optional) | only if `--object-storage` | ~€0 idle (a few GB of storage when used) |
|
|
299
325
|
| **Total to start** | | **under ~€1 / month** |
|
|
300
326
|
|
|
301
327
|
Compute is billed per second, only while actually serving: an idle container
|
|
@@ -321,6 +347,17 @@ No. The CLI bootstraps via APIs; Terraform runs inside GitHub Actions.
|
|
|
321
347
|
Yes. The CLI asks for the name and the visibility; default is public (the
|
|
322
348
|
infra holds no secrets), or choose private interactively or with `--private`.
|
|
323
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
|
+
|
|
324
361
|
**Why is the container not created on the first apply?**
|
|
325
362
|
A Serverless Container needs an image, and none exists yet. Registry and
|
|
326
363
|
database are created immediately; the container is gated on
|
|
@@ -331,9 +368,21 @@ Scaleway Serverless Containers have no built-in auth in front of public
|
|
|
331
368
|
endpoints. Credentials live in Infisical, the container gets
|
|
332
369
|
`BASIC_AUTH_ENABLED=true`, and a few lines of middleware enforce it.
|
|
333
370
|
|
|
334
|
-
**
|
|
335
|
-
|
|
336
|
-
|
|
371
|
+
**Which environments do I get, and can I change them?**
|
|
372
|
+
You choose at creation: production only, staging + production (default), or
|
|
373
|
+
dev + staging + production — interactively or with `--environments`
|
|
374
|
+
(e.g. `--environments prod` or `--environments dev,staging,prod`). Production
|
|
375
|
+
is always gated by a manual approval; non-production environments enable Basic
|
|
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`.
|
|
380
|
+
|
|
381
|
+
**Can I store files, not just rows?**
|
|
382
|
+
Yes, opt in with `--object-storage` (or answer yes interactively). Each
|
|
383
|
+
environment gets its own Object Storage bucket and a dedicated credential; the
|
|
384
|
+
pipeline syncs the `S3_*` coordinates to Infisical for the app to read. It is
|
|
385
|
+
off by default — many apps only need the database.
|
|
337
386
|
|
|
338
387
|
## CLI reference
|
|
339
388
|
|
|
@@ -348,13 +397,21 @@ one job in each workflow.
|
|
|
348
397
|
--infisical-host <url> or env INFISICAL_HOST (default: https://app.infisical.com)
|
|
349
398
|
--infisical-client-id <id> or env INFISICAL_CLIENT_ID
|
|
350
399
|
--infisical-client-secret <s> or env INFISICAL_CLIENT_SECRET
|
|
351
|
-
--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)
|
|
352
403
|
--github-token <token> or env GITHUB_TOKEN / GH_TOKEN (scopes: repo, workflow)
|
|
353
404
|
--repo-name <name> GitHub repository name (default: project name)
|
|
354
405
|
--private / --public Repository visibility (default: public)
|
|
355
|
-
--
|
|
406
|
+
--environments <preset> prod | staging+prod | dev+staging+prod
|
|
407
|
+
(or a list like "dev,staging,prod"; default staging+prod)
|
|
408
|
+
--object-storage Provision a per-environment Object Storage bucket
|
|
409
|
+
--no-object-storage Do not provision Object Storage (default)
|
|
410
|
+
--basic-auth Enable Basic Auth on non-production environments (default)
|
|
411
|
+
--no-basic-auth Disable Basic Auth on non-production environments
|
|
412
|
+
--dev-min-scale <n> Default 0 --dev-max-scale <n> Default 1
|
|
356
413
|
--staging-min-scale <n> Default 0 --staging-max-scale <n> Default 1
|
|
357
|
-
--prod-min-scale <n> Default 0 --prod-max-scale <n> Default
|
|
414
|
+
--prod-min-scale <n> Default 0 --prod-max-scale <n> Default 1
|
|
358
415
|
--config <file.json> Load answers from a JSON file
|
|
359
416
|
--advanced Also ask scaling questions interactively
|
|
360
417
|
--yes Accept defaults, skip the confirmation prompt
|
|
@@ -368,8 +425,10 @@ is taken from the environment or asked interactively:
|
|
|
368
425
|
{
|
|
369
426
|
"projectName": "my-app",
|
|
370
427
|
"region": "fr-par",
|
|
428
|
+
"environments": ["staging", "prod"],
|
|
429
|
+
"objectStorage": true,
|
|
371
430
|
"github": { "repoPrivate": true },
|
|
372
|
-
"scaling": { "
|
|
431
|
+
"scaling": { "prod": { "maxScale": 4 } }
|
|
373
432
|
}
|
|
374
433
|
```
|
|
375
434
|
|
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,23 +157,27 @@ 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
|
-
}
|
|
118
|
-
await
|
|
119
|
-
await
|
|
177
|
+
};
|
|
178
|
+
await setVariables(ctx, variables);
|
|
179
|
+
await configureEnvironments(ctx, answers);
|
|
180
|
+
await protectMainBranch(ctx, answers);
|
|
120
181
|
}
|
|
121
182
|
async function setSecrets(ctx, secrets) {
|
|
122
183
|
await sodium.ready;
|
|
@@ -157,29 +218,28 @@ async function setVariables(ctx, variables) {
|
|
|
157
218
|
}
|
|
158
219
|
}
|
|
159
220
|
}
|
|
160
|
-
/**
|
|
161
|
-
async function configureEnvironments(ctx) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
environment_name: 'production',
|
|
171
|
-
reviewers: [{ type: 'User', id: ctx.ownerId }],
|
|
172
|
-
});
|
|
221
|
+
/** Each environment gets a GitHub deployment environment; gated ones (prod) require approval. */
|
|
222
|
+
async function configureEnvironments(ctx, answers) {
|
|
223
|
+
for (const env of answers.environments) {
|
|
224
|
+
await ctx.octokit.repos.createOrUpdateEnvironment({
|
|
225
|
+
owner: ctx.owner,
|
|
226
|
+
repo: ctx.repo,
|
|
227
|
+
environment_name: env.githubEnvironment,
|
|
228
|
+
...(env.gated ? { reviewers: [{ type: 'User', id: ctx.ownerId }] } : {}),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
173
231
|
}
|
|
174
232
|
/** Require a green plan on PRs and forbid force-pushes/deletion of main. */
|
|
175
|
-
async function protectMainBranch(ctx) {
|
|
233
|
+
async function protectMainBranch(ctx, answers) {
|
|
176
234
|
await ctx.octokit.repos.updateBranchProtection({
|
|
177
235
|
owner: ctx.owner,
|
|
178
236
|
repo: ctx.repo,
|
|
179
237
|
branch: 'main',
|
|
180
238
|
required_status_checks: {
|
|
181
239
|
strict: false,
|
|
182
|
-
|
|
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)),
|
|
183
243
|
},
|
|
184
244
|
enforce_admins: false,
|
|
185
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 } });
|
|
@@ -43,10 +56,9 @@ async function createProject(host, token, name) {
|
|
|
43
56
|
}
|
|
44
57
|
return data.project;
|
|
45
58
|
}
|
|
46
|
-
async function ensureEnvironment(host, token, project, slug) {
|
|
59
|
+
async function ensureEnvironment(host, token, project, slug, name) {
|
|
47
60
|
if (project.environments?.some((e) => e.slug === slug))
|
|
48
61
|
return;
|
|
49
|
-
const name = slug === 'prod' ? 'Production' : 'Staging';
|
|
50
62
|
const { status, data } = await api(host, `/api/v1/workspace/${project.id}/environments`, { method: 'POST', token, body: { name, slug } });
|
|
51
63
|
// 400 usually means the environment already exists: fine for idempotency.
|
|
52
64
|
if (status !== 200 && status !== 400) {
|
|
@@ -72,24 +84,61 @@ async function seedSecret(host, token, projectId, environment, name, value) {
|
|
|
72
84
|
throw new InfisicalError(`Could not seed secret ${name} in ${environment} (HTTP ${status}${data.message ? `: ${data.message}` : ''}).`);
|
|
73
85
|
}
|
|
74
86
|
}
|
|
75
|
-
|
|
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
|
+
}
|
|
105
|
+
/** Create/reuse the project, ensure every environment, seed placeholder secrets. */
|
|
76
106
|
export async function bootstrapInfisical(answers) {
|
|
77
|
-
const { host, projectName } = answers.infisical;
|
|
78
|
-
const token = await login(answers);
|
|
107
|
+
const { host, projectName, projectId } = answers.infisical;
|
|
108
|
+
const token = await login(answers.infisical);
|
|
79
109
|
let createdProject = false;
|
|
80
|
-
let project
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
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);
|
|
84
116
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
}
|
|
125
|
+
for (const env of answers.environments) {
|
|
126
|
+
await ensureEnvironment(host, token, project, env.slug, env.displayName);
|
|
127
|
+
}
|
|
128
|
+
// Real values arrive from the pipeline after the first apply; seed placeholders
|
|
129
|
+
// now so the secret paths exist and the container has something to read.
|
|
91
130
|
const placeholder = 'placeholder-updated-by-pipeline-after-first-apply';
|
|
92
|
-
|
|
93
|
-
|
|
131
|
+
for (const env of answers.environments) {
|
|
132
|
+
if (env.basicAuth) {
|
|
133
|
+
await seedSecret(host, token, project.id, env.slug, 'BASIC_AUTH_USER', env.slug);
|
|
134
|
+
await seedSecret(host, token, project.id, env.slug, 'BASIC_AUTH_PASSWORD', randomBytes(18).toString('base64url'));
|
|
135
|
+
}
|
|
136
|
+
await seedSecret(host, token, project.id, env.slug, 'DATABASE_URL', placeholder);
|
|
137
|
+
if (answers.objectStorage) {
|
|
138
|
+
for (const key of S3_SECRET_KEYS) {
|
|
139
|
+
await seedSecret(host, token, project.id, env.slug, key, placeholder);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
94
143
|
return { projectId: project.id, createdProject };
|
|
95
144
|
}
|