@gambi97/keel-cli 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -48
- package/dist/bootstrap/github.js +97 -37
- package/dist/bootstrap/infisical.js +70 -21
- package/dist/bootstrap/scaleway.js +102 -17
- package/dist/config.js +124 -14
- package/dist/contracts.js +46 -0
- package/dist/generate.js +60 -4
- package/dist/index.js +239 -128
- package/dist/prompts.js +270 -63
- package/dist/ui.js +18 -6
- package/package.json +1 -1
- package/templates/.github/scripts/sync-secrets.sh +65 -0
- package/templates/.github/workflows/terraform-drift.yml +19 -1
- package/templates/.github/workflows/terraform-plan.yml +19 -1
- package/templates/README.md +56 -42
- package/templates/_partials/apply-header.yml +33 -0
- package/templates/_partials/apply-job.yml +34 -0
- package/templates/env.tfvars +8 -0
- package/templates/main.tf +12 -1
- package/templates/modules/app_stack/main.tf +34 -0
- package/templates/modules/app_stack/outputs.tf +18 -0
- package/templates/modules/app_stack/variables.tf +5 -0
- package/templates/outputs.tf +22 -0
- package/templates/variables.tf +8 -2
- package/templates/.github/scripts/sync-database-url.sh +0 -49
- package/templates/.github/workflows/terraform-apply.yml +0 -103
- package/templates/prod.tfvars +0 -7
- package/templates/staging.tfvars +0 -7
package/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,87 +26,289 @@ 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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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),
|
|
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);
|
|
74
|
+
if (!out.environments || out.environments.length === 0) {
|
|
75
|
+
const preset = await ask(p.select({
|
|
76
|
+
message: 'Which environments do you want?',
|
|
77
|
+
initialValue: DEFAULT_ENV_PRESET,
|
|
78
|
+
options: [
|
|
79
|
+
{ value: 'prod', label: 'Production only', hint: 'single environment' },
|
|
80
|
+
{ value: 'staging+prod', label: 'Staging + Production', hint: 'recommended' },
|
|
81
|
+
{ value: 'dev+staging+prod', label: 'Dev + Staging + Production' },
|
|
82
|
+
],
|
|
75
83
|
}));
|
|
84
|
+
out.environments = ENV_PRESETS[preset];
|
|
76
85
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
86
|
+
const slugs = out.environments;
|
|
87
|
+
const hasNonProd = slugs.some((s) => s !== 'prod');
|
|
88
|
+
if (out.objectStorage === undefined) {
|
|
89
|
+
out.objectStorage = await ask(p.confirm({
|
|
90
|
+
message: 'Provision an Object Storage bucket for application files (in addition to the database)?',
|
|
80
91
|
initialValue: false,
|
|
81
|
-
options: [
|
|
82
|
-
{ value: false, label: 'Public', hint: 'the infra contains no secrets' },
|
|
83
|
-
{ value: true, label: 'Private' },
|
|
84
|
-
],
|
|
85
92
|
}));
|
|
86
93
|
}
|
|
87
|
-
if (out.
|
|
88
|
-
out.
|
|
89
|
-
message: 'Protect
|
|
94
|
+
if (hasNonProd && out.basicAuth === undefined) {
|
|
95
|
+
out.basicAuth = await ask(p.confirm({
|
|
96
|
+
message: 'Protect non-production environments with Basic Auth (enforced by your app)?',
|
|
90
97
|
initialValue: true,
|
|
91
98
|
}));
|
|
92
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
|
+
}
|
|
93
111
|
if (options.advanced) {
|
|
94
112
|
const scale = async (message, initial) => Number(await ask(p.text({
|
|
95
113
|
message,
|
|
96
114
|
initialValue: String(initial),
|
|
97
115
|
validate: validate((v) => validateScale(v, 'scale')),
|
|
98
116
|
})));
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
117
|
+
for (const slug of slugs) {
|
|
118
|
+
const def = envDefaultScale(slug);
|
|
119
|
+
const current = out.scaling[slug] ?? {};
|
|
120
|
+
current.minScale = await scale(`${slug} min scale`, current.minScale ?? def.minScale);
|
|
121
|
+
current.maxScale = await scale(`${slug} max scale`, current.maxScale ?? def.maxScale);
|
|
122
|
+
out.scaling[slug] = current;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
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
|
+
}
|
|
103
311
|
}
|
|
104
|
-
return out;
|
|
105
312
|
}
|
|
106
313
|
export async function confirmSummary(summary) {
|
|
107
314
|
p.note(summary, 'About to create');
|
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}`,
|
|
@@ -47,13 +48,24 @@ export function renderSummary(answers, dryRun) {
|
|
|
47
48
|
'',
|
|
48
49
|
'Infisical',
|
|
49
50
|
` host ${answers.infisical.host}`,
|
|
50
|
-
` project ${answers.infisical.
|
|
51
|
-
|
|
51
|
+
` project ${answers.infisical.projectId
|
|
52
|
+
? `${answers.infisical.projectName} (existing, ${answers.infisical.projectId})`
|
|
53
|
+
: `${answers.infisical.projectName} (will be created)`}`,
|
|
54
|
+
` environments ${envSlugs} (+ placeholder secrets)`,
|
|
52
55
|
'',
|
|
53
|
-
`
|
|
54
|
-
|
|
55
|
-
`Scaling prod ${answers.scaling.prodMinScale}-${answers.scaling.prodMaxScale} instances`,
|
|
56
|
+
`Object Storage ${answers.objectStorage ? 'enabled (per-environment bucket + S3_* secrets)' : 'disabled'}`,
|
|
57
|
+
'Environments',
|
|
56
58
|
];
|
|
59
|
+
for (const env of answers.environments) {
|
|
60
|
+
const flags = [
|
|
61
|
+
`scale ${env.minScale}-${env.maxScale}`,
|
|
62
|
+
env.gated ? 'manual approval' : 'auto-deploy',
|
|
63
|
+
env.basicAuth ? 'basic auth' : null,
|
|
64
|
+
]
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join(', ');
|
|
67
|
+
lines.push(` ${env.slug.padEnd(8)} ${flags}`);
|
|
68
|
+
}
|
|
57
69
|
if (dryRun) {
|
|
58
70
|
lines.push('', 'DRY RUN: files will be generated locally, no account will be touched.');
|
|
59
71
|
}
|
|
@@ -69,7 +81,7 @@ export function renderNextSteps(answers, repoUrl, containerUrlHint) {
|
|
|
69
81
|
` project "${answers.infisical.projectName}" with real values.`,
|
|
70
82
|
` 3. Push to main (or merge a PR) to trigger the first terraform apply:`,
|
|
71
83
|
` ${repoUrl}/actions`,
|
|
72
|
-
` 4. Set container_image in
|
|
84
|
+
` 4. Set container_image in ${answers.environments.map((e) => `${e.slug}.tfvars`).join(' / ')} once an image exists.`,
|
|
73
85
|
'',
|
|
74
86
|
`Repository: ${repoUrl}`,
|
|
75
87
|
].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')
|
|
@@ -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
|
|