@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/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { ConfigError, finalizeAnswers, fromEnv, mergeAnswers, missingRequired, } from './config.js';
6
+ import { ConfigError, finalizeAnswers, fromEnv, mergeAnswers, missingRequired, parseEnvironments, } from './config.js';
7
7
  import { generateProject, GenerateError } from './generate.js';
8
8
  import { confirmSummary, fillMissing } from './prompts.js';
9
9
  import { isDone, loadState, markDone, STATE_FILE, stepData } from './state.js';
@@ -31,7 +31,13 @@ Options:
31
31
  --github-token <token> GitHub token, repo+workflow (env GITHUB_TOKEN)
32
32
  --repo-name <name> GitHub repository name (default: project name)
33
33
  --private / --public GitHub repository visibility (default: public)
34
- --no-basic-auth Disable Basic Auth on staging
34
+ --environments <preset> prod | staging+prod | dev+staging+prod
35
+ (or a list like "dev,staging,prod"; default staging+prod)
36
+ --no-basic-auth Disable Basic Auth on non-production environments
37
+ --object-storage Provision a per-environment Object Storage bucket
38
+ --no-object-storage Do not provision Object Storage (default)
39
+ --dev-min-scale <n> Dev min instances (default 0)
40
+ --dev-max-scale <n> Dev max instances (default 1)
35
41
  --staging-min-scale <n> Staging min instances (default 0)
36
42
  --staging-max-scale <n> Staging max instances (default 1)
37
43
  --prod-min-scale <n> Prod min instances (default 0)
@@ -61,8 +67,13 @@ function parseCli(argv) {
61
67
  'repo-name': { type: 'string' },
62
68
  private: { type: 'boolean' },
63
69
  public: { type: 'boolean' },
70
+ environments: { type: 'string' },
64
71
  'basic-auth': { type: 'boolean' },
65
72
  'no-basic-auth': { type: 'boolean' },
73
+ 'object-storage': { type: 'boolean' },
74
+ 'no-object-storage': { type: 'boolean' },
75
+ 'dev-min-scale': { type: 'string' },
76
+ 'dev-max-scale': { type: 'string' },
66
77
  'staging-min-scale': { type: 'string' },
67
78
  'staging-max-scale': { type: 'string' },
68
79
  'prod-min-scale': { type: 'string' },
@@ -108,12 +119,16 @@ function parseCli(argv) {
108
119
  repoName: values['repo-name'],
109
120
  repoPrivate: values.private ? true : values.public ? false : undefined,
110
121
  },
111
- basicAuthStaging: values['no-basic-auth'] ? false : values['basic-auth'],
122
+ environments: values.environments ? parseEnvironments(values.environments) : undefined,
123
+ basicAuth: values['no-basic-auth'] ? false : values['basic-auth'],
124
+ objectStorage: values['no-object-storage'] ? false : values['object-storage'],
112
125
  scaling: {
113
- stagingMinScale: num(values['staging-min-scale']),
114
- stagingMaxScale: num(values['staging-max-scale']),
115
- prodMinScale: num(values['prod-min-scale']),
116
- prodMaxScale: num(values['prod-max-scale']),
126
+ dev: { minScale: num(values['dev-min-scale']), maxScale: num(values['dev-max-scale']) },
127
+ staging: {
128
+ minScale: num(values['staging-min-scale']),
129
+ maxScale: num(values['staging-max-scale']),
130
+ },
131
+ prod: { minScale: num(values['prod-min-scale']), maxScale: num(values['prod-max-scale']) },
117
132
  },
118
133
  };
119
134
  return {
@@ -128,11 +143,21 @@ function parseCli(argv) {
128
143
  /** Config files use the same nested shape as PartialAnswers. */
129
144
  function normalizeConfigFile(raw) {
130
145
  const obj = (raw ?? {});
146
+ let environments;
147
+ if (typeof obj.environments === 'string') {
148
+ environments = parseEnvironments(obj.environments);
149
+ }
150
+ else if (Array.isArray(obj.environments)) {
151
+ environments = obj.environments;
152
+ }
131
153
  return mergeAnswers({
132
154
  projectName: obj.projectName,
133
155
  region: obj.region,
134
156
  targetDir: obj.targetDir,
135
- basicAuthStaging: obj.basicAuthStaging,
157
+ // `basicAuthStaging` is accepted as a legacy alias for `basicAuth`.
158
+ basicAuth: (obj.basicAuth ?? obj.basicAuthStaging),
159
+ objectStorage: obj.objectStorage,
160
+ environments,
136
161
  scaleway: (obj.scaleway ?? {}),
137
162
  infisical: (obj.infisical ?? {}),
138
163
  github: (obj.github ?? {}),
@@ -158,13 +183,16 @@ function checkEnvironment() {
158
183
  }
159
184
  }
160
185
  function printDryRunPlan(answers) {
186
+ const envList = answers.environments.map((e) => e.slug).join(', ');
187
+ const ghEnvs = answers.environments.map((e) => e.githubEnvironment).join('/');
161
188
  log.info([
162
189
  'Dry run: generated the repository locally. A real run would additionally:',
163
- ` - Scaleway: create Object Storage bucket "${answers.stateBucket}" (${answers.region})`,
164
- ` - Infisical: create/reuse project "${answers.infisical.projectName}", environments staging/prod,`,
165
- ' seed BASIC_AUTH_USER/BASIC_AUTH_PASSWORD (staging) and DATABASE_URL placeholders',
190
+ ` - Scaleway: create the Terraform state bucket "${answers.stateBucket}" (${answers.region})`,
191
+ ` - Infisical: create/reuse project "${answers.infisical.projectName}", environments ${envList},`,
192
+ ' seed BASIC_AUTH_USER/BASIC_AUTH_PASSWORD (non-prod) and DATABASE_URL placeholders' +
193
+ (answers.objectStorage ? ' and S3_* placeholders' : ''),
166
194
  ` - GitHub: create ${answers.github.repoPrivate ? 'private' : 'public'} repo "${answers.github.repoName}", push, set 6 encrypted secrets,`,
167
- ' 4 variables, staging/production environments and main branch protection',
195
+ ` 4 variables, ${ghEnvs} environments and main branch protection`,
168
196
  ].join('\n'));
169
197
  }
170
198
  async function runBootstrap(answers, state) {
@@ -278,7 +306,7 @@ async function main() {
278
306
  return;
279
307
  }
280
308
  const repoUrl = await runBootstrap(answers, state);
281
- outro(renderNextSteps(answers, repoUrl, `rg.${answers.region}.scw.cloud/${answers.projectName}-staging`));
309
+ outro(renderNextSteps(answers, repoUrl, `rg.${answers.region}.scw.cloud/${answers.projectName}-${answers.environments[0].slug}`));
282
310
  }
283
311
  main().catch((error) => {
284
312
  if (error instanceof ConfigError || error instanceof GenerateError) {
package/dist/prompts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as p from '@clack/prompts';
2
- import { ConfigError, DEFAULT_INFISICAL_HOST, DEFAULT_REGION, REGIONS, validateProjectName, validateScale, } from './config.js';
2
+ import { ConfigError, DEFAULT_ENV_PRESET, DEFAULT_INFISICAL_HOST, DEFAULT_REGION, ENV_PRESETS, envDefaultScale, REGIONS, validateProjectName, validateScale, } from './config.js';
3
3
  function bail() {
4
4
  p.cancel('Cancelled. Nothing was created.');
5
5
  process.exit(1);
@@ -84,9 +84,33 @@ export async function fillMissing(partial, options) {
84
84
  ],
85
85
  }));
86
86
  }
87
- if (out.basicAuthStaging === undefined) {
88
- out.basicAuthStaging = await ask(p.confirm({
89
- message: 'Protect staging with Basic Auth (enforced by your app)?',
87
+ if (!out.environments || out.environments.length === 0) {
88
+ const preset = await ask(p.select({
89
+ message: 'Which environments do you want?',
90
+ initialValue: DEFAULT_ENV_PRESET,
91
+ options: [
92
+ { value: 'prod', label: 'Production only', hint: 'single environment' },
93
+ {
94
+ value: 'staging+prod',
95
+ label: 'Staging + Production',
96
+ hint: 'recommended',
97
+ },
98
+ { value: 'dev+staging+prod', label: 'Dev + Staging + Production' },
99
+ ],
100
+ }));
101
+ out.environments = ENV_PRESETS[preset];
102
+ }
103
+ const slugs = out.environments;
104
+ const hasNonProd = slugs.some((s) => s !== 'prod');
105
+ if (out.objectStorage === undefined) {
106
+ out.objectStorage = await ask(p.confirm({
107
+ message: 'Provision an Object Storage bucket for application files (in addition to the database)?',
108
+ initialValue: false,
109
+ }));
110
+ }
111
+ if (hasNonProd && out.basicAuth === undefined) {
112
+ out.basicAuth = await ask(p.confirm({
113
+ message: 'Protect non-production environments with Basic Auth (enforced by your app)?',
90
114
  initialValue: true,
91
115
  }));
92
116
  }
@@ -96,10 +120,13 @@ export async function fillMissing(partial, options) {
96
120
  initialValue: String(initial),
97
121
  validate: validate((v) => validateScale(v, 'scale')),
98
122
  })));
99
- out.scaling.stagingMinScale = await scale('Staging min scale', out.scaling.stagingMinScale ?? 0);
100
- out.scaling.stagingMaxScale = await scale('Staging max scale', out.scaling.stagingMaxScale ?? 1);
101
- out.scaling.prodMinScale = await scale('Prod min scale', out.scaling.prodMinScale ?? 0);
102
- out.scaling.prodMaxScale = await scale('Prod max scale', out.scaling.prodMaxScale ?? 2);
123
+ for (const slug of slugs) {
124
+ const def = envDefaultScale(slug);
125
+ const current = out.scaling[slug] ?? {};
126
+ current.minScale = await scale(`${slug} min scale`, current.minScale ?? def.minScale);
127
+ current.maxScale = await scale(`${slug} max scale`, current.maxScale ?? def.maxScale);
128
+ out.scaling[slug] = current;
129
+ }
103
130
  }
104
131
  return out;
105
132
  }
package/dist/ui.js CHANGED
@@ -30,6 +30,7 @@ export async function withSpinner(label, fn) {
30
30
  }
31
31
  }
32
32
  export function renderSummary(answers, dryRun) {
33
+ const envSlugs = answers.environments.map((e) => e.slug).join(', ');
33
34
  const lines = [
34
35
  `Project ${answers.projectName}`,
35
36
  `Directory ./${answers.targetDir}`,
@@ -48,12 +49,21 @@ export function renderSummary(answers, dryRun) {
48
49
  'Infisical',
49
50
  ` host ${answers.infisical.host}`,
50
51
  ` project ${answers.infisical.projectName} (created or reused)`,
51
- ` environments staging, prod (+ placeholder secrets)`,
52
+ ` environments ${envSlugs} (+ placeholder secrets)`,
52
53
  '',
53
- `Basic Auth on staging ${answers.basicAuthStaging ? 'enabled' : 'disabled'}`,
54
- `Scaling staging ${answers.scaling.stagingMinScale}-${answers.scaling.stagingMaxScale} instances`,
55
- `Scaling prod ${answers.scaling.prodMinScale}-${answers.scaling.prodMaxScale} instances`,
54
+ `Object Storage ${answers.objectStorage ? 'enabled (per-environment bucket + S3_* secrets)' : 'disabled'}`,
55
+ 'Environments',
56
56
  ];
57
+ for (const env of answers.environments) {
58
+ const flags = [
59
+ `scale ${env.minScale}-${env.maxScale}`,
60
+ env.gated ? 'manual approval' : 'auto-deploy',
61
+ env.basicAuth ? 'basic auth' : null,
62
+ ]
63
+ .filter(Boolean)
64
+ .join(', ');
65
+ lines.push(` ${env.slug.padEnd(8)} ${flags}`);
66
+ }
57
67
  if (dryRun) {
58
68
  lines.push('', 'DRY RUN: files will be generated locally, no account will be touched.');
59
69
  }
@@ -69,7 +79,7 @@ export function renderNextSteps(answers, repoUrl, containerUrlHint) {
69
79
  ` project "${answers.infisical.projectName}" with real values.`,
70
80
  ` 3. Push to main (or merge a PR) to trigger the first terraform apply:`,
71
81
  ` ${repoUrl}/actions`,
72
- ` 4. Set container_image in staging.tfvars / prod.tfvars once an image exists.`,
82
+ ` 4. Set container_image in ${answers.environments.map((e) => `${e.slug}.tfvars`).join(' / ')} once an image exists.`,
73
83
  '',
74
84
  `Repository: ${repoUrl}`,
75
85
  ].join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gambi97/keel-cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "One command from zero to a production-shaped serverless infrastructure on Scaleway (Terraform + GitHub Actions + Infisical). Only Node required.",
5
5
  "keywords": [
6
6
  "keel",
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # Pushes the secrets Terraform produced to Infisical, so the application reads
3
+ # them at runtime: always DATABASE_URL (dedicated IAM credential included), plus
4
+ # the S3_* Object Storage coordinates when that feature is enabled.
5
+ # Usage: sync-secrets.sh <environment>
6
+ # Expects: INFISICAL_HOST, INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET,
7
+ # INFISICAL_PROJECT_ID in the environment; terraform init already run.
8
+ set -euo pipefail
9
+
10
+ ENVIRONMENT="${1:?usage: sync-secrets.sh <environment>}"
11
+
12
+ SECRETS_JSON="$(terraform output -json infisical_secrets)"
13
+ if [ -z "$SECRETS_JSON" ] || [ "$SECRETS_JSON" = "null" ] || [ "$SECRETS_JSON" = "{}" ]; then
14
+ echo "No secrets to sync."
15
+ exit 0
16
+ fi
17
+
18
+ ACCESS_TOKEN="$(curl -sS --fail-with-body -X POST \
19
+ "$INFISICAL_HOST/api/v1/auth/universal-auth/login" \
20
+ -H "Content-Type: application/json" \
21
+ -d "{\"clientId\":\"$INFISICAL_CLIENT_ID\",\"clientSecret\":\"$INFISICAL_CLIENT_SECRET\"}" \
22
+ | jq -r .accessToken)"
23
+ echo "::add-mask::$ACCESS_TOKEN"
24
+
25
+ sync_secret() {
26
+ local name="$1" value="$2"
27
+ echo "::add-mask::$value"
28
+ local payload
29
+ payload="$(jq -n \
30
+ --arg workspaceId "$INFISICAL_PROJECT_ID" \
31
+ --arg environment "$ENVIRONMENT" \
32
+ --arg value "$value" \
33
+ '{workspaceId: $workspaceId, environment: $environment, secretPath: "/", secretValue: $value, type: "shared"}')"
34
+ local status
35
+ status="$(curl -sS -o /dev/null -w "%{http_code}" -X PATCH \
36
+ "$INFISICAL_HOST/api/v3/secrets/raw/$name" \
37
+ -H "Authorization: Bearer $ACCESS_TOKEN" \
38
+ -H "Content-Type: application/json" \
39
+ -d "$payload")"
40
+ # PATCH updates the placeholder seeded at bootstrap; create the secret if missing.
41
+ if [ "$status" = "404" ]; then
42
+ curl -sS --fail-with-body -o /dev/null -X POST \
43
+ "$INFISICAL_HOST/api/v3/secrets/raw/$name" \
44
+ -H "Authorization: Bearer $ACCESS_TOKEN" \
45
+ -H "Content-Type: application/json" \
46
+ -d "$payload"
47
+ elif [ "$status" -ge 300 ]; then
48
+ echo "Failed to update $name in Infisical (HTTP $status)" >&2
49
+ exit 1
50
+ fi
51
+ echo "Synced $name to Infisical ($ENVIRONMENT)."
52
+ }
53
+
54
+ # Iterate the output map as base64-encoded {key,value} rows to survive any
55
+ # special characters in the values.
56
+ while IFS= read -r row; do
57
+ entry="$(echo "$row" | base64 --decode)"
58
+ name="$(echo "$entry" | jq -r '.key')"
59
+ value="$(echo "$entry" | jq -r '.value')"
60
+ if [ -z "$value" ] || [ "$value" = "null" ]; then
61
+ echo "Skipping $name (no value yet)."
62
+ continue
63
+ fi
64
+ sync_secret "$name" "$value"
65
+ done < <(echo "$SECRETS_JSON" | jq -r 'to_entries[] | @base64')
@@ -35,7 +35,7 @@ jobs:
35
35
  strategy:
36
36
  fail-fast: false
37
37
  matrix:
38
- environment: [staging, prod]
38
+ environment: __ENV_MATRIX__
39
39
  steps:
40
40
  - uses: actions/checkout@v4
41
41
 
@@ -29,7 +29,7 @@ jobs:
29
29
  strategy:
30
30
  fail-fast: false
31
31
  matrix:
32
- environment: [staging, prod]
32
+ environment: __ENV_MATRIX__
33
33
  steps:
34
34
  - uses: actions/checkout@v4
35
35
 
@@ -17,12 +17,14 @@ here.
17
17
  | Container namespace + Serverless Container | Runs the app, scales to zero when idle |
18
18
  | Serverless SQL Database (Postgres) | One independent database per environment |
19
19
  | IAM application + API key | Dedicated least-privilege database credential for the app |
20
+ | Object Storage bucket (optional) | Per-environment file store, with its own least-privilege credential |
20
21
 
21
- Two environments, **staging** and **prod**, isolated via Terraform
22
- workspaces: same code, two separate states, differences confined to
23
- `staging.tfvars` / `prod.tfvars`. Staging expects the app to enforce Basic
24
- Auth (see below). State lives in the `__PROJECT_NAME__-tfstate` bucket on
25
- Scaleway Object Storage.
22
+ Each environment is isolated via a Terraform **workspace**: same code, one
23
+ independent state per environment, differences confined to the committed
24
+ `<env>.tfvars` files (one per environment — look at the `*.tfvars` in this
25
+ repo to see which ones your project has). Non-production environments expect
26
+ the app to enforce Basic Auth (see below). State lives in the
27
+ `__PROJECT_NAME__-tfstate` bucket on Scaleway Object Storage.
26
28
 
27
29
  **There are no secrets in this repository**, and none should ever be added.
28
30
  Credentials live in GitHub Actions encrypted secrets (CI) and in Infisical
@@ -31,21 +33,24 @@ Credentials live in GitHub Actions encrypted secrets (CI) and in Infisical
31
33
  > Naming heads-up: `backend.tf` configures the Terraform **state backend**,
32
34
  > the bucket where Terraform records what it has created. It has nothing to
33
35
  > do with your application's backend, which is just the Docker image you
34
- > deploy.
36
+ > deploy. The optional Object Storage bucket above is a separate,
37
+ > application-facing file store.
35
38
 
36
39
  ## How deploys work
37
40
 
38
41
  ```
39
- PR to main -> terraform fmt + validate + plan (staging & prod), shown as PR checks
40
- merge/push main -> apply staging -> manual approval (production gate) -> apply prod
42
+ PR to main -> terraform fmt + validate + plan (every environment), shown as PR checks
43
+ merge/push main -> apply each environment in order; any gated environment (production) waits for manual approval
41
44
  ```
42
45
 
43
46
  - `main` is protected: PRs need a green plan, force pushes are blocked.
44
- - The prod apply waits for approval on the `production` GitHub Environment.
45
- - After each apply, the pipeline writes a ready-to-use Postgres connection
46
- string to Infisical as `DATABASE_URL` for that environment. The credential
47
- inside it is a dedicated IAM application that can only read/write the
48
- database (least privilege), not your main API key.
47
+ - Environments apply in order, each `needs:` the previous one; a gated
48
+ environment (production) waits for approval on its GitHub Environment.
49
+ - After each apply, the pipeline syncs the secrets Terraform produced to
50
+ Infisical for that environment: a ready-to-use Postgres `DATABASE_URL`
51
+ (whose credential is a dedicated IAM application that can only read/write
52
+ the database, not your main API key) and, when Object Storage is enabled,
53
+ the `S3_*` coordinates.
49
54
  - State is locked during applies (S3-native locking, `use_lockfile`), so
50
55
  concurrent runs cannot corrupt it.
51
56
  - A scheduled **drift detection** workflow (Monday 06:00 UTC, or manual via
@@ -55,43 +60,45 @@ merge/push main -> apply staging -> manual approval (production gate) -> apply
55
60
  ## First deploy
56
61
 
57
62
  1. **Merge or push to `main`.** The first pipeline run creates registry and
58
- databases. The Serverless Container is intentionally skipped until an
59
- image exists (`container_image` is empty), so the first apply is green.
63
+ databases (and the Object Storage bucket, if enabled). The Serverless
64
+ Container is intentionally skipped until an image exists (`container_image`
65
+ is empty), so the first apply is green.
60
66
 
61
67
  2. **Build and push the app image** (any Dockerfile, listening on port 8080
62
- by default):
68
+ by default), replacing `<env>` with your target environment (e.g. staging):
63
69
 
64
70
  ```sh
65
- docker login rg.__REGION__.scw.cloud/__PROJECT_NAME__-staging -u nologin --password-stdin <<< "$SCW_SECRET_KEY"
66
- docker build -t rg.__REGION__.scw.cloud/__PROJECT_NAME__-staging/app:latest .
67
- docker push rg.__REGION__.scw.cloud/__PROJECT_NAME__-staging/app:latest
71
+ docker login rg.__REGION__.scw.cloud/__PROJECT_NAME__-<env> -u nologin --password-stdin <<< "$SCW_SECRET_KEY"
72
+ docker build -t rg.__REGION__.scw.cloud/__PROJECT_NAME__-<env>/app:latest .
73
+ docker push rg.__REGION__.scw.cloud/__PROJECT_NAME__-<env>/app:latest
68
74
  ```
69
75
 
70
- 3. **Point the container at the image**: set `container_image` in
71
- `staging.tfvars` (and later in `prod.tfvars`, using the prod registry),
72
- open a PR, review the plan, merge. The apply creates the container and
73
- prints its URL (`terraform output container_url`).
76
+ 3. **Point the container at the image**: set `container_image` in the target
77
+ `<env>.tfvars`, open a PR, review the plan, merge. The apply creates the
78
+ container and prints its URL (`terraform output container_url`).
74
79
 
75
80
  4. **Fill in the real secrets in Infisical.** The bootstrap seeded
76
- placeholders; replace them with production values. The app reads
77
- everything from its environment:
81
+ placeholders; replace them with real values. The app reads everything from
82
+ its environment:
78
83
 
79
- | Variable | Environment | Notes |
84
+ | Variable | Where | Notes |
80
85
  |---|---|---|
81
- | `DATABASE_URL` | staging + prod | Complete connection string, auto-updated by the pipeline after each apply. No action needed |
82
- | `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` | staging | The app must enforce these when `BASIC_AUTH_ENABLED=true` |
86
+ | `DATABASE_URL` | every environment | Complete connection string, auto-updated by the pipeline after each apply. No action needed |
87
+ | `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` | non-production | The app must enforce these when `BASIC_AUTH_ENABLED=true` |
88
+ | `S3_BUCKET` / `S3_ENDPOINT` / `S3_REGION` / `S3_ACCESS_KEY` / `S3_SECRET_KEY` | every environment (if Object Storage enabled) | Auto-updated by the pipeline after each apply |
83
89
 
84
90
  Every secret in the Infisical environment is injected into the container
85
91
  as a secret environment variable, so adding an app secret is: add it in
86
92
  Infisical, re-run the apply (any merge to `main`).
87
93
 
88
- ## Basic Auth on staging
94
+ ## Basic Auth on non-production environments
89
95
 
90
96
  Scaleway Serverless Containers expose public endpoints with no built-in auth,
91
- so staging protection is enforced **by the application**: the container
97
+ so non-production protection is enforced **by the application**: the container
92
98
  receives `BASIC_AUTH_ENABLED=true` plus the credentials from Infisical, and
93
99
  the app is expected to respond `401` without them (a few lines of middleware
94
- in any framework). Prod does not set `BASIC_AUTH_ENABLED`.
100
+ in any framework). Production does not set `BASIC_AUTH_ENABLED`. Whether an
101
+ environment enables it is the `enable_basic_auth` flag in its `<env>.tfvars`.
95
102
 
96
103
  ## Day-2 operations
97
104
 
@@ -107,15 +114,18 @@ in any framework). Prod does not set `BASIC_AUTH_ENABLED`.
107
114
  secrets (repo Settings > Secrets and variables > Actions).
108
115
  - **Add a custom domain**: add a `scaleway_container_domain` resource in
109
116
  `modules/app_stack` pointing at the container, plus your DNS record.
110
- - **Add an environment**: add a workspace, an `<env>.tfvars`, an Infisical
111
- environment with the same slug, and mirror one job in each workflow.
117
+ - **Add an environment**: add a `<env>.tfvars`, an Infisical environment with
118
+ the same slug, an entry in the plan/drift matrix and a job in
119
+ `.github/workflows/terraform-apply.yml` (mirror an existing one and set its
120
+ `needs:` to chain after the previous environment).
112
121
  - **Pin provider versions**: after any local `terraform init`, commit the
113
122
  generated `.terraform.lock.hcl` so CI resolves the exact same provider
114
123
  builds on every run.
115
124
 
116
125
  ## Running Terraform locally (optional)
117
126
 
118
- CI is the source of truth, but plans can be run locally:
127
+ CI is the source of truth, but plans can be run locally (replace `<env>` with
128
+ one of your environments):
119
129
 
120
130
  ```sh
121
131
  cp backend.hcl.example backend.hcl
@@ -131,34 +141,35 @@ export TF_VAR_infisical_project_id=<infisical-project-id>
131
141
  export TF_VAR_infisical_host=https://app.infisical.com
132
142
 
133
143
  terraform init -backend-config=backend.hcl
134
- terraform workspace select -or-create staging
135
- terraform plan -var-file=staging.tfvars
144
+ terraform workspace select -or-create <env>
145
+ terraform plan -var-file=<env>.tfvars
136
146
  ```
137
147
 
138
148
  Avoid local applies: they race against CI on the same state.
139
149
 
140
150
  ## Configuration reference
141
151
 
142
- - **Committed tfvars** (`staging.tfvars`, `prod.tfvars`): non-sensitive knobs
143
- only (name, region, scaling, image).
152
+ - **Committed tfvars** (`<env>.tfvars`, one per environment): non-sensitive
153
+ knobs only (name, region, scaling, image, `enable_basic_auth`,
154
+ `enable_object_storage`).
144
155
  - **GitHub Actions secrets**: `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`,
145
156
  `SCW_DEFAULT_PROJECT_ID`, `SCW_DEFAULT_ORGANIZATION_ID`,
146
157
  `INFISICAL_CLIENT_ID`, `INFISICAL_CLIENT_SECRET`. The workflows reuse the
147
158
  Scaleway keys as `AWS_*` for the state backend.
148
159
  - **GitHub Actions variables**: `TF_STATE_BUCKET`, `SCW_REGION`,
149
160
  `INFISICAL_PROJECT_ID`, `INFISICAL_HOST`.
150
- - **Infisical**: one environment per workspace (`staging`, `prod`), all
151
- application secrets.
161
+ - **Infisical**: one environment per workspace, all application secrets.
152
162
 
153
163
  ## Troubleshooting
154
164
 
155
165
  - **Plan fails with 403 on the state bucket**: the `SCW_*` secrets don't
156
166
  match a Scaleway key with Object Storage access.
157
167
  - **Plan fails reading Infisical secrets**: check `INFISICAL_PROJECT_ID` and
158
- that the machine identity has access to the project and both environments.
168
+ that the machine identity has access to the project and every environment.
159
169
  - **Apply green but no container**: expected until `container_image` is set.
160
170
  - **App can't reach the database**: `DATABASE_URL` is only synced after an
161
171
  apply; check the value in Infisical for that environment. It must contain
162
172
  a username and password (the dedicated IAM credential), not placeholders.
163
173
  - **Apply fails creating IAM resources**: the CI Scaleway key needs IAM
164
- permissions (IAMManager) to create the app's database credential.
174
+ permissions (IAMManager) to create the app's database (and Object Storage)
175
+ credential.
@@ -0,0 +1,33 @@
1
+ name: Terraform apply
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ concurrency:
11
+ group: terraform-apply
12
+ cancel-in-progress: false
13
+
14
+ env:
15
+ # The Scaleway keys double as S3 credentials for the state backend, so the
16
+ # same secret feeds both variables: one credential, one place to rotate.
17
+ AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
18
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
19
+ SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
20
+ SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
21
+ SCW_DEFAULT_PROJECT_ID: ${{ secrets.SCW_DEFAULT_PROJECT_ID }}
22
+ SCW_DEFAULT_ORGANIZATION_ID: ${{ secrets.SCW_DEFAULT_ORGANIZATION_ID }}
23
+ INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }}
24
+ INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }}
25
+ INFISICAL_PROJECT_ID: ${{ vars.INFISICAL_PROJECT_ID }}
26
+ INFISICAL_HOST: ${{ vars.INFISICAL_HOST }}
27
+ TF_VAR_infisical_client_id: ${{ secrets.INFISICAL_CLIENT_ID }}
28
+ TF_VAR_infisical_client_secret: ${{ secrets.INFISICAL_CLIENT_SECRET }}
29
+ TF_VAR_infisical_project_id: ${{ vars.INFISICAL_PROJECT_ID }}
30
+ TF_VAR_infisical_host: ${{ vars.INFISICAL_HOST }}
31
+ TF_IN_AUTOMATION: "true"
32
+
33
+ jobs:
@@ -0,0 +1,34 @@
1
+ apply-__ENV_SLUG__:
2
+ name: apply (__ENV_SLUG__)
3
+ runs-on: ubuntu-latest
4
+ __NEEDS_LINE__ # The GitHub environment may carry a required-reviewer rule, gating this job.
5
+ environment: __GH_ENVIRONMENT__
6
+ steps:
7
+ - uses: actions/checkout@v4
8
+
9
+ - uses: hashicorp/setup-terraform@v3
10
+ with:
11
+ terraform_version: "~1.12"
12
+ terraform_wrapper: false
13
+
14
+ - name: Write backend config
15
+ run: |
16
+ cat > backend.hcl <<EOF
17
+ bucket = "${{ vars.TF_STATE_BUCKET }}"
18
+ region = "${{ vars.SCW_REGION }}"
19
+ endpoints = {
20
+ s3 = "https://s3.${{ vars.SCW_REGION }}.scw.cloud"
21
+ }
22
+ EOF
23
+
24
+ - name: Terraform init
25
+ run: terraform init -input=false -backend-config=backend.hcl
26
+
27
+ - name: Select workspace
28
+ run: terraform workspace select -or-create __ENV_SLUG__
29
+
30
+ - name: Terraform apply
31
+ run: terraform apply -input=false -auto-approve -var-file=__ENV_SLUG__.tfvars
32
+
33
+ - name: Sync secrets to Infisical
34
+ run: ./.github/scripts/sync-secrets.sh __ENV_SLUG__
@@ -0,0 +1,8 @@
1
+ # Non-sensitive __ENVIRONMENT__ configuration. Safe to commit.
2
+ project_name = "__PROJECT_NAME__"
3
+ region = "__REGION__"
4
+ environment = "__ENVIRONMENT__"
5
+ enable_basic_auth = __ENABLE_BASIC_AUTH__
6
+ enable_object_storage = __ENABLE_OBJECT_STORAGE__
7
+ min_scale = __MIN_SCALE__
8
+ max_scale = __MAX_SCALE__
package/templates/main.tf CHANGED
@@ -20,7 +20,8 @@ module "app_stack" {
20
20
  db_min_cpu = var.db_min_cpu
21
21
  db_max_cpu = var.db_max_cpu
22
22
 
23
- enable_basic_auth = var.enable_basic_auth
23
+ enable_basic_auth = var.enable_basic_auth
24
+ enable_object_storage = var.enable_object_storage
24
25
 
25
26
  secret_environment_variables = {
26
27
  for name, secret in data.infisical_secrets.app.secrets : name => secret.value
@@ -44,6 +44,40 @@ resource "scaleway_iam_api_key" "db" {
44
44
  description = "Database credential for ${local.name} (managed by Terraform)"
45
45
  }
46
46
 
47
+ # Optional Object Storage bucket for application files (uploads, assets, …),
48
+ # with a dedicated least-privilege credential just like the database: this
49
+ # application can reach Object Storage and nothing else. Its access/secret key
50
+ # and the bucket coordinates are synced to Infisical after each apply.
51
+ resource "scaleway_object_bucket" "files" {
52
+ count = var.enable_object_storage ? 1 : 0
53
+ name = "${local.name}-files"
54
+ region = var.region
55
+ }
56
+
57
+ resource "scaleway_iam_application" "storage" {
58
+ count = var.enable_object_storage ? 1 : 0
59
+ name = "${local.name}-storage"
60
+ description = "Object Storage access for ${local.name} (managed by Terraform)"
61
+ }
62
+
63
+ resource "scaleway_iam_policy" "storage_access" {
64
+ count = var.enable_object_storage ? 1 : 0
65
+ name = "${local.name}-storage-access"
66
+ description = "Object Storage access for ${local.name}"
67
+ application_id = scaleway_iam_application.storage[0].id
68
+
69
+ rule {
70
+ project_ids = [scaleway_sdb_sql_database.this.project_id]
71
+ permission_set_names = ["ObjectStorageFullAccess"]
72
+ }
73
+ }
74
+
75
+ resource "scaleway_iam_api_key" "storage" {
76
+ count = var.enable_object_storage ? 1 : 0
77
+ application_id = scaleway_iam_application.storage[0].id
78
+ description = "Object Storage credential for ${local.name} (managed by Terraform)"
79
+ }
80
+
47
81
  # The container is gated on an image being available: registry and database
48
82
  # are provisioned first, the container appears once an image has been pushed
49
83
  # and container_image is set.