@gambi97/keel-cli 0.2.0 → 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 CHANGED
@@ -28,6 +28,14 @@ provisioned by Terraform, deployed by GitHub Actions, with secrets in
28
28
  Docker image, keel gives it a place to run. You answer a few questions, push
29
29
  to `main`, and the infrastructure is live.
30
30
 
31
+ **keel is a starting point, not a control plane.** It runs once to lay the
32
+ foundation and hand you a repository you fully own. From then on, day-2 work —
33
+ scaling, new environments, custom domains, rotating secrets — happens in that
34
+ repository: edit a tfvars line, open a PR, merge. keel does not stay in the
35
+ loop, has nothing to update, and never manages your infrastructure after the
36
+ first setup. If you outgrow the generated layout, it is plain Terraform you
37
+ can take anywhere.
38
+
31
39
  - **Near-free to start.** Compute and database scale to zero; an idle project
32
40
  costs cents per month, with no expiring trial.
33
41
  - **Scales with your product.** Pick your environments (production only, or
@@ -47,10 +55,13 @@ GitHub (see [Prerequisites](#prerequisites) for how to get each one).
47
55
  npx @gambi97/keel-cli
48
56
  ```
49
57
 
50
- The CLI asks for a project name, region, repository name and visibility, picks
51
- up `SCW_*` / `INFISICAL_*` / `GITHUB_TOKEN` from your environment as defaults,
52
- then shows a **complete summary of what it will create and where**. Nothing is
53
- touched before you confirm. When it finishes, push to `main` (or merge the
58
+ The CLI asks for a project name, region and environments, then walks one
59
+ provider at a time GitHub, Infisical, Scaleway verifying each set of
60
+ credentials with read-only calls the moment you enter it (a bad token or a
61
+ non-empty repository is reported immediately, and only that answer is asked
62
+ again). It picks up `SCW_*` / `INFISICAL_*` / `GITHUB_TOKEN` from your
63
+ environment as defaults, then shows a **complete summary of what it will
64
+ create and where**. Nothing is touched before you confirm. When it finishes, push to `main` (or merge the
54
65
  first PR) and the pipeline provisions the infrastructure.
55
66
 
56
67
  Non-interactive and dry-run:
@@ -155,7 +166,7 @@ local tooling or production credentials.
155
166
  | Where | What |
156
167
  | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
157
168
  | Your machine | The generated repo: Terraform, workflows, README, initial git commit |
158
- | Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking) |
169
+ | Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking, restricted by a bucket policy to the identity behind your API key) |
159
170
  | GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; one deployment environment per selected environment (`production` gated by manual approval); branch protection on `main` |
160
171
  | Infisical | A project with one environment per selected environment, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (non-production, random password), a `DATABASE_URL` placeholder per environment, and `S3_*` placeholders when Object Storage is enabled |
161
172
 
@@ -283,10 +294,18 @@ creating anything**.
283
294
  plan, production applies need manual approval.
284
295
  - Terraform state lives in a private, versioned bucket with S3-native state
285
296
  locking (`use_lockfile`), so concurrent applies cannot corrupt it.
297
+ - The state bucket is **restricted by a bucket policy** to the identity that
298
+ owns the API key you give keel — the same identity CI authenticates with,
299
+ so the pipeline keeps working. Terraform state contains the generated
300
+ credentials (`DATABASE_URL`, `S3_*`), so no other principal in the project —
301
+ including the app's own credentials — can read it. Note that this also
302
+ hides the bucket from other console users; manage or remove the policy with
303
+ that same API key if you need to open it up.
286
304
  - The app connects to its database with a **dedicated least-privilege IAM
287
305
  credential** (read/write on that database, nothing else), not with your
288
306
  main API key. Object Storage, when enabled, gets its own separate dedicated
289
- credential the same way.
307
+ credential that can only use Object Storage (and, thanks to the bucket
308
+ policy above, cannot read the Terraform state).
290
309
  - A weekly drift-detection plan opens an issue when the real infrastructure
291
310
  no longer matches the code.
292
311
 
@@ -328,6 +347,17 @@ No. The CLI bootstraps via APIs; Terraform runs inside GitHub Actions.
328
347
  Yes. The CLI asks for the name and the visibility; default is public (the
329
348
  infra holds no secrets), or choose private interactively or with `--private`.
330
349
 
350
+ **Can I point keel at an existing repository?**
351
+ Only if it has no commits. keel pushes a brand-new history, so a repository
352
+ that already has commits (even just a README from the GitHub UI) would reject
353
+ the push; the CLI checks this up front and asks for another name. The easiest
354
+ path is to let keel create the repository for you.
355
+
356
+ **Can I reuse an existing Infisical project?**
357
+ Yes: pass its project ID (`--infisical-project-id` or interactively). The CLI
358
+ verifies the machine identity can access it before anything is created. Leave
359
+ it empty and keel creates a project named after your app instead.
360
+
331
361
  **Why is the container not created on the first apply?**
332
362
  A Serverless Container needs an image, and none exists yet. Registry and
333
363
  database are created immediately; the container is gated on
@@ -343,9 +373,10 @@ You choose at creation: production only, staging + production (default), or
343
373
  dev + staging + production — interactively or with `--environments`
344
374
  (e.g. `--environments prod` or `--environments dev,staging,prod`). Production
345
375
  is always gated by a manual approval; non-production environments enable Basic
346
- Auth by default. To add one to an existing repo later: add a `<env>.tfvars`,
347
- an Infisical environment, a matrix entry in the plan/drift workflows, and a
348
- chained job in `terraform-apply.yml`.
376
+ Auth by default. To add one to an existing repo later: add a `<env>.tfvars`
377
+ (the plan and drift workflows discover environments from the tfvars files at
378
+ the repo root automatically), an Infisical environment with the same slug, and
379
+ a chained job in `terraform-apply.yml`.
349
380
 
350
381
  **Can I store files, not just rows?**
351
382
  Yes, opt in with `--object-storage` (or answer yes interactively). Each
@@ -366,7 +397,9 @@ off by default — many apps only need the database.
366
397
  --infisical-host <url> or env INFISICAL_HOST (default: https://app.infisical.com)
367
398
  --infisical-client-id <id> or env INFISICAL_CLIENT_ID
368
399
  --infisical-client-secret <s> or env INFISICAL_CLIENT_SECRET
369
- --infisical-project-name <n> Infisical project (default: project name)
400
+ --infisical-project-id <id> Existing Infisical project ID to reuse
401
+ (or env INFISICAL_PROJECT_ID; default: create by name)
402
+ --infisical-project-name <n> Infisical project name (default: project name)
370
403
  --github-token <token> or env GITHUB_TOKEN / GH_TOKEN (scopes: repo, workflow)
371
404
  --repo-name <name> GitHub repository name (default: project name)
372
405
  --private / --public Repository visibility (default: public)
@@ -374,10 +407,11 @@ off by default — many apps only need the database.
374
407
  (or a list like "dev,staging,prod"; default staging+prod)
375
408
  --object-storage Provision a per-environment Object Storage bucket
376
409
  --no-object-storage Do not provision Object Storage (default)
410
+ --basic-auth Enable Basic Auth on non-production environments (default)
377
411
  --no-basic-auth Disable Basic Auth on non-production environments
378
412
  --dev-min-scale <n> Default 0 --dev-max-scale <n> Default 1
379
413
  --staging-min-scale <n> Default 0 --staging-max-scale <n> Default 1
380
- --prod-min-scale <n> Default 0 --prod-max-scale <n> Default 2
414
+ --prod-min-scale <n> Default 0 --prod-max-scale <n> Default 1
381
415
  --config <file.json> Load answers from a JSON file
382
416
  --advanced Also ask scaling questions interactively
383
417
  --yes Accept defaults, skip the confirmation prompt
@@ -4,47 +4,104 @@ import { createRequire } from 'node:module';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { Octokit } from '@octokit/rest';
7
+ import { planStatusCheckContext } from '../contracts.js';
7
8
  // The ESM build of libsodium-wrappers is broken (missing libsodium.mjs), so
8
9
  // load the CommonJS build explicitly.
9
10
  const require = createRequire(import.meta.url);
10
11
  const sodium = require('libsodium-wrappers');
11
12
  export class GitHubError extends Error {
13
+ field;
14
+ constructor(message, field = 'token') {
15
+ super(message);
16
+ this.field = field;
17
+ }
12
18
  }
13
- export async function createContext(answers) {
14
- const octokit = new Octokit({ auth: answers.github.token });
19
+ export async function createContext(github) {
20
+ // Silence Octokit's own request logging: expected non-2xx responses (a 404
21
+ // for a repo that does not exist yet, a 401 for a bad token) are handled
22
+ // here and would otherwise scribble over the prompt spinner.
23
+ const octokit = new Octokit({
24
+ auth: github.token,
25
+ log: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
26
+ });
15
27
  let login;
16
28
  let ownerId;
29
+ let scopes;
17
30
  try {
18
- const { data } = await octokit.users.getAuthenticated();
31
+ const { data, headers } = await octokit.users.getAuthenticated();
19
32
  login = data.login;
20
33
  ownerId = data.id;
34
+ scopes = headers['x-oauth-scopes'];
21
35
  }
22
36
  catch {
23
37
  throw new GitHubError('GitHub rejected the token. It needs the "repo" and "workflow" scopes ' +
24
38
  '(classic PAT) or equivalent fine-grained permissions.');
25
39
  }
40
+ // Classic PATs advertise their scopes in a header; fine-grained tokens do
41
+ // not, so an absent/empty header only means "cannot check here".
42
+ if (scopes) {
43
+ const granted = new Set(scopes.split(',').map((s) => s.trim()));
44
+ for (const required of ['repo', 'workflow']) {
45
+ if (!granted.has(required)) {
46
+ throw new GitHubError(`The GitHub token is missing the "${required}" scope (it has: ${scopes}). ` +
47
+ 'Create a classic PAT with the "repo" and "workflow" scopes.');
48
+ }
49
+ }
50
+ }
26
51
  return {
27
52
  octokit,
28
53
  owner: login,
29
54
  ownerId,
30
- repo: answers.github.repoName,
31
- repoPrivate: answers.github.repoPrivate,
55
+ repo: github.repoName,
56
+ repoPrivate: github.repoPrivate,
32
57
  };
33
58
  }
34
- /** Create the repository, or reuse it when it already exists. */
35
- export async function ensureRepo(ctx) {
59
+ /** Read-only look at the target repository: existence, push access, emptiness. */
60
+ export async function inspectRepo(ctx) {
61
+ let url;
36
62
  try {
37
63
  const { data } = await ctx.octokit.repos.get({ owner: ctx.owner, repo: ctx.repo });
38
- if (!data.permissions?.push) {
39
- throw new GitHubError(`Repository ${ctx.owner}/${ctx.repo} exists but the token cannot push to it.`);
40
- }
41
- return { created: false, url: data.html_url };
64
+ if (!data.permissions?.push)
65
+ return { state: 'no-push', url: data.html_url };
66
+ url = data.html_url;
42
67
  }
43
68
  catch (error) {
44
- if (error instanceof GitHubError)
45
- throw error;
46
- if (error.status !== 404)
47
- throw error;
69
+ if (error.status === 404)
70
+ return { state: 'not-found' };
71
+ throw error;
72
+ }
73
+ try {
74
+ const { data } = await ctx.octokit.repos.listCommits({
75
+ owner: ctx.owner,
76
+ repo: ctx.repo,
77
+ per_page: 1,
78
+ });
79
+ return { state: data.length > 0 ? 'non-empty' : 'empty', url };
80
+ }
81
+ catch (error) {
82
+ // GitHub answers 409 "Git Repository is empty" for a repo with no commits.
83
+ if (error.status === 409)
84
+ return { state: 'empty', url };
85
+ throw error;
86
+ }
87
+ }
88
+ /** Turn a blocking repo state into a GitHubError; pass on 'not-found'/'empty'. */
89
+ export function assertRepoUsable(ctx, state) {
90
+ if (state === 'no-push') {
91
+ throw new GitHubError(`Repository ${ctx.owner}/${ctx.repo} exists but the token cannot push to it.`, 'repo');
92
+ }
93
+ if (state === 'non-empty') {
94
+ throw new GitHubError(`Repository ${ctx.owner}/${ctx.repo} already has commits. keel pushes a brand-new ` +
95
+ 'history, so the push would be rejected: use a new repository name (keel creates ' +
96
+ 'it for you) or an existing repository with no commits.', 'repo');
97
+ }
98
+ }
99
+ /** Create the repository, or reuse it when it already exists and is empty. */
100
+ export async function ensureRepo(ctx) {
101
+ const inspected = await inspectRepo(ctx);
102
+ assertRepoUsable(ctx, inspected.state);
103
+ if (inspected.state !== 'not-found') {
104
+ return { created: false, url: inspected.url };
48
105
  }
49
106
  const { data } = await ctx.octokit.repos.createForAuthenticatedUser({
50
107
  name: ctx.repo,
@@ -100,21 +157,25 @@ export function pushRepo(ctx, token, targetDir) {
100
157
  /** Encrypt and upload every CI secret, then set the plain variables. */
101
158
  export async function configureRepo(ctx, answers, infisicalProjectId) {
102
159
  // The workflows map AWS_* (state backend) from the same SCW_* secrets, so
103
- // each credential is stored once and rotated in one place.
104
- await setSecrets(ctx, {
160
+ // each credential is stored once and rotated in one place. The Record types
161
+ // force these names to stay in lockstep with the contracts the generated
162
+ // workflows are tested against.
163
+ const secrets = {
105
164
  SCW_ACCESS_KEY: answers.scaleway.accessKey,
106
165
  SCW_SECRET_KEY: answers.scaleway.secretKey,
107
166
  SCW_DEFAULT_PROJECT_ID: answers.scaleway.projectId,
108
167
  SCW_DEFAULT_ORGANIZATION_ID: answers.scaleway.organizationId,
109
168
  INFISICAL_CLIENT_ID: answers.infisical.clientId,
110
169
  INFISICAL_CLIENT_SECRET: answers.infisical.clientSecret,
111
- });
112
- await setVariables(ctx, {
170
+ };
171
+ await setSecrets(ctx, secrets);
172
+ const variables = {
113
173
  TF_STATE_BUCKET: answers.stateBucket,
114
174
  SCW_REGION: answers.region,
115
175
  INFISICAL_PROJECT_ID: infisicalProjectId,
116
176
  INFISICAL_HOST: answers.infisical.host,
117
- });
177
+ };
178
+ await setVariables(ctx, variables);
118
179
  await configureEnvironments(ctx, answers);
119
180
  await protectMainBranch(ctx, answers);
120
181
  }
@@ -176,8 +237,9 @@ async function protectMainBranch(ctx, answers) {
176
237
  branch: 'main',
177
238
  required_status_checks: {
178
239
  strict: false,
179
- // Matches the job names produced by the plan workflow: "plan (<slug>)".
180
- contexts: answers.environments.map((env) => `plan (${env.slug})`),
240
+ // Must match the job names produced by the plan workflow; the shared
241
+ // format lives in contracts.ts and is tested against the template.
242
+ contexts: answers.environments.map((env) => planStatusCheckContext(env.slug)),
181
243
  },
182
244
  enforce_admins: false,
183
245
  required_pull_request_reviews: null,
@@ -1,5 +1,11 @@
1
1
  import { randomBytes } from 'node:crypto';
2
+ import { S3_SECRET_KEYS } from '../contracts.js';
2
3
  export class InfisicalError extends Error {
4
+ field;
5
+ constructor(message, field = 'credentials') {
6
+ super(message);
7
+ this.field = field;
8
+ }
3
9
  }
4
10
  async function api(host, path, options = {}) {
5
11
  const response = await fetch(`${host}${path}`, {
@@ -20,8 +26,8 @@ async function api(host, path, options = {}) {
20
26
  }
21
27
  return { status: response.status, data };
22
28
  }
23
- export async function login(answers) {
24
- const { host, clientId, clientSecret } = answers.infisical;
29
+ export async function login(infisical) {
30
+ const { host, clientId, clientSecret } = infisical;
25
31
  const { status, data } = await api(host, '/api/v1/auth/universal-auth/login', { method: 'POST', body: { clientId, clientSecret } });
26
32
  if (status !== 200 || !data.accessToken) {
27
33
  throw new InfisicalError(`Infisical Universal Auth login failed (HTTP ${status}${data.message ? `: ${data.message}` : ''}). ` +
@@ -29,11 +35,18 @@ export async function login(answers) {
29
35
  }
30
36
  return data.accessToken;
31
37
  }
32
- async function findProject(host, token, name) {
38
+ async function listProjects(host, token) {
33
39
  const { status, data } = await api(host, '/api/v1/workspace', { token });
34
40
  if (status !== 200)
35
- return undefined;
36
- return data.workspaces?.find((w) => w.name === name);
41
+ return [];
42
+ return data.workspaces ?? [];
43
+ }
44
+ async function findProject(host, token, name) {
45
+ return (await listProjects(host, token)).find((w) => w.name === name);
46
+ }
47
+ /** Look an existing project up by ID among those the identity can access. */
48
+ export async function findProjectById(host, token, id) {
49
+ return (await listProjects(host, token)).find((w) => w.id === id);
37
50
  }
38
51
  async function createProject(host, token, name) {
39
52
  const { status, data } = await api(host, '/api/v2/workspace', { method: 'POST', token, body: { projectName: name } });
@@ -71,22 +84,43 @@ async function seedSecret(host, token, projectId, environment, name, value) {
71
84
  throw new InfisicalError(`Could not seed secret ${name} in ${environment} (HTTP ${status}${data.message ? `: ${data.message}` : ''}).`);
72
85
  }
73
86
  }
74
- const S3_PLACEHOLDER_KEYS = [
75
- 'S3_BUCKET',
76
- 'S3_ENDPOINT',
77
- 'S3_REGION',
78
- 'S3_ACCESS_KEY',
79
- 'S3_SECRET_KEY',
80
- ];
87
+ function inaccessibleProject(projectId) {
88
+ return new InfisicalError(`Infisical project "${projectId}" was not found or the machine identity has no access to it. ` +
89
+ 'Check the project ID and that the identity is a member of the project.', 'project');
90
+ }
91
+ /**
92
+ * Read-only validation: Universal Auth login works and, when a project ID was
93
+ * given, that project is accessible. Returns the project name when found.
94
+ */
95
+ export async function validateInfisical(infisical) {
96
+ const token = await login(infisical);
97
+ if (infisical.projectId) {
98
+ const project = await findProjectById(infisical.host, token, infisical.projectId);
99
+ if (!project)
100
+ throw inaccessibleProject(infisical.projectId);
101
+ return { projectName: project.name };
102
+ }
103
+ return {};
104
+ }
81
105
  /** Create/reuse the project, ensure every environment, seed placeholder secrets. */
82
106
  export async function bootstrapInfisical(answers) {
83
- const { host, projectName } = answers.infisical;
84
- const token = await login(answers);
107
+ const { host, projectName, projectId } = answers.infisical;
108
+ const token = await login(answers.infisical);
85
109
  let createdProject = false;
86
- let project = await findProject(host, token, projectName);
87
- if (!project) {
88
- project = await createProject(host, token, projectName);
89
- createdProject = true;
110
+ let project;
111
+ if (projectId) {
112
+ // An explicit ID is never created implicitly: fail loudly if unreachable.
113
+ project = await findProjectById(host, token, projectId);
114
+ if (!project)
115
+ throw inaccessibleProject(projectId);
116
+ }
117
+ else {
118
+ // Find-by-name keeps re-runs idempotent even without a recorded ID.
119
+ project = await findProject(host, token, projectName);
120
+ if (!project) {
121
+ project = await createProject(host, token, projectName);
122
+ createdProject = true;
123
+ }
90
124
  }
91
125
  for (const env of answers.environments) {
92
126
  await ensureEnvironment(host, token, project, env.slug, env.displayName);
@@ -101,7 +135,7 @@ export async function bootstrapInfisical(answers) {
101
135
  }
102
136
  await seedSecret(host, token, project.id, env.slug, 'DATABASE_URL', placeholder);
103
137
  if (answers.objectStorage) {
104
- for (const key of S3_PLACEHOLDER_KEYS) {
138
+ for (const key of S3_SECRET_KEYS) {
105
139
  await seedSecret(host, token, project.id, env.slug, key, placeholder);
106
140
  }
107
141
  }
@@ -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(answers) {
10
- const { secretKey, projectId, organizationId } = answers.scaleway;
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
- /** Create the Terraform state bucket if it does not exist yet (idempotent). */
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
- return { created: false }; // Already exists and we own it.
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.send(new CreateBucketCommand({ Bucket: bucket }));
59
- // Versioning protects the state history against accidental overwrites.
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
@@ -29,7 +29,9 @@ const ENV_DEFAULTS = {
29
29
  production: true,
30
30
  gated: true,
31
31
  minScale: 0,
32
- maxScale: 2,
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,
33
35
  },
34
36
  };
35
37
  /** Default min/max scale for an environment, for prompts and summaries. */
@@ -134,6 +136,7 @@ export function fromEnv(env) {
134
136
  host: env.INFISICAL_HOST,
135
137
  clientId: env.INFISICAL_CLIENT_ID,
136
138
  clientSecret: env.INFISICAL_CLIENT_SECRET,
139
+ projectId: env.INFISICAL_PROJECT_ID,
137
140
  },
138
141
  github: {
139
142
  token: env.GITHUB_TOKEN ?? env.GH_TOKEN,
@@ -223,6 +226,9 @@ export function finalizeAnswers(partial) {
223
226
  clientId: requireString(partial.infisical.clientId, 'Infisical client ID'),
224
227
  clientSecret: requireString(partial.infisical.clientSecret, 'Infisical client secret'),
225
228
  projectName: partial.infisical.projectName?.trim() || projectName,
229
+ ...(partial.infisical.projectId?.trim()
230
+ ? { projectId: partial.infisical.projectId.trim() }
231
+ : {}),
226
232
  },
227
233
  github: {
228
234
  token: requireString(partial.github.token, 'GitHub token'),
@@ -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
@@ -19,8 +19,6 @@ export function tokenMap(answers) {
19
19
  __PROJECT_NAME__: answers.projectName,
20
20
  __REGION__: answers.region,
21
21
  __TF_STATE_BUCKET__: answers.stateBucket,
22
- // YAML inline list for the plan/drift job matrix, e.g. [staging, prod].
23
- __ENV_MATRIX__: `[${slugs.join(', ')}]`,
24
22
  // HCL list for the `environment` variable validation in variables.tf.
25
23
  __ENV_SLUGS_TF__: `[${slugs.map((s) => `"${s}"`).join(', ')}]`,
26
24
  };
@@ -37,7 +35,15 @@ function envTfvarsTokens(answers, env) {
37
35
  __MAX_SCALE__: String(env.maxScale),
38
36
  };
39
37
  }
40
- /** Templates rendered specially (per environment) instead of copied 1:1. */
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
+ */
41
47
  const SPECIAL_TEMPLATES = new Set(['env.tfvars', '.github/workflows/terraform-apply.yml']);
42
48
  function isSpecial(rel) {
43
49
  return SPECIAL_TEMPLATES.has(rel) || rel.startsWith('_partials/');