@gambi97/keel-cli 0.2.0 → 0.3.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 +70 -25
- package/dist/bootstrap/github.js +84 -22
- package/dist/bootstrap/infisical.js +59 -22
- package/dist/bootstrap/scaleway.js +102 -17
- package/dist/config.js +14 -3
- package/dist/contracts.js +80 -0
- package/dist/generate.js +37 -3
- package/dist/index.js +216 -142
- package/dist/meta.js +12 -0
- package/dist/prompts.js +246 -66
- package/dist/ui.js +3 -1
- package/package.json +1 -1
- package/templates/.github/scripts/sync-secrets.sh +13 -4
- package/templates/.github/workflows/terraform-drift.yml +19 -1
- package/templates/.github/workflows/terraform-plan.yml +19 -1
- package/templates/README.md +29 -5
- package/templates/_gitignore +1 -1
- package/templates/main.tf +10 -0
- package/templates/modules/app_stack/main.tf +3 -0
- package/templates/outputs.tf +12 -3
- package/templates/variables.tf +5 -0
package/dist/prompts.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
|
-
import {
|
|
2
|
+
import { createContext, GitHubError, inspectRepo, assertRepoUsable } from './bootstrap/github.js';
|
|
3
|
+
import { InfisicalError, validateInfisical } from './bootstrap/infisical.js';
|
|
4
|
+
import { ScalewayError, validateScalewayCredentials } from './bootstrap/scaleway.js';
|
|
5
|
+
import { ConfigError, DEFAULT_ENV_PRESET, DEFAULT_INFISICAL_HOST, DEFAULT_REGION, ENV_PRESETS, envDefaultScale, REGIONS, validateProjectName, validateScale, validateUrl, } from './config.js';
|
|
6
|
+
import { isDone, loadState } from './state.js';
|
|
7
|
+
import { log } from './ui.js';
|
|
3
8
|
function bail() {
|
|
4
9
|
p.cancel('Cancelled. Nothing was created.');
|
|
5
10
|
process.exit(1);
|
|
@@ -21,80 +26,58 @@ function validate(fn) {
|
|
|
21
26
|
}
|
|
22
27
|
};
|
|
23
28
|
}
|
|
24
|
-
|
|
29
|
+
const secret = (message) => ask(p.password({ message }));
|
|
30
|
+
const text = (message, placeholder) => ask(p.text({
|
|
31
|
+
message,
|
|
32
|
+
...(placeholder ? { placeholder } : {}),
|
|
33
|
+
validate: (v) => (v.trim() ? undefined : 'Required.'),
|
|
34
|
+
}));
|
|
35
|
+
/**
|
|
36
|
+
* Interactively fill everything still missing from flags/env/config file.
|
|
37
|
+
*
|
|
38
|
+
* The project name comes first (it seeds the repo/bucket/project defaults),
|
|
39
|
+
* then the three provider blocks (GitHub, Infisical, Scaleway) — each closed
|
|
40
|
+
* by a read-only validation call that reports bad input immediately and
|
|
41
|
+
* re-asks only the offending value — and finally the configuration you want
|
|
42
|
+
* (region, environments, storage, scaling). Nothing is created here; creation
|
|
43
|
+
* starts only after the final confirmation.
|
|
44
|
+
*/
|
|
25
45
|
export async function fillMissing(partial, options) {
|
|
26
46
|
const out = structuredClone(partial);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
out.region = await ask(p.select({
|
|
36
|
-
message: 'Scaleway region',
|
|
37
|
-
initialValue: DEFAULT_REGION,
|
|
38
|
-
options: REGIONS.map((r) => ({ value: r, label: r })),
|
|
39
|
-
}));
|
|
47
|
+
await askProjectName(out);
|
|
48
|
+
// Each block opens with a "┌ <Provider>" section corner and closes with a
|
|
49
|
+
// "└ <Provider> connected — …" line, so every question flows inside its own
|
|
50
|
+
// section and the next one opens only when the previous is verified.
|
|
51
|
+
if (!options.dryRun) {
|
|
52
|
+
await askGitHub(out);
|
|
53
|
+
await askInfisical(out);
|
|
54
|
+
await askScaleway(out);
|
|
40
55
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
// Configuration comes last: with the accounts verified, these are the only
|
|
57
|
+
// real choices left. In a dry run it is all that is asked after the name.
|
|
58
|
+
await askConfiguration(out, options);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
async function askProjectName(out) {
|
|
62
|
+
if (out.projectName)
|
|
63
|
+
return;
|
|
64
|
+
out.projectName = await ask(p.text({
|
|
65
|
+
message: 'Project name (dns-safe, used for repo, bucket and resources)',
|
|
66
|
+
placeholder: 'my-app',
|
|
67
|
+
validate: validate(validateProjectName),
|
|
46
68
|
}));
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
out.scaleway.projectId = await text('Scaleway project ID');
|
|
53
|
-
if (!out.scaleway.organizationId)
|
|
54
|
-
out.scaleway.organizationId = await text('Scaleway organization ID');
|
|
55
|
-
if (!out.infisical.host) {
|
|
56
|
-
out.infisical.host = await ask(p.text({ message: 'Infisical host', initialValue: DEFAULT_INFISICAL_HOST }));
|
|
57
|
-
}
|
|
58
|
-
if (!out.infisical.clientId)
|
|
59
|
-
out.infisical.clientId = await text('Infisical machine identity client ID');
|
|
60
|
-
if (!out.infisical.clientSecret)
|
|
61
|
-
out.infisical.clientSecret = await secret('Infisical machine identity client secret');
|
|
62
|
-
if (!out.infisical.projectName) {
|
|
63
|
-
out.infisical.projectName = await ask(p.text({
|
|
64
|
-
message: 'Infisical project name (existing project is reused, otherwise created)',
|
|
65
|
-
initialValue: out.projectName,
|
|
66
|
-
}));
|
|
67
|
-
}
|
|
68
|
-
if (!out.github.token)
|
|
69
|
-
out.github.token = await secret('GitHub token (scopes: repo, workflow)');
|
|
70
|
-
if (!out.github.repoName) {
|
|
71
|
-
out.github.repoName = await ask(p.text({
|
|
72
|
-
message: 'GitHub repository name',
|
|
73
|
-
initialValue: out.projectName,
|
|
74
|
-
validate: validate(validateProjectName),
|
|
75
|
-
}));
|
|
76
|
-
}
|
|
77
|
-
if (out.github.repoPrivate === undefined) {
|
|
78
|
-
out.github.repoPrivate = await ask(p.select({
|
|
79
|
-
message: 'Repository visibility',
|
|
80
|
-
initialValue: false,
|
|
81
|
-
options: [
|
|
82
|
-
{ value: false, label: 'Public', hint: 'the infra contains no secrets' },
|
|
83
|
-
{ value: true, label: 'Private' },
|
|
84
|
-
],
|
|
85
|
-
}));
|
|
86
|
-
}
|
|
69
|
+
}
|
|
70
|
+
/** Everything that shapes the infrastructure: region, environments, options. */
|
|
71
|
+
async function askConfiguration(out, options) {
|
|
72
|
+
p.intro('Configuration — region, environments and options');
|
|
73
|
+
await askRegion(out);
|
|
87
74
|
if (!out.environments || out.environments.length === 0) {
|
|
88
75
|
const preset = await ask(p.select({
|
|
89
76
|
message: 'Which environments do you want?',
|
|
90
77
|
initialValue: DEFAULT_ENV_PRESET,
|
|
91
78
|
options: [
|
|
92
79
|
{ value: 'prod', label: 'Production only', hint: 'single environment' },
|
|
93
|
-
{
|
|
94
|
-
value: 'staging+prod',
|
|
95
|
-
label: 'Staging + Production',
|
|
96
|
-
hint: 'recommended',
|
|
97
|
-
},
|
|
80
|
+
{ value: 'staging+prod', label: 'Staging + Production', hint: 'recommended' },
|
|
98
81
|
{ value: 'dev+staging+prod', label: 'Dev + Staging + Production' },
|
|
99
82
|
],
|
|
100
83
|
}));
|
|
@@ -114,6 +97,17 @@ export async function fillMissing(partial, options) {
|
|
|
114
97
|
initialValue: true,
|
|
115
98
|
}));
|
|
116
99
|
}
|
|
100
|
+
// Production is the one scaling knob worth surfacing at setup: everything
|
|
101
|
+
// scales to zero when idle, and this is the ceiling. Staging/dev stay 0-1;
|
|
102
|
+
// full per-environment control lives behind --advanced.
|
|
103
|
+
if (!options.advanced && slugs.includes('prod') && out.scaling.prod?.maxScale === undefined) {
|
|
104
|
+
const max = await ask(p.text({
|
|
105
|
+
message: 'Maximum production instances (idle scales to zero; raise later in prod.tfvars)',
|
|
106
|
+
initialValue: String(envDefaultScale('prod').maxScale),
|
|
107
|
+
validate: validate((v) => validateScale(v, 'prod max scale')),
|
|
108
|
+
}));
|
|
109
|
+
out.scaling.prod = { ...(out.scaling.prod ?? {}), maxScale: Number(max) };
|
|
110
|
+
}
|
|
117
111
|
if (options.advanced) {
|
|
118
112
|
const scale = async (message, initial) => Number(await ask(p.text({
|
|
119
113
|
message,
|
|
@@ -128,7 +122,193 @@ export async function fillMissing(partial, options) {
|
|
|
128
122
|
out.scaling[slug] = current;
|
|
129
123
|
}
|
|
130
124
|
}
|
|
131
|
-
|
|
125
|
+
}
|
|
126
|
+
/** GitHub block: repo + token, then verify token, scopes and repo state. */
|
|
127
|
+
async function askGitHub(out) {
|
|
128
|
+
// When resuming a run that already pushed, the repo legitimately has
|
|
129
|
+
// commits: a non-empty repo must not block the resume.
|
|
130
|
+
const targetDir = out.targetDir?.trim() || out.projectName;
|
|
131
|
+
const alreadyPushed = isDone(loadState(targetDir, out.projectName), 'github-push');
|
|
132
|
+
p.intro('GitHub — repository, visibility and token');
|
|
133
|
+
for (;;) {
|
|
134
|
+
if (!out.github.repoName) {
|
|
135
|
+
out.github.repoName = await ask(p.text({
|
|
136
|
+
message: 'GitHub repository name (a new empty repository works best)',
|
|
137
|
+
initialValue: out.projectName,
|
|
138
|
+
validate: validate(validateProjectName),
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
if (out.github.repoPrivate === undefined) {
|
|
142
|
+
out.github.repoPrivate = await ask(p.select({
|
|
143
|
+
message: 'Repository visibility',
|
|
144
|
+
initialValue: false,
|
|
145
|
+
options: [
|
|
146
|
+
{ value: false, label: 'Public', hint: 'the infra contains no secrets' },
|
|
147
|
+
{ value: true, label: 'Private' },
|
|
148
|
+
],
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
if (!out.github.token) {
|
|
152
|
+
out.github.token = await secret('GitHub token (scopes: repo, workflow)');
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const ctx = await createContext({
|
|
156
|
+
token: out.github.token,
|
|
157
|
+
repoName: out.github.repoName,
|
|
158
|
+
repoPrivate: out.github.repoPrivate ?? false,
|
|
159
|
+
});
|
|
160
|
+
const { state } = await inspectRepo(ctx);
|
|
161
|
+
if (!(state === 'non-empty' && alreadyPushed)) {
|
|
162
|
+
assertRepoUsable(ctx, state);
|
|
163
|
+
}
|
|
164
|
+
const repo = `${ctx.owner}/${out.github.repoName}`;
|
|
165
|
+
p.outro(state === 'not-found'
|
|
166
|
+
? `GitHub connected — ${repo} will be created after confirmation.`
|
|
167
|
+
: state === 'non-empty'
|
|
168
|
+
? `GitHub connected — resuming, ${repo} was already pushed.`
|
|
169
|
+
: `GitHub connected — existing empty repository ${repo} will be reused.`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (!(error instanceof GitHubError))
|
|
174
|
+
throw error;
|
|
175
|
+
log.error(error.message);
|
|
176
|
+
if (error.field === 'repo') {
|
|
177
|
+
out.github.repoName = undefined;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
out.github.token = undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Infisical block: host + machine identity, then verify login and project. */
|
|
186
|
+
async function askInfisical(out) {
|
|
187
|
+
p.intro('Infisical — secret-manager project and machine identity');
|
|
188
|
+
if (!out.infisical.host) {
|
|
189
|
+
const choice = await ask(p.select({
|
|
190
|
+
message: 'Infisical host',
|
|
191
|
+
initialValue: 'us',
|
|
192
|
+
options: [
|
|
193
|
+
{ value: 'us', label: 'US — app.infisical.com', hint: 'default' },
|
|
194
|
+
{ value: 'eu', label: 'EU — eu.infisical.com' },
|
|
195
|
+
{ value: 'other', label: 'Other (self-hosted)' },
|
|
196
|
+
],
|
|
197
|
+
}));
|
|
198
|
+
if (choice === 'us')
|
|
199
|
+
out.infisical.host = DEFAULT_INFISICAL_HOST;
|
|
200
|
+
else if (choice === 'eu')
|
|
201
|
+
out.infisical.host = 'https://eu.infisical.com';
|
|
202
|
+
else {
|
|
203
|
+
out.infisical.host = await ask(p.text({
|
|
204
|
+
message: 'Infisical host URL',
|
|
205
|
+
placeholder: 'https://infisical.example.com',
|
|
206
|
+
validate: validate((v) => validateUrl(v, 'Infisical host')),
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (;;) {
|
|
211
|
+
if (!out.infisical.clientId) {
|
|
212
|
+
out.infisical.clientId = await text('Infisical machine identity client ID');
|
|
213
|
+
}
|
|
214
|
+
if (!out.infisical.clientSecret) {
|
|
215
|
+
out.infisical.clientSecret = await secret('Infisical machine identity client secret');
|
|
216
|
+
}
|
|
217
|
+
if (!out.infisical.projectId && !out.infisical.projectName) {
|
|
218
|
+
const id = await ask(p.text({
|
|
219
|
+
message: `Infisical project ID to reuse (leave empty to create "${out.projectName}")`,
|
|
220
|
+
placeholder: 'empty: create a new project',
|
|
221
|
+
defaultValue: '',
|
|
222
|
+
}));
|
|
223
|
+
if (id.trim())
|
|
224
|
+
out.infisical.projectId = id.trim();
|
|
225
|
+
else
|
|
226
|
+
out.infisical.projectName = out.projectName;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const { projectName } = await validateInfisical({
|
|
230
|
+
host: out.infisical.host,
|
|
231
|
+
clientId: out.infisical.clientId,
|
|
232
|
+
clientSecret: out.infisical.clientSecret,
|
|
233
|
+
projectId: out.infisical.projectId,
|
|
234
|
+
});
|
|
235
|
+
if (projectName)
|
|
236
|
+
out.infisical.projectName = projectName;
|
|
237
|
+
p.outro(out.infisical.projectId
|
|
238
|
+
? `Infisical connected — existing project "${projectName}" (${out.infisical.projectId}) will be reused.`
|
|
239
|
+
: `Infisical connected — project "${out.infisical.projectName}" will be created after confirmation.`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
if (!(error instanceof InfisicalError))
|
|
244
|
+
throw error;
|
|
245
|
+
log.error(error.message);
|
|
246
|
+
if (error.field === 'project') {
|
|
247
|
+
out.infisical.projectId = undefined;
|
|
248
|
+
out.infisical.projectName = undefined;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
out.infisical.clientId = undefined;
|
|
252
|
+
out.infisical.clientSecret = undefined;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function askRegion(out) {
|
|
258
|
+
if (out.region)
|
|
259
|
+
return;
|
|
260
|
+
out.region = await ask(p.select({
|
|
261
|
+
message: 'Scaleway region',
|
|
262
|
+
initialValue: DEFAULT_REGION,
|
|
263
|
+
options: REGIONS.map((r) => ({ value: r, label: r })),
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
/** Scaleway block: keys + IDs, then verify with a read-only API call. */
|
|
267
|
+
async function askScaleway(out) {
|
|
268
|
+
p.intro('Scaleway — account keys and project');
|
|
269
|
+
for (;;) {
|
|
270
|
+
if (!out.scaleway.accessKey)
|
|
271
|
+
out.scaleway.accessKey = await text('Scaleway access key');
|
|
272
|
+
if (!out.scaleway.secretKey)
|
|
273
|
+
out.scaleway.secretKey = await secret('Scaleway secret key');
|
|
274
|
+
if (!out.scaleway.projectId)
|
|
275
|
+
out.scaleway.projectId = await text('Scaleway project ID');
|
|
276
|
+
if (!out.scaleway.organizationId) {
|
|
277
|
+
out.scaleway.organizationId = await text('Scaleway organization ID');
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
await validateScalewayCredentials({
|
|
281
|
+
secretKey: out.scaleway.secretKey,
|
|
282
|
+
projectId: out.scaleway.projectId,
|
|
283
|
+
organizationId: out.scaleway.organizationId,
|
|
284
|
+
});
|
|
285
|
+
p.outro('Scaleway connected — credentials valid and project reachable.');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (!(error instanceof ScalewayError))
|
|
290
|
+
throw error;
|
|
291
|
+
log.error(error.message);
|
|
292
|
+
switch (error.code) {
|
|
293
|
+
case 'auth':
|
|
294
|
+
out.scaleway.accessKey = undefined;
|
|
295
|
+
out.scaleway.secretKey = undefined;
|
|
296
|
+
break;
|
|
297
|
+
case 'project':
|
|
298
|
+
out.scaleway.projectId = undefined;
|
|
299
|
+
break;
|
|
300
|
+
case 'organization':
|
|
301
|
+
out.scaleway.organizationId = undefined;
|
|
302
|
+
break;
|
|
303
|
+
default: {
|
|
304
|
+
// Transient API error: nothing to correct, offer a plain retry.
|
|
305
|
+
const retry = await ask(p.confirm({ message: 'Scaleway API error. Retry?' }));
|
|
306
|
+
if (!retry)
|
|
307
|
+
bail();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
132
312
|
}
|
|
133
313
|
export async function confirmSummary(summary) {
|
|
134
314
|
p.note(summary, 'About to create');
|
package/dist/ui.js
CHANGED
|
@@ -48,7 +48,9 @@ export function renderSummary(answers, dryRun) {
|
|
|
48
48
|
'',
|
|
49
49
|
'Infisical',
|
|
50
50
|
` host ${answers.infisical.host}`,
|
|
51
|
-
` project ${answers.infisical.
|
|
51
|
+
` project ${answers.infisical.projectId
|
|
52
|
+
? `${answers.infisical.projectName} (existing, ${answers.infisical.projectId})`
|
|
53
|
+
: `${answers.infisical.projectName} (will be created)`}`,
|
|
52
54
|
` environments ${envSlugs} (+ placeholder secrets)`,
|
|
53
55
|
'',
|
|
54
56
|
`Object Storage ${answers.objectStorage ? 'enabled (per-environment bucket + S3_* secrets)' : 'disabled'}`,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Pushes the secrets Terraform produced to Infisical, so the application reads
|
|
3
|
-
# them at runtime: always DATABASE_URL
|
|
4
|
-
#
|
|
3
|
+
# them at runtime: always DATABASE_URL and APP_URL, plus the S3_* Object
|
|
4
|
+
# Storage coordinates when that feature is enabled.
|
|
5
5
|
# Usage: sync-secrets.sh <environment>
|
|
6
6
|
# Expects: INFISICAL_HOST, INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET,
|
|
7
7
|
# INFISICAL_PROJECT_ID in the environment; terraform init already run.
|
|
@@ -9,8 +9,17 @@ set -euo pipefail
|
|
|
9
9
|
|
|
10
10
|
ENVIRONMENT="${1:?usage: sync-secrets.sh <environment>}"
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# Collect the base "infisical_secrets" output plus any module-contributed
|
|
13
|
+
# "infisical_secrets_<name>" output, merged into one map. A module added to
|
|
14
|
+
# this repo contributes secrets by exposing its own output under that prefix:
|
|
15
|
+
# nothing here or in outputs.tf needs editing.
|
|
16
|
+
SECRETS_JSON="$(terraform output -json | jq -c '
|
|
17
|
+
[to_entries[]
|
|
18
|
+
| select(.key | test("^infisical_secrets(_[a-z0-9_]+)?$"))
|
|
19
|
+
| select(.value.value | type == "object")
|
|
20
|
+
| .value.value]
|
|
21
|
+
| add // {}')"
|
|
22
|
+
if [ "$SECRETS_JSON" = "{}" ] || [ "$SECRETS_JSON" = "null" ]; then
|
|
14
23
|
echo "No secrets to sync."
|
|
15
24
|
exit 0
|
|
16
25
|
fi
|
|
@@ -29,13 +29,31 @@ env:
|
|
|
29
29
|
TF_IN_AUTOMATION: "true"
|
|
30
30
|
|
|
31
31
|
jobs:
|
|
32
|
+
discover:
|
|
33
|
+
name: discover environments
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
outputs:
|
|
36
|
+
environments: ${{ steps.list.outputs.environments }}
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v4
|
|
39
|
+
|
|
40
|
+
# Every <env>.tfvars at the repo root is an environment: adding one to
|
|
41
|
+
# the drift matrix is just adding its tfvars file, no workflow edit.
|
|
42
|
+
- name: List environments from tfvars files
|
|
43
|
+
id: list
|
|
44
|
+
run: |
|
|
45
|
+
environments=$(ls *.tfvars | sed 's/\.tfvars$//' \
|
|
46
|
+
| jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')
|
|
47
|
+
echo "environments=$environments" >> "$GITHUB_OUTPUT"
|
|
48
|
+
|
|
32
49
|
drift:
|
|
33
50
|
name: drift (${{ matrix.environment }})
|
|
51
|
+
needs: discover
|
|
34
52
|
runs-on: ubuntu-latest
|
|
35
53
|
strategy:
|
|
36
54
|
fail-fast: false
|
|
37
55
|
matrix:
|
|
38
|
-
environment:
|
|
56
|
+
environment: ${{ fromJSON(needs.discover.outputs.environments) }}
|
|
39
57
|
steps:
|
|
40
58
|
- uses: actions/checkout@v4
|
|
41
59
|
|
|
@@ -23,13 +23,31 @@ env:
|
|
|
23
23
|
TF_IN_AUTOMATION: "true"
|
|
24
24
|
|
|
25
25
|
jobs:
|
|
26
|
+
discover:
|
|
27
|
+
name: discover environments
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
outputs:
|
|
30
|
+
environments: ${{ steps.list.outputs.environments }}
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v4
|
|
33
|
+
|
|
34
|
+
# Every <env>.tfvars at the repo root is an environment: adding one to
|
|
35
|
+
# the plan matrix is just adding its tfvars file, no workflow edit.
|
|
36
|
+
- name: List environments from tfvars files
|
|
37
|
+
id: list
|
|
38
|
+
run: |
|
|
39
|
+
environments=$(ls *.tfvars | sed 's/\.tfvars$//' \
|
|
40
|
+
| jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')
|
|
41
|
+
echo "environments=$environments" >> "$GITHUB_OUTPUT"
|
|
42
|
+
|
|
26
43
|
plan:
|
|
27
44
|
name: plan (${{ matrix.environment }})
|
|
45
|
+
needs: discover
|
|
28
46
|
runs-on: ubuntu-latest
|
|
29
47
|
strategy:
|
|
30
48
|
fail-fast: false
|
|
31
49
|
matrix:
|
|
32
|
-
environment:
|
|
50
|
+
environment: ${{ fromJSON(needs.discover.outputs.environments) }}
|
|
33
51
|
steps:
|
|
34
52
|
- uses: actions/checkout@v4
|
|
35
53
|
|
package/templates/README.md
CHANGED
|
@@ -49,8 +49,8 @@ merge/push main -> apply each environment in order; any gated environment (pro
|
|
|
49
49
|
- After each apply, the pipeline syncs the secrets Terraform produced to
|
|
50
50
|
Infisical for that environment: a ready-to-use Postgres `DATABASE_URL`
|
|
51
51
|
(whose credential is a dedicated IAM application that can only read/write
|
|
52
|
-
the database, not your main API key)
|
|
53
|
-
the `S3_*` coordinates.
|
|
52
|
+
the database, not your main API key), the app's public `APP_URL` and, when
|
|
53
|
+
Object Storage is enabled, the `S3_*` coordinates.
|
|
54
54
|
- State is locked during applies (S3-native locking, `use_lockfile`), so
|
|
55
55
|
concurrent runs cannot corrupt it.
|
|
56
56
|
- A scheduled **drift detection** workflow (Monday 06:00 UTC, or manual via
|
|
@@ -84,6 +84,7 @@ merge/push main -> apply each environment in order; any gated environment (pro
|
|
|
84
84
|
| Variable | Where | Notes |
|
|
85
85
|
|---|---|---|
|
|
86
86
|
| `DATABASE_URL` | every environment | Complete connection string, auto-updated by the pipeline after each apply. No action needed |
|
|
87
|
+
| `APP_URL` | every environment | Public URL of the app, auto-updated by the pipeline once a container exists. Useful for links, OAuth callbacks, webhooks |
|
|
87
88
|
| `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` | non-production | The app must enforce these when `BASIC_AUTH_ENABLED=true` |
|
|
88
89
|
| `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 |
|
|
89
90
|
|
|
@@ -114,14 +115,37 @@ environment enables it is the `enable_basic_auth` flag in its `<env>.tfvars`.
|
|
|
114
115
|
secrets (repo Settings > Secrets and variables > Actions).
|
|
115
116
|
- **Add a custom domain**: add a `scaleway_container_domain` resource in
|
|
116
117
|
`modules/app_stack` pointing at the container, plus your DNS record.
|
|
117
|
-
- **Add an environment**: add a `<env>.tfvars
|
|
118
|
-
|
|
118
|
+
- **Add an environment**: add a `<env>.tfvars` at the repo root (the plan and
|
|
119
|
+
drift workflows discover environments from the tfvars files automatically),
|
|
120
|
+
create an Infisical environment with the same slug, and add a job in
|
|
119
121
|
`.github/workflows/terraform-apply.yml` (mirror an existing one and set its
|
|
120
|
-
`needs:` to chain after the previous environment).
|
|
122
|
+
`needs:` to chain after the previous environment). Note: the new `plan
|
|
123
|
+
(<env>)` check becomes required on `main` only after you add it in the
|
|
124
|
+
branch-protection settings.
|
|
121
125
|
- **Pin provider versions**: after any local `terraform init`, commit the
|
|
122
126
|
generated `.terraform.lock.hcl` so CI resolves the exact same provider
|
|
123
127
|
builds on every run.
|
|
124
128
|
|
|
129
|
+
## Extending this repo with your own module
|
|
130
|
+
|
|
131
|
+
The layout is designed so additions never edit generated files:
|
|
132
|
+
|
|
133
|
+
1. Put your Terraform in a **new file at the repo root** (e.g. `jobs.tf`,
|
|
134
|
+
optionally with a `modules/<name>/` directory) — Terraform merges all root
|
|
135
|
+
`.tf` files automatically. Reach the app stack only through its outputs,
|
|
136
|
+
never into its internals.
|
|
137
|
+
2. Per-environment configuration goes into the existing `<env>.tfvars` files
|
|
138
|
+
(append new variables; declare them in your own `.tf` file).
|
|
139
|
+
3. If your module produces secrets the app should read, expose them as an
|
|
140
|
+
output named `infisical_secrets_<name>` (a map of string, marked
|
|
141
|
+
`sensitive`). The pipeline collects every output matching that prefix and
|
|
142
|
+
syncs it to Infisical after each apply — no edit to `outputs.tf` or the
|
|
143
|
+
workflows required.
|
|
144
|
+
|
|
145
|
+
`.keel/manifest.json` (committed) records the keel version, the contract
|
|
146
|
+
version and the generation options this repo was created with, so tooling and
|
|
147
|
+
future you know exactly what it is built on.
|
|
148
|
+
|
|
125
149
|
## Running Terraform locally (optional)
|
|
126
150
|
|
|
127
151
|
CI is the source of truth, but plans can be run locally (replace `<env>` with
|
package/templates/_gitignore
CHANGED
package/templates/main.tf
CHANGED
|
@@ -3,6 +3,16 @@ data "infisical_secrets" "app" {
|
|
|
3
3
|
env_slug = var.environment
|
|
4
4
|
workspace_id = var.infisical_project_id
|
|
5
5
|
folder_path = "/"
|
|
6
|
+
|
|
7
|
+
lifecycle {
|
|
8
|
+
# Guards against applying one environment's tfvars into another
|
|
9
|
+
# environment's state (e.g. prod.tfvars while the staging workspace is
|
|
10
|
+
# selected), which would create prod-named resources in staging state.
|
|
11
|
+
precondition {
|
|
12
|
+
condition = terraform.workspace == var.environment
|
|
13
|
+
error_message = "The selected Terraform workspace must match var.environment: run `terraform workspace select <env>` before plan/apply with <env>.tfvars."
|
|
14
|
+
}
|
|
15
|
+
}
|
|
6
16
|
}
|
|
7
17
|
|
|
8
18
|
module "app_stack" {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
locals {
|
|
2
|
+
# <project>-<env>: the naming convention every per-environment resource
|
|
3
|
+
# shares with the CLI (which builds registry hints from it) — do not change
|
|
4
|
+
# one side without the other; contracts.test.ts pins them together.
|
|
2
5
|
name = "${var.project_name}-${var.environment}"
|
|
3
6
|
}
|
|
4
7
|
|
package/templates/outputs.tf
CHANGED
|
@@ -24,13 +24,22 @@ output "object_bucket_name" {
|
|
|
24
24
|
value = module.app_stack.object_bucket_name
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
# The
|
|
28
|
-
#
|
|
27
|
+
# The secrets the pipeline pushes to Infisical after each apply: always
|
|
28
|
+
# DATABASE_URL and APP_URL, plus the S3_* coordinates when Object Storage is
|
|
29
|
+
# enabled. A module added to this repo can contribute its own secrets by
|
|
30
|
+
# exposing an output named "infisical_secrets_<name>" (a map of string): the
|
|
31
|
+
# pipeline collects every output matching that prefix, so extending never
|
|
32
|
+
# requires editing this file.
|
|
29
33
|
output "infisical_secrets" {
|
|
30
34
|
description = "Secrets synced to Infisical after each apply."
|
|
31
35
|
sensitive = true
|
|
32
36
|
value = merge(
|
|
33
|
-
{
|
|
37
|
+
{
|
|
38
|
+
DATABASE_URL = module.app_stack.database_url
|
|
39
|
+
# Public URL of the app; null until a container image is deployed (the
|
|
40
|
+
# pipeline skips null values and syncs it on the first apply after).
|
|
41
|
+
APP_URL = module.app_stack.container_url
|
|
42
|
+
},
|
|
34
43
|
var.enable_object_storage ? {
|
|
35
44
|
S3_BUCKET = module.app_stack.object_bucket_name
|
|
36
45
|
S3_ENDPOINT = module.app_stack.object_bucket_endpoint
|
package/templates/variables.tf
CHANGED
|
@@ -46,6 +46,11 @@ variable "min_scale" {
|
|
|
46
46
|
description = "Minimum number of container instances."
|
|
47
47
|
type = number
|
|
48
48
|
default = 0
|
|
49
|
+
|
|
50
|
+
validation {
|
|
51
|
+
condition = var.min_scale <= var.max_scale
|
|
52
|
+
error_message = "min_scale cannot exceed max_scale."
|
|
53
|
+
}
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
variable "max_scale" {
|