@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
|
@@ -1,30 +1,36 @@
|
|
|
1
|
-
import { CreateBucketCommand, HeadBucketCommand, PutBucketVersioningCommand, S3Client, } from '@aws-sdk/client-s3';
|
|
1
|
+
import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, PutBucketPolicyCommand, PutBucketVersioningCommand, S3Client, } from '@aws-sdk/client-s3';
|
|
2
2
|
export class ScalewayError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(message, code = 'api') {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
3
8
|
}
|
|
4
9
|
const API_BASE = 'https://api.scaleway.com';
|
|
5
10
|
/**
|
|
6
11
|
* Validate credentials with a harmless read call before creating anything.
|
|
7
|
-
* Also confirms the project belongs to the given organization.
|
|
12
|
+
* Also confirms the project belongs to the given organization. Read-only: it
|
|
13
|
+
* proves the key can see the project, not that it can create resources.
|
|
8
14
|
*/
|
|
9
|
-
export async function validateScalewayCredentials(
|
|
10
|
-
const { secretKey, projectId, organizationId } =
|
|
15
|
+
export async function validateScalewayCredentials(scaleway) {
|
|
16
|
+
const { secretKey, projectId, organizationId } = scaleway;
|
|
11
17
|
const response = await fetch(`${API_BASE}/account/v3/projects/${projectId}`, {
|
|
12
18
|
headers: { 'X-Auth-Token': secretKey },
|
|
13
19
|
});
|
|
14
20
|
if (response.status === 401 || response.status === 403) {
|
|
15
21
|
throw new ScalewayError('Scaleway rejected the API key (401/403). Check SCW_ACCESS_KEY / SCW_SECRET_KEY and ' +
|
|
16
|
-
'make sure the key can read the project and manage Object Storage, Containers and Serverless SQL.');
|
|
22
|
+
'make sure the key can read the project and manage Object Storage, Containers and Serverless SQL.', 'auth');
|
|
17
23
|
}
|
|
18
24
|
if (response.status === 404) {
|
|
19
|
-
throw new ScalewayError(`Scaleway project ${projectId} not found with this API key
|
|
25
|
+
throw new ScalewayError(`Scaleway project ${projectId} not found with this API key.`, 'project');
|
|
20
26
|
}
|
|
21
27
|
if (!response.ok) {
|
|
22
|
-
throw new ScalewayError(`Scaleway API error while validating credentials: HTTP ${response.status}
|
|
28
|
+
throw new ScalewayError(`Scaleway API error while validating credentials: HTTP ${response.status}.`, 'api');
|
|
23
29
|
}
|
|
24
30
|
const project = (await response.json());
|
|
25
31
|
if (project.organization_id && project.organization_id !== organizationId) {
|
|
26
32
|
throw new ScalewayError(`Scaleway project ${projectId} belongs to organization ${project.organization_id}, ` +
|
|
27
|
-
`not ${organizationId}. Check --scw-organization-id
|
|
33
|
+
`not ${organizationId}. Check --scw-organization-id.`, 'organization');
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
36
|
function s3Client(answers) {
|
|
@@ -37,13 +43,90 @@ function s3Client(answers) {
|
|
|
37
43
|
},
|
|
38
44
|
});
|
|
39
45
|
}
|
|
40
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the IAM identity (user or application) that owns the API key, in
|
|
48
|
+
* the `<kind>:<uuid>` principal form bucket policies expect. Returns
|
|
49
|
+
* undefined when the key cannot read IAM; the caller degrades to a warning.
|
|
50
|
+
*/
|
|
51
|
+
async function apiKeyPrincipal(scaleway) {
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(`${API_BASE}/iam/v1alpha1/api-keys/${scaleway.accessKey}`, {
|
|
54
|
+
headers: { 'X-Auth-Token': scaleway.secretKey },
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
return undefined;
|
|
58
|
+
const key = (await response.json());
|
|
59
|
+
if (key.application_id)
|
|
60
|
+
return `application_id:${key.application_id}`;
|
|
61
|
+
if (key.user_id)
|
|
62
|
+
return `user_id:${key.user_id}`;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Restrict the state bucket to the identity that owns the bootstrap API key —
|
|
71
|
+
* the same identity CI authenticates with, so the pipeline keeps working.
|
|
72
|
+
* Terraform state contains the generated credentials (DATABASE_URL, S3 keys),
|
|
73
|
+
* and the per-app IAM policies are project-wide for Object Storage: without
|
|
74
|
+
* this policy the app's own storage credential could read the state. An
|
|
75
|
+
* existing policy is never overwritten. Failures degrade to a warning: a
|
|
76
|
+
* missed lockdown must not strand a half-finished bootstrap.
|
|
77
|
+
*/
|
|
78
|
+
async function lockDownStateBucket(client, bucket, answers) {
|
|
79
|
+
try {
|
|
80
|
+
await client.send(new GetBucketPolicyCommand({ Bucket: bucket }));
|
|
81
|
+
return undefined; // A policy is already in place: leave it alone.
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const status = error.$metadata?.httpStatusCode;
|
|
85
|
+
if (status !== 404) {
|
|
86
|
+
return (`Could not read the policy of bucket "${bucket}" (HTTP ${status ?? 'error'}); ` +
|
|
87
|
+
'the state bucket was NOT restricted to your identity.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const principal = await apiKeyPrincipal(answers.scaleway);
|
|
91
|
+
if (!principal) {
|
|
92
|
+
return ('Could not resolve the IAM identity behind the API key, so the state bucket was NOT ' +
|
|
93
|
+
'restricted: any Object Storage credential in the project can read the Terraform state. ' +
|
|
94
|
+
'Grant the key IAM read access and re-run, or add a bucket policy manually.');
|
|
95
|
+
}
|
|
96
|
+
const policy = {
|
|
97
|
+
Version: '2023-04-17',
|
|
98
|
+
Id: 'keel-state-bucket-bootstrap-identity-only',
|
|
99
|
+
Statement: [
|
|
100
|
+
{
|
|
101
|
+
Sid: 'OnlyBootstrapIdentity',
|
|
102
|
+
Effect: 'Allow',
|
|
103
|
+
Principal: { SCW: principal },
|
|
104
|
+
Action: ['*'],
|
|
105
|
+
Resource: [bucket, `${bucket}/*`],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
await client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) }));
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return (`Could not apply a policy to bucket "${bucket}" ` +
|
|
115
|
+
`(${error instanceof Error ? error.message : String(error)}); ` +
|
|
116
|
+
'the state bucket was NOT restricted to your identity.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Create the Terraform state bucket if it does not exist yet (idempotent) and
|
|
121
|
+
* restrict it to the bootstrap identity via a bucket policy.
|
|
122
|
+
*/
|
|
41
123
|
export async function ensureStateBucket(answers) {
|
|
42
124
|
const client = s3Client(answers);
|
|
43
125
|
const bucket = answers.stateBucket;
|
|
126
|
+
let created = false;
|
|
44
127
|
try {
|
|
45
128
|
await client.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
46
|
-
|
|
129
|
+
// Already exists and we own it (resume): still ensure the policy below.
|
|
47
130
|
}
|
|
48
131
|
catch (error) {
|
|
49
132
|
const status = error.$metadata?.httpStatusCode;
|
|
@@ -54,12 +137,14 @@ export async function ensureStateBucket(answers) {
|
|
|
54
137
|
if (status !== 404 && status !== 301) {
|
|
55
138
|
throw error;
|
|
56
139
|
}
|
|
140
|
+
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
141
|
+
// Versioning protects the state history against accidental overwrites.
|
|
142
|
+
await client.send(new PutBucketVersioningCommand({
|
|
143
|
+
Bucket: bucket,
|
|
144
|
+
VersioningConfiguration: { Status: 'Enabled' },
|
|
145
|
+
}));
|
|
146
|
+
created = true;
|
|
57
147
|
}
|
|
58
|
-
await client
|
|
59
|
-
|
|
60
|
-
await client.send(new PutBucketVersioningCommand({
|
|
61
|
-
Bucket: bucket,
|
|
62
|
-
VersioningConfiguration: { Status: 'Enabled' },
|
|
63
|
-
}));
|
|
64
|
-
return { created: true };
|
|
148
|
+
const policyWarning = await lockDownStateBucket(client, bucket, answers);
|
|
149
|
+
return policyWarning ? { created, policyWarning } : { created };
|
|
65
150
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,12 +1,51 @@
|
|
|
1
1
|
export const REGIONS = ['fr-par', 'nl-ams', 'pl-waw'];
|
|
2
2
|
export const DEFAULT_INFISICAL_HOST = 'https://app.infisical.com';
|
|
3
3
|
export const DEFAULT_REGION = 'fr-par';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The environments keel knows how to provision. The order here is also the
|
|
6
|
+
* deploy order (dev first, prod last) and drives the apply-workflow chain.
|
|
7
|
+
*/
|
|
8
|
+
export const KNOWN_ENV_SLUGS = ['dev', 'staging', 'prod'];
|
|
9
|
+
const ENV_DEFAULTS = {
|
|
10
|
+
dev: {
|
|
11
|
+
displayName: 'Development',
|
|
12
|
+
githubEnvironment: 'dev',
|
|
13
|
+
production: false,
|
|
14
|
+
gated: false,
|
|
15
|
+
minScale: 0,
|
|
16
|
+
maxScale: 1,
|
|
17
|
+
},
|
|
18
|
+
staging: {
|
|
19
|
+
displayName: 'Staging',
|
|
20
|
+
githubEnvironment: 'staging',
|
|
21
|
+
production: false,
|
|
22
|
+
gated: false,
|
|
23
|
+
minScale: 0,
|
|
24
|
+
maxScale: 1,
|
|
25
|
+
},
|
|
26
|
+
prod: {
|
|
27
|
+
displayName: 'Production',
|
|
28
|
+
githubEnvironment: 'production',
|
|
29
|
+
production: true,
|
|
30
|
+
gated: true,
|
|
31
|
+
minScale: 0,
|
|
32
|
+
// Start small: one instance is enough to go live and costs the least;
|
|
33
|
+
// raising the ceiling later is a one-line prod.tfvars change.
|
|
34
|
+
maxScale: 1,
|
|
35
|
+
},
|
|
9
36
|
};
|
|
37
|
+
/** Default min/max scale for an environment, for prompts and summaries. */
|
|
38
|
+
export function envDefaultScale(slug) {
|
|
39
|
+
const def = ENV_DEFAULTS[slug];
|
|
40
|
+
return { minScale: def.minScale, maxScale: def.maxScale };
|
|
41
|
+
}
|
|
42
|
+
/** Named presets exposed on the CLI; the value is the list of env slugs. */
|
|
43
|
+
export const ENV_PRESETS = {
|
|
44
|
+
prod: ['prod'],
|
|
45
|
+
'staging+prod': ['staging', 'prod'],
|
|
46
|
+
'dev+staging+prod': ['dev', 'staging', 'prod'],
|
|
47
|
+
};
|
|
48
|
+
export const DEFAULT_ENV_PRESET = 'staging+prod';
|
|
10
49
|
export class ConfigError extends Error {
|
|
11
50
|
}
|
|
12
51
|
const PROJECT_NAME_RE = /^[a-z](?:[a-z0-9-]{0,48}[a-z0-9])?$/;
|
|
@@ -32,6 +71,35 @@ export function validateScale(value, label) {
|
|
|
32
71
|
}
|
|
33
72
|
return n;
|
|
34
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Parse a `--environments` value into an ordered, de-duplicated slug list.
|
|
76
|
+
* Accepts preset names ("staging+prod") or free lists ("dev,staging,prod").
|
|
77
|
+
*/
|
|
78
|
+
export function parseEnvironments(raw) {
|
|
79
|
+
const trimmed = raw.trim();
|
|
80
|
+
if (ENV_PRESETS[trimmed])
|
|
81
|
+
return ENV_PRESETS[trimmed];
|
|
82
|
+
const slugs = trimmed
|
|
83
|
+
.split(/[\s,+]+/)
|
|
84
|
+
.map((s) => s.trim().toLowerCase())
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
return normalizeEnvSlugs(slugs);
|
|
87
|
+
}
|
|
88
|
+
/** Validate slugs against the known set and return them in deploy order. */
|
|
89
|
+
export function normalizeEnvSlugs(slugs) {
|
|
90
|
+
for (const slug of slugs) {
|
|
91
|
+
if (!KNOWN_ENV_SLUGS.includes(slug)) {
|
|
92
|
+
throw new ConfigError(`Unknown environment "${slug}". Supported environments: ${KNOWN_ENV_SLUGS.join(', ')} ` +
|
|
93
|
+
`(or a preset: ${Object.keys(ENV_PRESETS).join(', ')}).`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const chosen = new Set(slugs);
|
|
97
|
+
const ordered = KNOWN_ENV_SLUGS.filter((s) => chosen.has(s));
|
|
98
|
+
if (ordered.length === 0) {
|
|
99
|
+
throw new ConfigError('At least one environment is required.');
|
|
100
|
+
}
|
|
101
|
+
return ordered;
|
|
102
|
+
}
|
|
35
103
|
export function validateUrl(value, label) {
|
|
36
104
|
let parsed;
|
|
37
105
|
try {
|
|
@@ -68,6 +136,7 @@ export function fromEnv(env) {
|
|
|
68
136
|
host: env.INFISICAL_HOST,
|
|
69
137
|
clientId: env.INFISICAL_CLIENT_ID,
|
|
70
138
|
clientSecret: env.INFISICAL_CLIENT_SECRET,
|
|
139
|
+
projectId: env.INFISICAL_PROJECT_ID,
|
|
71
140
|
},
|
|
72
141
|
github: {
|
|
73
142
|
token: env.GITHUB_TOKEN ?? env.GH_TOKEN,
|
|
@@ -79,26 +148,68 @@ export function fromEnv(env) {
|
|
|
79
148
|
export function mergeAnswers(...sources) {
|
|
80
149
|
const out = { scaleway: {}, infisical: {}, github: {}, scaling: {} };
|
|
81
150
|
for (const src of sources) {
|
|
82
|
-
for (const key of [
|
|
151
|
+
for (const key of [
|
|
152
|
+
'projectName',
|
|
153
|
+
'region',
|
|
154
|
+
'targetDir',
|
|
155
|
+
'basicAuth',
|
|
156
|
+
'objectStorage',
|
|
157
|
+
]) {
|
|
83
158
|
const value = src[key];
|
|
84
159
|
if (value !== undefined) {
|
|
85
160
|
out[key] = value;
|
|
86
161
|
}
|
|
87
162
|
}
|
|
88
|
-
|
|
163
|
+
if (src.environments !== undefined) {
|
|
164
|
+
out.environments = src.environments;
|
|
165
|
+
}
|
|
166
|
+
for (const group of ['scaleway', 'infisical', 'github']) {
|
|
89
167
|
for (const [k, v] of Object.entries(src[group] ?? {})) {
|
|
90
168
|
if (v !== undefined) {
|
|
91
169
|
out[group][k] = v;
|
|
92
170
|
}
|
|
93
171
|
}
|
|
94
172
|
}
|
|
173
|
+
// Scaling is a per-slug record: merge each slug's fields, deepest wins.
|
|
174
|
+
for (const [slug, override] of Object.entries(src.scaling ?? {})) {
|
|
175
|
+
if (!override)
|
|
176
|
+
continue;
|
|
177
|
+
const current = out.scaling[slug] ?? {};
|
|
178
|
+
if (override.minScale !== undefined)
|
|
179
|
+
current.minScale = override.minScale;
|
|
180
|
+
if (override.maxScale !== undefined)
|
|
181
|
+
current.maxScale = override.maxScale;
|
|
182
|
+
out.scaling[slug] = current;
|
|
183
|
+
}
|
|
95
184
|
}
|
|
96
185
|
return out;
|
|
97
186
|
}
|
|
187
|
+
/** Build the resolved environment list from selected slugs + overrides. */
|
|
188
|
+
export function resolveEnvironments(slugs, basicAuth, overrides) {
|
|
189
|
+
return slugs.map((slug) => {
|
|
190
|
+
const def = ENV_DEFAULTS[slug];
|
|
191
|
+
const ov = overrides[slug] ?? {};
|
|
192
|
+
return {
|
|
193
|
+
slug,
|
|
194
|
+
displayName: def.displayName,
|
|
195
|
+
githubEnvironment: def.githubEnvironment,
|
|
196
|
+
production: def.production,
|
|
197
|
+
gated: def.gated,
|
|
198
|
+
// Basic Auth is a non-production safety net; production is never gated by it.
|
|
199
|
+
basicAuth: def.production ? false : basicAuth,
|
|
200
|
+
minScale: validateScale(ov.minScale ?? def.minScale, `${slug} min scale`),
|
|
201
|
+
maxScale: validateScale(ov.maxScale ?? def.maxScale, `${slug} max scale`),
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
}
|
|
98
205
|
/** Validate a fully-collected set of answers and freeze it into a Config. */
|
|
99
206
|
export function finalizeAnswers(partial) {
|
|
100
207
|
const projectName = validateProjectName(requireString(partial.projectName, 'project name'));
|
|
101
208
|
const region = validateRegion(partial.region ?? DEFAULT_REGION);
|
|
209
|
+
const slugs = partial.environments && partial.environments.length > 0
|
|
210
|
+
? normalizeEnvSlugs(partial.environments)
|
|
211
|
+
: ENV_PRESETS[DEFAULT_ENV_PRESET];
|
|
212
|
+
const basicAuth = partial.basicAuth ?? true;
|
|
102
213
|
return {
|
|
103
214
|
projectName,
|
|
104
215
|
region,
|
|
@@ -115,19 +226,18 @@ export function finalizeAnswers(partial) {
|
|
|
115
226
|
clientId: requireString(partial.infisical.clientId, 'Infisical client ID'),
|
|
116
227
|
clientSecret: requireString(partial.infisical.clientSecret, 'Infisical client secret'),
|
|
117
228
|
projectName: partial.infisical.projectName?.trim() || projectName,
|
|
229
|
+
...(partial.infisical.projectId?.trim()
|
|
230
|
+
? { projectId: partial.infisical.projectId.trim() }
|
|
231
|
+
: {}),
|
|
118
232
|
},
|
|
119
233
|
github: {
|
|
120
234
|
token: requireString(partial.github.token, 'GitHub token'),
|
|
121
235
|
repoName: validateProjectName(partial.github.repoName?.trim() || projectName),
|
|
122
236
|
repoPrivate: partial.github.repoPrivate ?? false,
|
|
123
237
|
},
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
stagingMaxScale: validateScale(partial.scaling.stagingMaxScale ?? DEFAULT_SCALING.stagingMaxScale, 'staging max scale'),
|
|
128
|
-
prodMinScale: validateScale(partial.scaling.prodMinScale ?? DEFAULT_SCALING.prodMinScale, 'prod min scale'),
|
|
129
|
-
prodMaxScale: validateScale(partial.scaling.prodMaxScale ?? DEFAULT_SCALING.prodMaxScale, 'prod max scale'),
|
|
130
|
-
},
|
|
238
|
+
basicAuth,
|
|
239
|
+
objectStorage: partial.objectStorage ?? false,
|
|
240
|
+
environments: resolveEnvironments(slugs, basicAuth, partial.scaling),
|
|
131
241
|
};
|
|
132
242
|
}
|
|
133
243
|
/** List which required values are still missing, for non-interactive runs. */
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names shared between the bootstrap code and the generated repository.
|
|
3
|
+
*
|
|
4
|
+
* The CLI configures the user's account by name (required status checks,
|
|
5
|
+
* Actions secrets/variables, seeded Infisical secrets) and the generated
|
|
6
|
+
* workflows and Terraform reference the same names. A rename on one side
|
|
7
|
+
* without the other passes CI here and breaks at runtime in the user's
|
|
8
|
+
* account — so both sides import these constants, and contracts.test.ts
|
|
9
|
+
* renders the templates and asserts they still agree.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Job name of the plan workflow for one environment; branch protection
|
|
13
|
+
* requires exactly these contexts before a PR can merge.
|
|
14
|
+
*/
|
|
15
|
+
export function planStatusCheckContext(environment) {
|
|
16
|
+
return `plan (${environment})`;
|
|
17
|
+
}
|
|
18
|
+
/** Root Terraform output consumed by .github/scripts/sync-secrets.sh. */
|
|
19
|
+
export const INFISICAL_SECRETS_OUTPUT = 'infisical_secrets';
|
|
20
|
+
/**
|
|
21
|
+
* Object Storage coordinates: seeded as placeholders by the CLI, produced by
|
|
22
|
+
* the infisical_secrets output, synced to Infisical by the pipeline.
|
|
23
|
+
*/
|
|
24
|
+
export const S3_SECRET_KEYS = [
|
|
25
|
+
'S3_BUCKET',
|
|
26
|
+
'S3_ENDPOINT',
|
|
27
|
+
'S3_REGION',
|
|
28
|
+
'S3_ACCESS_KEY',
|
|
29
|
+
'S3_SECRET_KEY',
|
|
30
|
+
];
|
|
31
|
+
/** Encrypted Actions secrets set by configureRepo, read by the workflows. */
|
|
32
|
+
export const CI_SECRET_NAMES = [
|
|
33
|
+
'SCW_ACCESS_KEY',
|
|
34
|
+
'SCW_SECRET_KEY',
|
|
35
|
+
'SCW_DEFAULT_PROJECT_ID',
|
|
36
|
+
'SCW_DEFAULT_ORGANIZATION_ID',
|
|
37
|
+
'INFISICAL_CLIENT_ID',
|
|
38
|
+
'INFISICAL_CLIENT_SECRET',
|
|
39
|
+
];
|
|
40
|
+
/** Plain Actions variables set by configureRepo, read by the workflows. */
|
|
41
|
+
export const CI_VARIABLE_NAMES = [
|
|
42
|
+
'TF_STATE_BUCKET',
|
|
43
|
+
'SCW_REGION',
|
|
44
|
+
'INFISICAL_PROJECT_ID',
|
|
45
|
+
'INFISICAL_HOST',
|
|
46
|
+
];
|
package/dist/generate.js
CHANGED
|
@@ -12,17 +12,59 @@ const RENAMES = {
|
|
|
12
12
|
export function templatesDir() {
|
|
13
13
|
return fileURLToPath(new URL('../templates', import.meta.url));
|
|
14
14
|
}
|
|
15
|
+
/** Global tokens shared by every non per-environment template. */
|
|
15
16
|
export function tokenMap(answers) {
|
|
17
|
+
const slugs = answers.environments.map((e) => e.slug);
|
|
16
18
|
return {
|
|
17
19
|
__PROJECT_NAME__: answers.projectName,
|
|
18
20
|
__REGION__: answers.region,
|
|
19
21
|
__TF_STATE_BUCKET__: answers.stateBucket,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
__PROD_MIN_SCALE__: String(answers.scaling.prodMinScale),
|
|
23
|
-
__PROD_MAX_SCALE__: String(answers.scaling.prodMaxScale),
|
|
22
|
+
// HCL list for the `environment` variable validation in variables.tf.
|
|
23
|
+
__ENV_SLUGS_TF__: `[${slugs.map((s) => `"${s}"`).join(', ')}]`,
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
/** Tokens for one rendered `<env>.tfvars` file. */
|
|
27
|
+
function envTfvarsTokens(answers, env) {
|
|
28
|
+
return {
|
|
29
|
+
__PROJECT_NAME__: answers.projectName,
|
|
30
|
+
__REGION__: answers.region,
|
|
31
|
+
__ENVIRONMENT__: env.slug,
|
|
32
|
+
__ENABLE_BASIC_AUTH__: String(env.basicAuth),
|
|
33
|
+
__ENABLE_OBJECT_STORAGE__: String(answers.objectStorage),
|
|
34
|
+
__MIN_SCALE__: String(env.minScale),
|
|
35
|
+
__MAX_SCALE__: String(env.maxScale),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Templates rendered specially (per environment) instead of copied 1:1.
|
|
40
|
+
*
|
|
41
|
+
* Keep this set small: the templating here is plain token replacement by
|
|
42
|
+
* design. A conditional feature must become a Terraform variable rendered
|
|
43
|
+
* into tfvars (as enable_object_storage does), never a conditionally emitted
|
|
44
|
+
* file — the day a feature cannot be expressed that way is the day to adopt
|
|
45
|
+
* a real template engine, not to add another special case here.
|
|
46
|
+
*/
|
|
47
|
+
const SPECIAL_TEMPLATES = new Set(['env.tfvars', '.github/workflows/terraform-apply.yml']);
|
|
48
|
+
function isSpecial(rel) {
|
|
49
|
+
return SPECIAL_TEMPLATES.has(rel) || rel.startsWith('_partials/');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Assemble the apply workflow: the shared header, then one job per environment
|
|
53
|
+
* in deploy order, each `needs:` the previous so applies run sequentially.
|
|
54
|
+
*/
|
|
55
|
+
function renderApplyWorkflow(source, answers, globalTokens) {
|
|
56
|
+
const header = renderContent(readFileSync(join(source, '_partials/apply-header.yml'), 'utf8'), globalTokens, '_partials/apply-header.yml');
|
|
57
|
+
const jobTemplate = readFileSync(join(source, '_partials/apply-job.yml'), 'utf8');
|
|
58
|
+
const jobs = answers.environments.map((env, i) => {
|
|
59
|
+
const previous = answers.environments[i - 1];
|
|
60
|
+
return renderContent(jobTemplate, {
|
|
61
|
+
__ENV_SLUG__: env.slug,
|
|
62
|
+
__GH_ENVIRONMENT__: env.githubEnvironment,
|
|
63
|
+
__NEEDS_LINE__: previous ? ` needs: apply-${previous.slug}\n` : '',
|
|
64
|
+
}, '_partials/apply-job.yml');
|
|
65
|
+
});
|
|
66
|
+
return header + jobs.join('\n');
|
|
67
|
+
}
|
|
26
68
|
function renderContent(content, tokens, file) {
|
|
27
69
|
let out = content;
|
|
28
70
|
for (const [token, value] of Object.entries(tokens)) {
|
|
@@ -59,6 +101,8 @@ export function generateProject(answers, options = {}) {
|
|
|
59
101
|
mkdirSync(target, { recursive: true });
|
|
60
102
|
const written = [];
|
|
61
103
|
for (const rel of walk(source)) {
|
|
104
|
+
if (isSpecial(rel))
|
|
105
|
+
continue;
|
|
62
106
|
const parts = rel.split('/');
|
|
63
107
|
const fileName = parts[parts.length - 1] ?? rel;
|
|
64
108
|
const destRel = [...parts.slice(0, -1), RENAMES[fileName] ?? fileName].join('/');
|
|
@@ -71,6 +115,18 @@ export function generateProject(answers, options = {}) {
|
|
|
71
115
|
}
|
|
72
116
|
written.push(destRel);
|
|
73
117
|
}
|
|
118
|
+
// Per-environment tfvars: one <slug>.tfvars from the shared env.tfvars template.
|
|
119
|
+
const envTemplate = readFileSync(join(source, 'env.tfvars'), 'utf8');
|
|
120
|
+
for (const env of answers.environments) {
|
|
121
|
+
const destRel = `${env.slug}.tfvars`;
|
|
122
|
+
writeFileSync(join(target, destRel), renderContent(envTemplate, envTfvarsTokens(answers, env), 'env.tfvars'));
|
|
123
|
+
written.push(destRel);
|
|
124
|
+
}
|
|
125
|
+
// Apply workflow: a header plus one deploy job per environment, chained so
|
|
126
|
+
// each environment applies only after the previous one succeeded.
|
|
127
|
+
const applyDestRel = '.github/workflows/terraform-apply.yml';
|
|
128
|
+
writeFileSync(join(target, applyDestRel), renderApplyWorkflow(source, answers, tokens));
|
|
129
|
+
written.push(applyDestRel);
|
|
74
130
|
// backend.hcl (git-ignored) so local terraform runs work out of the box.
|
|
75
131
|
cpSync(join(target, 'backend.hcl.example'), join(target, 'backend.hcl'));
|
|
76
132
|
if (options.git !== false) {
|