@gambi97/keel-cli 0.1.1 → 0.2.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 +67 -42
- package/dist/bootstrap/github.js +15 -17
- package/dist/bootstrap/infisical.js +25 -10
- package/dist/config.js +118 -14
- package/dist/generate.js +54 -4
- package/dist/index.js +41 -13
- package/dist/prompts.js +35 -8
- package/dist/ui.js +15 -5
- package/package.json +1 -1
- package/templates/.github/scripts/sync-secrets.sh +65 -0
- package/templates/.github/workflows/terraform-drift.yml +1 -1
- package/templates/.github/workflows/terraform-plan.yml +1 -1
- package/templates/README.md +53 -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 +2 -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
|
@@ -30,8 +30,9 @@ to `main`, and the infrastructure is live.
|
|
|
30
30
|
|
|
31
31
|
- **Near-free to start.** Compute and database scale to zero; an idle project
|
|
32
32
|
costs cents per month, with no expiring trial.
|
|
33
|
-
- **Scales with your product.**
|
|
34
|
-
|
|
33
|
+
- **Scales with your product.** Pick your environments (production only, or
|
|
34
|
+
add staging and dev) and, if you need it, an application file store; growing
|
|
35
|
+
is a one-line tfvars change reviewed in a PR.
|
|
35
36
|
- **Nothing to install.** No Terraform, no cloud CLI. Node and git, done.
|
|
36
37
|
- **No secrets in the repo, ever.** Encrypted CI secrets and a dedicated
|
|
37
38
|
secret manager, wired for you.
|
|
@@ -151,21 +152,22 @@ local tooling or production credentials.
|
|
|
151
152
|
|
|
152
153
|
**Phase A: bootstrap (the CLI, via APIs, after your confirmation)**
|
|
153
154
|
|
|
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
|
|
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), a `DATABASE_URL` placeholder per environment, and `S3_*` placeholders when Object Storage is enabled |
|
|
160
161
|
|
|
161
162
|
**Phase B: first deploy (Terraform in GitHub Actions, on push to `main`)**
|
|
162
163
|
|
|
163
|
-
| Scaleway resource
|
|
164
|
-
|
|
|
165
|
-
| Registry namespace
|
|
166
|
-
| Container namespace + Serverless Container
|
|
167
|
-
| Serverless SQL Database
|
|
168
|
-
| IAM application + API key
|
|
164
|
+
| Scaleway resource | Notes |
|
|
165
|
+
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
166
|
+
| Registry namespace | Private, one per environment |
|
|
167
|
+
| Container namespace + Serverless Container | The container appears once you set `container_image` in the tfvars; registry and database are created right away |
|
|
168
|
+
| Serverless SQL Database | One per environment; after each apply the pipeline writes a ready-to-use connection string to Infisical as `DATABASE_URL` |
|
|
169
|
+
| IAM application + API key | Dedicated credential that can only read/write the database (least privilege), embedded in `DATABASE_URL` |
|
|
170
|
+
| 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
171
|
|
|
170
172
|
No custom domain is configured: the app gets an auto-generated Scaleway URL.
|
|
171
173
|
Add one later with a single `scaleway_container_domain` resource.
|
|
@@ -176,28 +178,29 @@ Add one later with a single `scaleway_container_domain` resource.
|
|
|
176
178
|
my-app/
|
|
177
179
|
├── README.md # operating manual for the repo
|
|
178
180
|
├── .github/workflows/
|
|
179
|
-
│ ├── terraform-plan.yml # PR: fmt + validate + plan (
|
|
180
|
-
│ ├── terraform-apply.yml # main: apply
|
|
181
|
+
│ ├── terraform-plan.yml # PR: fmt + validate + plan (every environment)
|
|
182
|
+
│ ├── terraform-apply.yml # main: apply each env in order; production gated
|
|
181
183
|
│ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
|
|
182
184
|
├── versions.tf · providers.tf · backend.tf
|
|
183
185
|
├── backend.hcl.example # state bucket coordinates (backend.hcl is git-ignored)
|
|
184
186
|
├── variables.tf · main.tf · outputs.tf
|
|
185
|
-
├──
|
|
186
|
-
└── modules/app_stack/ # registry + container + database +
|
|
187
|
+
├── <env>.tfvars # one per environment, non-sensitive config, committed
|
|
188
|
+
└── modules/app_stack/ # registry + container + database (+ object storage) + credentials
|
|
187
189
|
```
|
|
188
190
|
|
|
189
|
-
Environments are separated with **Terraform workspaces**: same code,
|
|
190
|
-
independent
|
|
191
|
-
files.
|
|
191
|
+
Environments are separated with **Terraform workspaces**: same code, one
|
|
192
|
+
independent state per environment in one bucket, differences confined to the
|
|
193
|
+
per-environment `<env>.tfvars` files.
|
|
192
194
|
|
|
193
|
-
| Data
|
|
194
|
-
|
|
|
195
|
-
| Scaleway API keys
|
|
196
|
-
| Infisical machine identity
|
|
197
|
-
| Basic Auth user/password
|
|
198
|
-
| Database connection string
|
|
199
|
-
|
|
|
200
|
-
|
|
|
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
|
+
| Object Storage coordinates (opt-in) | Infisical (each env) | `S3_*` values synced by the pipeline after each apply |
|
|
202
|
+
| Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
|
|
203
|
+
| Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
|
|
201
204
|
|
|
202
205
|
## After the bootstrap
|
|
203
206
|
|
|
@@ -206,19 +209,21 @@ day-2 work (scaling, rotating secrets, custom domains, troubleshooting). The
|
|
|
206
209
|
short version of the first deploy:
|
|
207
210
|
|
|
208
211
|
1. **Push to `main`** (or merge a PR): the pipeline provisions registry and
|
|
209
|
-
databases
|
|
212
|
+
databases (and an Object Storage bucket, if enabled). Approve the
|
|
213
|
+
`production` gate when prompted.
|
|
210
214
|
2. **Build and push your app image** to the registry endpoint from the apply
|
|
211
|
-
output:
|
|
215
|
+
output (replace `<env>` with your target environment):
|
|
212
216
|
```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
|
|
217
|
+
docker login rg.fr-par.scw.cloud/my-app-<env> -u nologin --password-stdin <<< "$SCW_SECRET_KEY"
|
|
218
|
+
docker build -t rg.fr-par.scw.cloud/my-app-<env>/app:latest .
|
|
219
|
+
docker push rg.fr-par.scw.cloud/my-app-<env>/app:latest
|
|
216
220
|
```
|
|
217
221
|
3. **Set `container_image`** in the tfvars and open a PR: the next apply
|
|
218
222
|
creates the containers.
|
|
219
223
|
4. **Replace the placeholder secrets** in Infisical with real values. The app
|
|
220
|
-
reads `DATABASE_URL` and `
|
|
221
|
-
|
|
224
|
+
reads `DATABASE_URL`, `BASIC_AUTH_*` and (if enabled) `S3_*` from its
|
|
225
|
+
environment; non-production environments also receive `BASIC_AUTH_ENABLED=true`
|
|
226
|
+
and enforce it.
|
|
222
227
|
|
|
223
228
|
## Prerequisites
|
|
224
229
|
|
|
@@ -280,7 +285,8 @@ creating anything**.
|
|
|
280
285
|
locking (`use_lockfile`), so concurrent applies cannot corrupt it.
|
|
281
286
|
- The app connects to its database with a **dedicated least-privilege IAM
|
|
282
287
|
credential** (read/write on that database, nothing else), not with your
|
|
283
|
-
main API key.
|
|
288
|
+
main API key. Object Storage, when enabled, gets its own separate dedicated
|
|
289
|
+
credential the same way.
|
|
284
290
|
- A weekly drift-detection plan opens an issue when the real infrastructure
|
|
285
291
|
no longer matches the code.
|
|
286
292
|
|
|
@@ -296,6 +302,7 @@ included (Scaleway `fr-par` list prices, excl. VAT):
|
|
|
296
302
|
| Serverless SQL (both envs) | idle most of the time | ~€0.20 storage + a few cents of compute |
|
|
297
303
|
| Object Storage (Terraform state) | a few MB | ~€0 |
|
|
298
304
|
| Container Registry | 1-2 image versions | ~€0.05 (€0 if the registry is public) |
|
|
305
|
+
| Object Storage (optional) | only if `--object-storage` | ~€0 idle (a few GB of storage when used) |
|
|
299
306
|
| **Total to start** | | **under ~€1 / month** |
|
|
300
307
|
|
|
301
308
|
Compute is billed per second, only while actually serving: an idle container
|
|
@@ -331,9 +338,20 @@ Scaleway Serverless Containers have no built-in auth in front of public
|
|
|
331
338
|
endpoints. Credentials live in Infisical, the container gets
|
|
332
339
|
`BASIC_AUTH_ENABLED=true`, and a few lines of middleware enforce it.
|
|
333
340
|
|
|
334
|
-
**
|
|
335
|
-
|
|
336
|
-
|
|
341
|
+
**Which environments do I get, and can I change them?**
|
|
342
|
+
You choose at creation: production only, staging + production (default), or
|
|
343
|
+
dev + staging + production — interactively or with `--environments`
|
|
344
|
+
(e.g. `--environments prod` or `--environments dev,staging,prod`). Production
|
|
345
|
+
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
|
+
an Infisical environment, a matrix entry in the plan/drift workflows, and a
|
|
348
|
+
chained job in `terraform-apply.yml`.
|
|
349
|
+
|
|
350
|
+
**Can I store files, not just rows?**
|
|
351
|
+
Yes, opt in with `--object-storage` (or answer yes interactively). Each
|
|
352
|
+
environment gets its own Object Storage bucket and a dedicated credential; the
|
|
353
|
+
pipeline syncs the `S3_*` coordinates to Infisical for the app to read. It is
|
|
354
|
+
off by default — many apps only need the database.
|
|
337
355
|
|
|
338
356
|
## CLI reference
|
|
339
357
|
|
|
@@ -352,7 +370,12 @@ one job in each workflow.
|
|
|
352
370
|
--github-token <token> or env GITHUB_TOKEN / GH_TOKEN (scopes: repo, workflow)
|
|
353
371
|
--repo-name <name> GitHub repository name (default: project name)
|
|
354
372
|
--private / --public Repository visibility (default: public)
|
|
355
|
-
--
|
|
373
|
+
--environments <preset> prod | staging+prod | dev+staging+prod
|
|
374
|
+
(or a list like "dev,staging,prod"; default staging+prod)
|
|
375
|
+
--object-storage Provision a per-environment Object Storage bucket
|
|
376
|
+
--no-object-storage Do not provision Object Storage (default)
|
|
377
|
+
--no-basic-auth Disable Basic Auth on non-production environments
|
|
378
|
+
--dev-min-scale <n> Default 0 --dev-max-scale <n> Default 1
|
|
356
379
|
--staging-min-scale <n> Default 0 --staging-max-scale <n> Default 1
|
|
357
380
|
--prod-min-scale <n> Default 0 --prod-max-scale <n> Default 2
|
|
358
381
|
--config <file.json> Load answers from a JSON file
|
|
@@ -368,8 +391,10 @@ is taken from the environment or asked interactively:
|
|
|
368
391
|
{
|
|
369
392
|
"projectName": "my-app",
|
|
370
393
|
"region": "fr-par",
|
|
394
|
+
"environments": ["staging", "prod"],
|
|
395
|
+
"objectStorage": true,
|
|
371
396
|
"github": { "repoPrivate": true },
|
|
372
|
-
"scaling": { "
|
|
397
|
+
"scaling": { "prod": { "maxScale": 4 } }
|
|
373
398
|
}
|
|
374
399
|
```
|
|
375
400
|
|
package/dist/bootstrap/github.js
CHANGED
|
@@ -115,8 +115,8 @@ export async function configureRepo(ctx, answers, infisicalProjectId) {
|
|
|
115
115
|
INFISICAL_PROJECT_ID: infisicalProjectId,
|
|
116
116
|
INFISICAL_HOST: answers.infisical.host,
|
|
117
117
|
});
|
|
118
|
-
await configureEnvironments(ctx);
|
|
119
|
-
await protectMainBranch(ctx);
|
|
118
|
+
await configureEnvironments(ctx, answers);
|
|
119
|
+
await protectMainBranch(ctx, answers);
|
|
120
120
|
}
|
|
121
121
|
async function setSecrets(ctx, secrets) {
|
|
122
122
|
await sodium.ready;
|
|
@@ -157,29 +157,27 @@ async function setVariables(ctx, variables) {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
}
|
|
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
|
-
});
|
|
160
|
+
/** Each environment gets a GitHub deployment environment; gated ones (prod) require approval. */
|
|
161
|
+
async function configureEnvironments(ctx, answers) {
|
|
162
|
+
for (const env of answers.environments) {
|
|
163
|
+
await ctx.octokit.repos.createOrUpdateEnvironment({
|
|
164
|
+
owner: ctx.owner,
|
|
165
|
+
repo: ctx.repo,
|
|
166
|
+
environment_name: env.githubEnvironment,
|
|
167
|
+
...(env.gated ? { reviewers: [{ type: 'User', id: ctx.ownerId }] } : {}),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
173
170
|
}
|
|
174
171
|
/** Require a green plan on PRs and forbid force-pushes/deletion of main. */
|
|
175
|
-
async function protectMainBranch(ctx) {
|
|
172
|
+
async function protectMainBranch(ctx, answers) {
|
|
176
173
|
await ctx.octokit.repos.updateBranchProtection({
|
|
177
174
|
owner: ctx.owner,
|
|
178
175
|
repo: ctx.repo,
|
|
179
176
|
branch: 'main',
|
|
180
177
|
required_status_checks: {
|
|
181
178
|
strict: false,
|
|
182
|
-
|
|
179
|
+
// Matches the job names produced by the plan workflow: "plan (<slug>)".
|
|
180
|
+
contexts: answers.environments.map((env) => `plan (${env.slug})`),
|
|
183
181
|
},
|
|
184
182
|
enforce_admins: false,
|
|
185
183
|
required_pull_request_reviews: null,
|
|
@@ -43,10 +43,9 @@ async function createProject(host, token, name) {
|
|
|
43
43
|
}
|
|
44
44
|
return data.project;
|
|
45
45
|
}
|
|
46
|
-
async function ensureEnvironment(host, token, project, slug) {
|
|
46
|
+
async function ensureEnvironment(host, token, project, slug, name) {
|
|
47
47
|
if (project.environments?.some((e) => e.slug === slug))
|
|
48
48
|
return;
|
|
49
|
-
const name = slug === 'prod' ? 'Production' : 'Staging';
|
|
50
49
|
const { status, data } = await api(host, `/api/v1/workspace/${project.id}/environments`, { method: 'POST', token, body: { name, slug } });
|
|
51
50
|
// 400 usually means the environment already exists: fine for idempotency.
|
|
52
51
|
if (status !== 200 && status !== 400) {
|
|
@@ -72,7 +71,14 @@ async function seedSecret(host, token, projectId, environment, name, value) {
|
|
|
72
71
|
throw new InfisicalError(`Could not seed secret ${name} in ${environment} (HTTP ${status}${data.message ? `: ${data.message}` : ''}).`);
|
|
73
72
|
}
|
|
74
73
|
}
|
|
75
|
-
|
|
74
|
+
const S3_PLACEHOLDER_KEYS = [
|
|
75
|
+
'S3_BUCKET',
|
|
76
|
+
'S3_ENDPOINT',
|
|
77
|
+
'S3_REGION',
|
|
78
|
+
'S3_ACCESS_KEY',
|
|
79
|
+
'S3_SECRET_KEY',
|
|
80
|
+
];
|
|
81
|
+
/** Create/reuse the project, ensure every environment, seed placeholder secrets. */
|
|
76
82
|
export async function bootstrapInfisical(answers) {
|
|
77
83
|
const { host, projectName } = answers.infisical;
|
|
78
84
|
const token = await login(answers);
|
|
@@ -82,14 +88,23 @@ export async function bootstrapInfisical(answers) {
|
|
|
82
88
|
project = await createProject(host, token, projectName);
|
|
83
89
|
createdProject = true;
|
|
84
90
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (answers.basicAuthStaging) {
|
|
88
|
-
await seedSecret(host, token, project.id, 'staging', 'BASIC_AUTH_USER', 'staging');
|
|
89
|
-
await seedSecret(host, token, project.id, 'staging', 'BASIC_AUTH_PASSWORD', randomBytes(18).toString('base64url'));
|
|
91
|
+
for (const env of answers.environments) {
|
|
92
|
+
await ensureEnvironment(host, token, project, env.slug, env.displayName);
|
|
90
93
|
}
|
|
94
|
+
// Real values arrive from the pipeline after the first apply; seed placeholders
|
|
95
|
+
// now so the secret paths exist and the container has something to read.
|
|
91
96
|
const placeholder = 'placeholder-updated-by-pipeline-after-first-apply';
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
for (const env of answers.environments) {
|
|
98
|
+
if (env.basicAuth) {
|
|
99
|
+
await seedSecret(host, token, project.id, env.slug, 'BASIC_AUTH_USER', env.slug);
|
|
100
|
+
await seedSecret(host, token, project.id, env.slug, 'BASIC_AUTH_PASSWORD', randomBytes(18).toString('base64url'));
|
|
101
|
+
}
|
|
102
|
+
await seedSecret(host, token, project.id, env.slug, 'DATABASE_URL', placeholder);
|
|
103
|
+
if (answers.objectStorage) {
|
|
104
|
+
for (const key of S3_PLACEHOLDER_KEYS) {
|
|
105
|
+
await seedSecret(host, token, project.id, env.slug, key, placeholder);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
94
109
|
return { projectId: project.id, createdProject };
|
|
95
110
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,12 +1,49 @@
|
|
|
1
1
|
export const REGIONS = ['fr-par', 'nl-ams', 'pl-waw'];
|
|
2
2
|
export const DEFAULT_INFISICAL_HOST = 'https://app.infisical.com';
|
|
3
3
|
export const DEFAULT_REGION = 'fr-par';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The environments keel knows how to provision. The order here is also the
|
|
6
|
+
* deploy order (dev first, prod last) and drives the apply-workflow chain.
|
|
7
|
+
*/
|
|
8
|
+
export const KNOWN_ENV_SLUGS = ['dev', 'staging', 'prod'];
|
|
9
|
+
const ENV_DEFAULTS = {
|
|
10
|
+
dev: {
|
|
11
|
+
displayName: 'Development',
|
|
12
|
+
githubEnvironment: 'dev',
|
|
13
|
+
production: false,
|
|
14
|
+
gated: false,
|
|
15
|
+
minScale: 0,
|
|
16
|
+
maxScale: 1,
|
|
17
|
+
},
|
|
18
|
+
staging: {
|
|
19
|
+
displayName: 'Staging',
|
|
20
|
+
githubEnvironment: 'staging',
|
|
21
|
+
production: false,
|
|
22
|
+
gated: false,
|
|
23
|
+
minScale: 0,
|
|
24
|
+
maxScale: 1,
|
|
25
|
+
},
|
|
26
|
+
prod: {
|
|
27
|
+
displayName: 'Production',
|
|
28
|
+
githubEnvironment: 'production',
|
|
29
|
+
production: true,
|
|
30
|
+
gated: true,
|
|
31
|
+
minScale: 0,
|
|
32
|
+
maxScale: 2,
|
|
33
|
+
},
|
|
9
34
|
};
|
|
35
|
+
/** Default min/max scale for an environment, for prompts and summaries. */
|
|
36
|
+
export function envDefaultScale(slug) {
|
|
37
|
+
const def = ENV_DEFAULTS[slug];
|
|
38
|
+
return { minScale: def.minScale, maxScale: def.maxScale };
|
|
39
|
+
}
|
|
40
|
+
/** Named presets exposed on the CLI; the value is the list of env slugs. */
|
|
41
|
+
export const ENV_PRESETS = {
|
|
42
|
+
prod: ['prod'],
|
|
43
|
+
'staging+prod': ['staging', 'prod'],
|
|
44
|
+
'dev+staging+prod': ['dev', 'staging', 'prod'],
|
|
45
|
+
};
|
|
46
|
+
export const DEFAULT_ENV_PRESET = 'staging+prod';
|
|
10
47
|
export class ConfigError extends Error {
|
|
11
48
|
}
|
|
12
49
|
const PROJECT_NAME_RE = /^[a-z](?:[a-z0-9-]{0,48}[a-z0-9])?$/;
|
|
@@ -32,6 +69,35 @@ export function validateScale(value, label) {
|
|
|
32
69
|
}
|
|
33
70
|
return n;
|
|
34
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Parse a `--environments` value into an ordered, de-duplicated slug list.
|
|
74
|
+
* Accepts preset names ("staging+prod") or free lists ("dev,staging,prod").
|
|
75
|
+
*/
|
|
76
|
+
export function parseEnvironments(raw) {
|
|
77
|
+
const trimmed = raw.trim();
|
|
78
|
+
if (ENV_PRESETS[trimmed])
|
|
79
|
+
return ENV_PRESETS[trimmed];
|
|
80
|
+
const slugs = trimmed
|
|
81
|
+
.split(/[\s,+]+/)
|
|
82
|
+
.map((s) => s.trim().toLowerCase())
|
|
83
|
+
.filter(Boolean);
|
|
84
|
+
return normalizeEnvSlugs(slugs);
|
|
85
|
+
}
|
|
86
|
+
/** Validate slugs against the known set and return them in deploy order. */
|
|
87
|
+
export function normalizeEnvSlugs(slugs) {
|
|
88
|
+
for (const slug of slugs) {
|
|
89
|
+
if (!KNOWN_ENV_SLUGS.includes(slug)) {
|
|
90
|
+
throw new ConfigError(`Unknown environment "${slug}". Supported environments: ${KNOWN_ENV_SLUGS.join(', ')} ` +
|
|
91
|
+
`(or a preset: ${Object.keys(ENV_PRESETS).join(', ')}).`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const chosen = new Set(slugs);
|
|
95
|
+
const ordered = KNOWN_ENV_SLUGS.filter((s) => chosen.has(s));
|
|
96
|
+
if (ordered.length === 0) {
|
|
97
|
+
throw new ConfigError('At least one environment is required.');
|
|
98
|
+
}
|
|
99
|
+
return ordered;
|
|
100
|
+
}
|
|
35
101
|
export function validateUrl(value, label) {
|
|
36
102
|
let parsed;
|
|
37
103
|
try {
|
|
@@ -79,26 +145,68 @@ export function fromEnv(env) {
|
|
|
79
145
|
export function mergeAnswers(...sources) {
|
|
80
146
|
const out = { scaleway: {}, infisical: {}, github: {}, scaling: {} };
|
|
81
147
|
for (const src of sources) {
|
|
82
|
-
for (const key of [
|
|
148
|
+
for (const key of [
|
|
149
|
+
'projectName',
|
|
150
|
+
'region',
|
|
151
|
+
'targetDir',
|
|
152
|
+
'basicAuth',
|
|
153
|
+
'objectStorage',
|
|
154
|
+
]) {
|
|
83
155
|
const value = src[key];
|
|
84
156
|
if (value !== undefined) {
|
|
85
157
|
out[key] = value;
|
|
86
158
|
}
|
|
87
159
|
}
|
|
88
|
-
|
|
160
|
+
if (src.environments !== undefined) {
|
|
161
|
+
out.environments = src.environments;
|
|
162
|
+
}
|
|
163
|
+
for (const group of ['scaleway', 'infisical', 'github']) {
|
|
89
164
|
for (const [k, v] of Object.entries(src[group] ?? {})) {
|
|
90
165
|
if (v !== undefined) {
|
|
91
166
|
out[group][k] = v;
|
|
92
167
|
}
|
|
93
168
|
}
|
|
94
169
|
}
|
|
170
|
+
// Scaling is a per-slug record: merge each slug's fields, deepest wins.
|
|
171
|
+
for (const [slug, override] of Object.entries(src.scaling ?? {})) {
|
|
172
|
+
if (!override)
|
|
173
|
+
continue;
|
|
174
|
+
const current = out.scaling[slug] ?? {};
|
|
175
|
+
if (override.minScale !== undefined)
|
|
176
|
+
current.minScale = override.minScale;
|
|
177
|
+
if (override.maxScale !== undefined)
|
|
178
|
+
current.maxScale = override.maxScale;
|
|
179
|
+
out.scaling[slug] = current;
|
|
180
|
+
}
|
|
95
181
|
}
|
|
96
182
|
return out;
|
|
97
183
|
}
|
|
184
|
+
/** Build the resolved environment list from selected slugs + overrides. */
|
|
185
|
+
export function resolveEnvironments(slugs, basicAuth, overrides) {
|
|
186
|
+
return slugs.map((slug) => {
|
|
187
|
+
const def = ENV_DEFAULTS[slug];
|
|
188
|
+
const ov = overrides[slug] ?? {};
|
|
189
|
+
return {
|
|
190
|
+
slug,
|
|
191
|
+
displayName: def.displayName,
|
|
192
|
+
githubEnvironment: def.githubEnvironment,
|
|
193
|
+
production: def.production,
|
|
194
|
+
gated: def.gated,
|
|
195
|
+
// Basic Auth is a non-production safety net; production is never gated by it.
|
|
196
|
+
basicAuth: def.production ? false : basicAuth,
|
|
197
|
+
minScale: validateScale(ov.minScale ?? def.minScale, `${slug} min scale`),
|
|
198
|
+
maxScale: validateScale(ov.maxScale ?? def.maxScale, `${slug} max scale`),
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
}
|
|
98
202
|
/** Validate a fully-collected set of answers and freeze it into a Config. */
|
|
99
203
|
export function finalizeAnswers(partial) {
|
|
100
204
|
const projectName = validateProjectName(requireString(partial.projectName, 'project name'));
|
|
101
205
|
const region = validateRegion(partial.region ?? DEFAULT_REGION);
|
|
206
|
+
const slugs = partial.environments && partial.environments.length > 0
|
|
207
|
+
? normalizeEnvSlugs(partial.environments)
|
|
208
|
+
: ENV_PRESETS[DEFAULT_ENV_PRESET];
|
|
209
|
+
const basicAuth = partial.basicAuth ?? true;
|
|
102
210
|
return {
|
|
103
211
|
projectName,
|
|
104
212
|
region,
|
|
@@ -121,13 +229,9 @@ export function finalizeAnswers(partial) {
|
|
|
121
229
|
repoName: validateProjectName(partial.github.repoName?.trim() || projectName),
|
|
122
230
|
repoPrivate: partial.github.repoPrivate ?? false,
|
|
123
231
|
},
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
stagingMaxScale: validateScale(partial.scaling.stagingMaxScale ?? DEFAULT_SCALING.stagingMaxScale, 'staging max scale'),
|
|
128
|
-
prodMinScale: validateScale(partial.scaling.prodMinScale ?? DEFAULT_SCALING.prodMinScale, 'prod min scale'),
|
|
129
|
-
prodMaxScale: validateScale(partial.scaling.prodMaxScale ?? DEFAULT_SCALING.prodMaxScale, 'prod max scale'),
|
|
130
|
-
},
|
|
232
|
+
basicAuth,
|
|
233
|
+
objectStorage: partial.objectStorage ?? false,
|
|
234
|
+
environments: resolveEnvironments(slugs, basicAuth, partial.scaling),
|
|
131
235
|
};
|
|
132
236
|
}
|
|
133
237
|
/** List which required values are still missing, for non-interactive runs. */
|
package/dist/generate.js
CHANGED
|
@@ -12,17 +12,53 @@ const RENAMES = {
|
|
|
12
12
|
export function templatesDir() {
|
|
13
13
|
return fileURLToPath(new URL('../templates', import.meta.url));
|
|
14
14
|
}
|
|
15
|
+
/** Global tokens shared by every non per-environment template. */
|
|
15
16
|
export function tokenMap(answers) {
|
|
17
|
+
const slugs = answers.environments.map((e) => e.slug);
|
|
16
18
|
return {
|
|
17
19
|
__PROJECT_NAME__: answers.projectName,
|
|
18
20
|
__REGION__: answers.region,
|
|
19
21
|
__TF_STATE_BUCKET__: answers.stateBucket,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// YAML inline list for the plan/drift job matrix, e.g. [staging, prod].
|
|
23
|
+
__ENV_MATRIX__: `[${slugs.join(', ')}]`,
|
|
24
|
+
// HCL list for the `environment` variable validation in variables.tf.
|
|
25
|
+
__ENV_SLUGS_TF__: `[${slugs.map((s) => `"${s}"`).join(', ')}]`,
|
|
24
26
|
};
|
|
25
27
|
}
|
|
28
|
+
/** Tokens for one rendered `<env>.tfvars` file. */
|
|
29
|
+
function envTfvarsTokens(answers, env) {
|
|
30
|
+
return {
|
|
31
|
+
__PROJECT_NAME__: answers.projectName,
|
|
32
|
+
__REGION__: answers.region,
|
|
33
|
+
__ENVIRONMENT__: env.slug,
|
|
34
|
+
__ENABLE_BASIC_AUTH__: String(env.basicAuth),
|
|
35
|
+
__ENABLE_OBJECT_STORAGE__: String(answers.objectStorage),
|
|
36
|
+
__MIN_SCALE__: String(env.minScale),
|
|
37
|
+
__MAX_SCALE__: String(env.maxScale),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Templates rendered specially (per environment) instead of copied 1:1. */
|
|
41
|
+
const SPECIAL_TEMPLATES = new Set(['env.tfvars', '.github/workflows/terraform-apply.yml']);
|
|
42
|
+
function isSpecial(rel) {
|
|
43
|
+
return SPECIAL_TEMPLATES.has(rel) || rel.startsWith('_partials/');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Assemble the apply workflow: the shared header, then one job per environment
|
|
47
|
+
* in deploy order, each `needs:` the previous so applies run sequentially.
|
|
48
|
+
*/
|
|
49
|
+
function renderApplyWorkflow(source, answers, globalTokens) {
|
|
50
|
+
const header = renderContent(readFileSync(join(source, '_partials/apply-header.yml'), 'utf8'), globalTokens, '_partials/apply-header.yml');
|
|
51
|
+
const jobTemplate = readFileSync(join(source, '_partials/apply-job.yml'), 'utf8');
|
|
52
|
+
const jobs = answers.environments.map((env, i) => {
|
|
53
|
+
const previous = answers.environments[i - 1];
|
|
54
|
+
return renderContent(jobTemplate, {
|
|
55
|
+
__ENV_SLUG__: env.slug,
|
|
56
|
+
__GH_ENVIRONMENT__: env.githubEnvironment,
|
|
57
|
+
__NEEDS_LINE__: previous ? ` needs: apply-${previous.slug}\n` : '',
|
|
58
|
+
}, '_partials/apply-job.yml');
|
|
59
|
+
});
|
|
60
|
+
return header + jobs.join('\n');
|
|
61
|
+
}
|
|
26
62
|
function renderContent(content, tokens, file) {
|
|
27
63
|
let out = content;
|
|
28
64
|
for (const [token, value] of Object.entries(tokens)) {
|
|
@@ -59,6 +95,8 @@ export function generateProject(answers, options = {}) {
|
|
|
59
95
|
mkdirSync(target, { recursive: true });
|
|
60
96
|
const written = [];
|
|
61
97
|
for (const rel of walk(source)) {
|
|
98
|
+
if (isSpecial(rel))
|
|
99
|
+
continue;
|
|
62
100
|
const parts = rel.split('/');
|
|
63
101
|
const fileName = parts[parts.length - 1] ?? rel;
|
|
64
102
|
const destRel = [...parts.slice(0, -1), RENAMES[fileName] ?? fileName].join('/');
|
|
@@ -71,6 +109,18 @@ export function generateProject(answers, options = {}) {
|
|
|
71
109
|
}
|
|
72
110
|
written.push(destRel);
|
|
73
111
|
}
|
|
112
|
+
// Per-environment tfvars: one <slug>.tfvars from the shared env.tfvars template.
|
|
113
|
+
const envTemplate = readFileSync(join(source, 'env.tfvars'), 'utf8');
|
|
114
|
+
for (const env of answers.environments) {
|
|
115
|
+
const destRel = `${env.slug}.tfvars`;
|
|
116
|
+
writeFileSync(join(target, destRel), renderContent(envTemplate, envTfvarsTokens(answers, env), 'env.tfvars'));
|
|
117
|
+
written.push(destRel);
|
|
118
|
+
}
|
|
119
|
+
// Apply workflow: a header plus one deploy job per environment, chained so
|
|
120
|
+
// each environment applies only after the previous one succeeded.
|
|
121
|
+
const applyDestRel = '.github/workflows/terraform-apply.yml';
|
|
122
|
+
writeFileSync(join(target, applyDestRel), renderApplyWorkflow(source, answers, tokens));
|
|
123
|
+
written.push(applyDestRel);
|
|
74
124
|
// backend.hcl (git-ignored) so local terraform runs work out of the box.
|
|
75
125
|
cpSync(join(target, 'backend.hcl.example'), join(target, 'backend.hcl'));
|
|
76
126
|
if (options.git !== false) {
|