@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/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
|
-
--
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
|
164
|
-
` - Infisical: create/reuse project "${answers.infisical.projectName}", environments
|
|
165
|
-
' seed BASIC_AUTH_USER/BASIC_AUTH_PASSWORD (
|
|
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
|
-
|
|
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}
|
|
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.
|
|
88
|
-
|
|
89
|
-
message: '
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
52
|
+
` environments ${envSlugs} (+ placeholder secrets)`,
|
|
52
53
|
'',
|
|
53
|
-
`
|
|
54
|
-
|
|
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
|
|
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
|
@@ -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')
|
package/templates/README.md
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 (
|
|
40
|
-
merge/push main -> apply
|
|
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
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
59
|
-
|
|
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__
|
|
66
|
-
docker build -t rg.__REGION__.scw.cloud/__PROJECT_NAME__
|
|
67
|
-
docker push rg.__REGION__.scw.cloud/__PROJECT_NAME__
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
77
|
-
|
|
81
|
+
placeholders; replace them with real values. The app reads everything from
|
|
82
|
+
its environment:
|
|
78
83
|
|
|
79
|
-
| Variable |
|
|
84
|
+
| Variable | Where | Notes |
|
|
80
85
|
|---|---|---|
|
|
81
|
-
| `DATABASE_URL` |
|
|
82
|
-
| `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` |
|
|
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
|
|
94
|
+
## Basic Auth on non-production environments
|
|
89
95
|
|
|
90
96
|
Scaleway Serverless Containers expose public endpoints with no built-in auth,
|
|
91
|
-
so
|
|
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).
|
|
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
|
|
111
|
-
|
|
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
|
|
135
|
-
terraform plan -var-file
|
|
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** (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|