@thejoseki/clawform 2.3.1 → 2.4.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 +141 -120
- package/bin/clawform.js +161 -151
- package/package.json +65 -65
- package/src/aws/catalog.js +116 -116
- package/src/aws/changeset.js +231 -231
- package/src/aws/drift.js +59 -59
- package/src/aws/exports.js +213 -213
- package/src/aws/inventory.js +156 -156
- package/src/commands/activate.js +137 -105
- package/src/commands/ci-init.js +393 -212
- package/src/commands/cost-report.js +163 -163
- package/src/commands/deactivate.js +9 -2
- package/src/commands/deploy.js +413 -413
- package/src/commands/diff.js +39 -39
- package/src/commands/drift.js +35 -35
- package/src/commands/init-plugin.js +6 -6
- package/src/commands/init.js +360 -360
- package/src/commands/rollback.js +126 -126
- package/src/commands/status.js +147 -147
- package/src/commands/sync.js +50 -50
- package/src/commands/update.js +24 -0
- package/src/commands/validate.js +349 -349
- package/src/hooks/block-dangerous.js +62 -62
- package/src/hooks/cfn-lint-after-edit-eval.js +30 -30
- package/src/hooks/cfn-lint-after-edit.js +81 -81
- package/src/hooks/check-catalog-fresh-eval.js +63 -63
- package/src/hooks/check-catalog-fresh.js +33 -33
- package/src/hooks/session-context-eval.js +81 -81
- package/src/hooks/session-context.js +41 -41
- package/src/hooks/subagent-context-eval.js +44 -44
- package/src/hooks/subagent-context.js +48 -48
- package/src/lib/audit-synthesis.js +24 -24
- package/src/lib/aws-client.js +15 -15
- package/src/lib/cfn-yaml.js +92 -92
- package/src/lib/config.js +76 -76
- package/src/lib/constants.js +85 -85
- package/src/lib/defaults.js +16 -16
- package/src/lib/install-workflow.js +28 -28
- package/src/lib/kit-source.js +43 -5
- package/src/lib/license-client.js +84 -84
- package/src/lib/license-config.js +162 -162
- package/src/lib/license-store.js +43 -8
- package/src/lib/license.js +163 -150
- package/src/lib/lint-overrides.js +226 -226
- package/src/lib/logger.js +16 -16
- package/src/lib/project-config.js +145 -145
- package/src/lib/providers/polar.js +215 -215
- package/src/lib/session-state.js +91 -91
package/src/commands/ci-init.js
CHANGED
|
@@ -1,212 +1,393 @@
|
|
|
1
|
-
// clawform ci init — generate
|
|
2
|
-
// GitLab
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// lint-overrides on top;
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
1
|
+
// clawform ci init — generate CI config for the Clawform model: your Git host
|
|
2
|
+
// (GitHub or GitLab, SaaS or self-managed) is the source of truth; AWS just runs
|
|
3
|
+
// the pipeline.
|
|
4
|
+
//
|
|
5
|
+
// validate (every MR/PR, offline) → upload (protected branch)
|
|
6
|
+
// - validate: cfn-lint the cfn/ templates on a runner. No AWS creds, so it's
|
|
7
|
+
// safe on any runner and fast on every change. cfn-lint is the CI floor —
|
|
8
|
+
// local `clawform validate` adds cfn-guard + cfn-nag + lint-overrides on top;
|
|
9
|
+
// the generated file says so.
|
|
10
|
+
// - upload: zip the repo and put it to the pipeline's SourceBucket as
|
|
11
|
+
// source.zip, which triggers the CodePipeline stood up by
|
|
12
|
+
// templates/cicd-pipeline.yaml (validate → manual approval → execute).
|
|
13
|
+
//
|
|
14
|
+
// Credentials come one of two ways (--auth): `keys` uses long-lived IAM access
|
|
15
|
+
// keys stored as CI secrets (default); `oidc` is keyless — the protected branch
|
|
16
|
+
// assumes an IAM role via the host's OIDC issuer (github.com / gitlab.com only).
|
|
17
|
+
//
|
|
18
|
+
// The CLI writes the file and prints the next steps; it does NOT touch AWS.
|
|
19
|
+
// The render* functions are pure string builders, unit-tested without I/O.
|
|
20
|
+
|
|
21
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { join, dirname } from 'node:path';
|
|
24
|
+
import { logger } from '../lib/logger.js';
|
|
25
|
+
import { loadProjectConfig } from '../lib/project-config.js';
|
|
26
|
+
import { assertLicensed } from '../lib/license.js';
|
|
27
|
+
import { resolveClient as resolveLicenseClient } from '../lib/license-client.js';
|
|
28
|
+
|
|
29
|
+
const PROVIDERS = new Set(['gitlab', 'github']);
|
|
30
|
+
const AUTH_MODES = new Set(['keys', 'oidc']);
|
|
31
|
+
|
|
32
|
+
const BUCKET_PLACEHOLDER = '<PIPELINE_SOURCE_BUCKET>';
|
|
33
|
+
|
|
34
|
+
// The bundle-and-upload tail shared by both GitLab auth branches. Built explicitly
|
|
35
|
+
// (no stderr suppression, no || fallback) so a real zip failure fails the job
|
|
36
|
+
// instead of uploading a partial bundle. Emitted identically for keys and oidc so
|
|
37
|
+
// the two modes stay functionally equivalent past the credential step (D1, D4).
|
|
38
|
+
function gitlabBundleSteps({ cfnDir, bucket }) {
|
|
39
|
+
return ` - BUNDLE_PATHS="${cfnDir}"
|
|
40
|
+
- for p in clawform.config.json params; do [ -e "$p" ] && BUNDLE_PATHS="$BUNDLE_PATHS $p"; done
|
|
41
|
+
- echo "Bundling: $BUNDLE_PATHS"
|
|
42
|
+
- zip -r source.zip $BUNDLE_PATHS
|
|
43
|
+
- aws s3 cp source.zip "s3://${bucket}/source.zip"`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderGitlabCi({ cfnDir = 'cfn', branch = 'main', sourceBucket, auth = 'keys', roleArn, region } = {}) {
|
|
47
|
+
const bucket = sourceBucket || BUCKET_PLACEHOLDER;
|
|
48
|
+
const isOidc = auth === 'oidc';
|
|
49
|
+
|
|
50
|
+
// Host-neutral header framing (D1): "GitLab is the source of truth" holds for
|
|
51
|
+
// SaaS and self-managed alike — the old "On-Prem" label hid gitlab.com. The
|
|
52
|
+
// oidc header must NOT carry the stored-key "Required CI/CD variables" block.
|
|
53
|
+
const header = isOidc
|
|
54
|
+
? `# Generated by \`clawform ci init --auth oidc\`. Clawform CI (keyless / OIDC).
|
|
55
|
+
# Model: GitLab (SaaS or self-managed) is the source of truth; AWS runs the
|
|
56
|
+
# pipeline. The protected branch assumes an IAM role via GitLab OIDC — NO stored
|
|
57
|
+
# AWS keys — then uploads a source bundle to S3, which triggers the CodePipeline
|
|
58
|
+
# from templates/cicd-pipeline.yaml (validate -> manual approval -> execute).
|
|
59
|
+
#
|
|
60
|
+
# No AWS access keys are stored. The AWS-side IAM OIDC provider + role are a
|
|
61
|
+
# one-time prerequisite — see the \`clawform ci init\` "Next steps" output for the
|
|
62
|
+
# exact trust policy (issuer gitlab.com, sub-scoped to this project + branch,
|
|
63
|
+
# audience sts.amazonaws.com). OIDC targets gitlab.com; on a self-managed
|
|
64
|
+
# instance re-run with --auth keys.
|
|
65
|
+
# NOTE: do NOT enable CI_DEBUG_TRACE on this job — a trace would print the OIDC
|
|
66
|
+
# JWT ($AWS_ID_TOKEN) as it is written to the token file.`
|
|
67
|
+
: `# Generated by \`clawform ci init\`. Clawform CI: your Git host is the source of
|
|
68
|
+
# truth; AWS just runs the pipeline. The protected branch uploads a source bundle
|
|
69
|
+
# to S3, which triggers the CodePipeline from templates/cicd-pipeline.yaml
|
|
70
|
+
# (validate -> manual approval -> execute).
|
|
71
|
+
#
|
|
72
|
+
# Required CI/CD variables (Settings -> CI/CD -> Variables), protected + masked:
|
|
73
|
+
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY -> an IAM user scoped to
|
|
74
|
+
# s3:PutObject on the pipeline SourceBucket (or use a runner instance role)
|
|
75
|
+
# AWS_DEFAULT_REGION -> the pipeline's region`;
|
|
76
|
+
|
|
77
|
+
// The upload job: keys reads creds from protected CI variables; oidc mints
|
|
78
|
+
// short-lived creds from ONE assume-role-with-web-identity call (three fields
|
|
79
|
+
// from one session — two calls would not pair) with the token passed via a file
|
|
80
|
+
// so it never lands on the process argv (Finding 10). Both keep the full bundle
|
|
81
|
+
// body and the entrypoint override (amazon/aws-cli's ENTRYPOINT is the aws
|
|
82
|
+
// binary, so without it the runner cannot start a shell).
|
|
83
|
+
const uploadJob = isOidc
|
|
84
|
+
? `deploy:upload:
|
|
85
|
+
stage: deploy
|
|
86
|
+
image:
|
|
87
|
+
name: amazon/aws-cli:latest
|
|
88
|
+
entrypoint: [""]
|
|
89
|
+
id_tokens:
|
|
90
|
+
AWS_ID_TOKEN:
|
|
91
|
+
aud: sts.amazonaws.com
|
|
92
|
+
rules:
|
|
93
|
+
- if: '$CI_COMMIT_BRANCH == "${branch}"'
|
|
94
|
+
before_script:
|
|
95
|
+
- yum install -y -q zip
|
|
96
|
+
script:
|
|
97
|
+
# Keyless auth via the AWS web-identity-token-file credential provider: write
|
|
98
|
+
# the OIDC JWT to a file and point the SDK at it with AWS_ROLE_ARN +
|
|
99
|
+
# AWS_WEB_IDENTITY_TOKEN_FILE. The CLI assumes the role itself on each call —
|
|
100
|
+
# no manual STS call to parse, no credentials in the job shell (so nothing to
|
|
101
|
+
# leak into logs), and the token never appears on a process argv.
|
|
102
|
+
- export AWS_DEFAULT_REGION=${region}
|
|
103
|
+
- export AWS_ROLE_ARN="${roleArn}"
|
|
104
|
+
- export AWS_ROLE_SESSION_NAME="gitlab-clawform-$CI_PIPELINE_ID"
|
|
105
|
+
- printf '%s' "$AWS_ID_TOKEN" > /tmp/awsclaw-oidc-token
|
|
106
|
+
- export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/awsclaw-oidc-token
|
|
107
|
+
${gitlabBundleSteps({ cfnDir, bucket })}`
|
|
108
|
+
: `deploy:upload:
|
|
109
|
+
stage: deploy
|
|
110
|
+
image:
|
|
111
|
+
name: amazon/aws-cli:latest
|
|
112
|
+
entrypoint: [""]
|
|
113
|
+
rules:
|
|
114
|
+
- if: '$CI_COMMIT_BRANCH == "${branch}"'
|
|
115
|
+
before_script:
|
|
116
|
+
- yum install -y -q zip
|
|
117
|
+
script:
|
|
118
|
+
# Creds come from the protected CI/CD variables above, injected as env vars.
|
|
119
|
+
${gitlabBundleSteps({ cfnDir, bucket })}`;
|
|
120
|
+
|
|
121
|
+
return `${header}
|
|
122
|
+
|
|
123
|
+
stages:
|
|
124
|
+
- validate
|
|
125
|
+
- deploy
|
|
126
|
+
|
|
127
|
+
# Every merge request: lint the templates. Offline — no AWS credentials needed.
|
|
128
|
+
# NOTE: cfn-lint is the CI *floor*. Local \`clawform validate\` additionally runs
|
|
129
|
+
# cfn-guard (the shipped rules.guard, incl. the Rule #4 retention checks) and
|
|
130
|
+
# cfn-nag — run it before merging; green CI is not the same as a green /validate.
|
|
131
|
+
validate:cfn:
|
|
132
|
+
stage: validate
|
|
133
|
+
image: python:3.12-slim
|
|
134
|
+
rules:
|
|
135
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
136
|
+
variables:
|
|
137
|
+
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
|
|
138
|
+
cache:
|
|
139
|
+
key: clawform-pip
|
|
140
|
+
paths:
|
|
141
|
+
- .cache/pip
|
|
142
|
+
script:
|
|
143
|
+
- pip install --quiet cfn-lint
|
|
144
|
+
- cfn-lint ${cfnDir}/*.yaml
|
|
145
|
+
|
|
146
|
+
# Protected branch only: bundle the repo and hand it to the pipeline. CodePipeline
|
|
147
|
+
# re-validates, builds a change-set, and waits for a human to approve in the
|
|
148
|
+
# CodePipeline console before executing (Rule #5 approval gate, off-TTY).
|
|
149
|
+
${uploadJob}
|
|
150
|
+
# The pipeline takes over from here — approve the change-set in CodePipeline.
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The bundle-and-upload step body shared by both GitHub auth branches (indented
|
|
155
|
+
// for a `run: |` block). Identical across keys and oidc so the modes stay
|
|
156
|
+
// functionally equivalent past the credential step (D1).
|
|
157
|
+
function githubBundleStep({ cfnDir, bucket }) {
|
|
158
|
+
return ` - name: Bundle and upload source
|
|
159
|
+
run: |
|
|
160
|
+
# Bundle the template dir plus the optional config/params paths that
|
|
161
|
+
# exist. Built explicitly (no stderr suppression, no || fallback) so a
|
|
162
|
+
# real zip failure fails the job instead of uploading a partial bundle.
|
|
163
|
+
BUNDLE_PATHS="${cfnDir}"
|
|
164
|
+
for p in clawform.config.json params; do [ -e "$p" ] && BUNDLE_PATHS="$BUNDLE_PATHS $p"; done
|
|
165
|
+
echo "Bundling: $BUNDLE_PATHS"
|
|
166
|
+
zip -r source.zip $BUNDLE_PATHS
|
|
167
|
+
aws s3 cp source.zip "s3://${bucket}/source.zip"`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Same Git → S3 → CodePipeline model as the GitLab variant, expressed as a
|
|
171
|
+
// GitHub Actions workflow. Pure string builder, unit-tested without I/O.
|
|
172
|
+
export function renderGithubActions({ cfnDir = 'cfn', branch = 'main', sourceBucket, auth = 'keys', roleArn, region } = {}) {
|
|
173
|
+
const bucket = sourceBucket || BUCKET_PLACEHOLDER;
|
|
174
|
+
const isOidc = auth === 'oidc';
|
|
175
|
+
|
|
176
|
+
// Host-neutral header framing (D1). The oidc header must NOT carry the stored-key
|
|
177
|
+
// "Required repository secrets" block.
|
|
178
|
+
const header = isOidc
|
|
179
|
+
? `# Generated by \`clawform ci init --provider github --auth oidc\`. Clawform CI
|
|
180
|
+
# (keyless / OIDC). Model: GitHub is the source of truth; AWS runs the pipeline.
|
|
181
|
+
# The protected branch assumes an IAM role via GitHub OIDC — NO stored AWS keys —
|
|
182
|
+
# then uploads a source bundle to S3, which triggers the CodePipeline from
|
|
183
|
+
# templates/cicd-pipeline.yaml (validate -> manual approval -> execute).
|
|
184
|
+
#
|
|
185
|
+
# No AWS access keys are stored. The AWS-side IAM OIDC provider + role are a
|
|
186
|
+
# one-time prerequisite — see the \`clawform ci init\` "Next steps" output for the
|
|
187
|
+
# exact trust policy (issuer token.actions.githubusercontent.com, sub-scoped to
|
|
188
|
+
# this repo + branch, audience sts.amazonaws.com).`
|
|
189
|
+
: `# Generated by \`clawform ci init --provider github\`. Clawform CI: your Git host is
|
|
190
|
+
# the source of truth; AWS just runs the pipeline. The protected branch uploads a
|
|
191
|
+
# source bundle to S3, which triggers the CodePipeline from
|
|
192
|
+
# templates/cicd-pipeline.yaml (validate -> manual approval -> execute).
|
|
193
|
+
#
|
|
194
|
+
# Required repository secrets (Settings -> Secrets and variables -> Actions):
|
|
195
|
+
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY -> an IAM user scoped to
|
|
196
|
+
# s3:PutObject on the pipeline SourceBucket
|
|
197
|
+
# AWS_DEFAULT_REGION -> the pipeline's region`;
|
|
198
|
+
|
|
199
|
+
// OIDC needs a top-level grant so both jobs' checkout can read the repo; the
|
|
200
|
+
// token-minting permission is scoped to the upload job only (Finding 6).
|
|
201
|
+
const topPermissions = isOidc ? `\npermissions:\n contents: read\n` : '';
|
|
202
|
+
|
|
203
|
+
// The upload job: keys pulls creds from repository secrets; oidc grants
|
|
204
|
+
// id-token:write on THIS job only and lets configure-aws-credentials assume the
|
|
205
|
+
// role (audience defaults to sts.amazonaws.com — no static keys anywhere).
|
|
206
|
+
const uploadJob = isOidc
|
|
207
|
+
? ` upload:
|
|
208
|
+
if: github.ref == 'refs/heads/${branch}'
|
|
209
|
+
needs: validate
|
|
210
|
+
runs-on: ubuntu-latest
|
|
211
|
+
permissions:
|
|
212
|
+
id-token: write
|
|
213
|
+
contents: read
|
|
214
|
+
steps:
|
|
215
|
+
- uses: actions/checkout@v4
|
|
216
|
+
- uses: aws-actions/configure-aws-credentials@v4
|
|
217
|
+
with:
|
|
218
|
+
role-to-assume: ${roleArn}
|
|
219
|
+
aws-region: ${region}
|
|
220
|
+
${githubBundleStep({ cfnDir, bucket })}`
|
|
221
|
+
: ` upload:
|
|
222
|
+
if: github.ref == 'refs/heads/${branch}'
|
|
223
|
+
needs: validate
|
|
224
|
+
runs-on: ubuntu-latest
|
|
225
|
+
env:
|
|
226
|
+
AWS_ACCESS_KEY_ID: \${{ secrets.AWS_ACCESS_KEY_ID }}
|
|
227
|
+
AWS_SECRET_ACCESS_KEY: \${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
228
|
+
AWS_DEFAULT_REGION: \${{ secrets.AWS_DEFAULT_REGION }}
|
|
229
|
+
steps:
|
|
230
|
+
- uses: actions/checkout@v4
|
|
231
|
+
${githubBundleStep({ cfnDir, bucket })}`;
|
|
232
|
+
|
|
233
|
+
return `${header}
|
|
234
|
+
|
|
235
|
+
name: clawform-cfn
|
|
236
|
+
|
|
237
|
+
on:
|
|
238
|
+
pull_request:
|
|
239
|
+
push:
|
|
240
|
+
branches: [ ${branch} ]
|
|
241
|
+
${topPermissions}
|
|
242
|
+
jobs:
|
|
243
|
+
# Every pull request: lint the templates. Offline — no AWS credentials.
|
|
244
|
+
# NOTE: cfn-lint is the CI floor. Local \`clawform validate\` additionally runs
|
|
245
|
+
# cfn-guard (rules.guard, incl. Rule #4 retention) and cfn-nag — green CI is
|
|
246
|
+
# not the same as a green /validate.
|
|
247
|
+
validate:
|
|
248
|
+
runs-on: ubuntu-latest
|
|
249
|
+
steps:
|
|
250
|
+
- uses: actions/checkout@v4
|
|
251
|
+
- uses: actions/setup-python@v5
|
|
252
|
+
with:
|
|
253
|
+
python-version: '3.12'
|
|
254
|
+
cache: pip
|
|
255
|
+
- run: pip install --quiet cfn-lint
|
|
256
|
+
- run: cfn-lint ${cfnDir}/*.yaml
|
|
257
|
+
|
|
258
|
+
# Protected branch only: bundle the repo and hand it to the pipeline, which
|
|
259
|
+
# re-validates, builds a change-set, and waits for a human to approve in the
|
|
260
|
+
# CodePipeline console before executing (Rule #5 approval gate, off-TTY).
|
|
261
|
+
${uploadJob}
|
|
262
|
+
# The pipeline takes over from here — approve the change-set in CodePipeline.
|
|
263
|
+
`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export default async function ciInit({ sourceBucket, cfnDir = 'cfn', branch = 'main', force, projectDir, provider = 'gitlab', auth = 'keys', roleArn, region } = {}) {
|
|
267
|
+
await assertLicensed({ command: 'ci-init', client: resolveLicenseClient() });
|
|
268
|
+
const dir = projectDir || process.cwd();
|
|
269
|
+
|
|
270
|
+
// Validate the enums in ciInit() itself, not only in commander — a direct
|
|
271
|
+
// caller must not be able to silently degrade to gitlab/keys on a typo (the old
|
|
272
|
+
// `provider === 'github'` test did exactly that). Throw BEFORE any mkdir/write
|
|
273
|
+
// so a bad invocation never lands a file; assertLicensed stays the first call.
|
|
274
|
+
if (!PROVIDERS.has(provider)) {
|
|
275
|
+
throw new Error(`Unknown --provider "${provider}". Use one of: gitlab, github.`);
|
|
276
|
+
}
|
|
277
|
+
if (!AUTH_MODES.has(auth)) {
|
|
278
|
+
throw new Error(`Unknown --auth "${auth}". Use one of: keys, oidc.`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// OIDC needs an AWS role to assume and a region for the upload. role-arn is
|
|
282
|
+
// hard-required (the CLI never creates the role); region falls back to the
|
|
283
|
+
// project config so a configured repo need not repeat --region. Neither
|
|
284
|
+
// resolvable → refuse here, before writing, so we never ship a file whose
|
|
285
|
+
// protected-branch job is green on PRs but fails the moment it merges.
|
|
286
|
+
if (auth === 'oidc') {
|
|
287
|
+
if (!roleArn) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
'--auth oidc requires --role-arn <arn> (the IAM role the pipeline assumes via OIDC). ' +
|
|
290
|
+
'The CLI does not create it — see the "Next steps" trust-policy block.',
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
region = region || loadProjectConfig({ projectDir: dir }).region;
|
|
294
|
+
if (!region) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
'--auth oidc needs a region: pass --region <name> or set "region" in clawform.config.json.',
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Provider decides both the rendered content and where it lands. Default is
|
|
302
|
+
// gitlab so the pre-existing call shape keeps working.
|
|
303
|
+
const isGithub = provider === 'github';
|
|
304
|
+
const isOidc = auth === 'oidc';
|
|
305
|
+
const content = isGithub
|
|
306
|
+
? renderGithubActions({ cfnDir, branch, sourceBucket, auth, roleArn, region })
|
|
307
|
+
: renderGitlabCi({ cfnDir, branch, sourceBucket, auth, roleArn, region });
|
|
308
|
+
|
|
309
|
+
const primary = isGithub
|
|
310
|
+
? join(dir, '.github', 'workflows', 'clawform-cfn.yml')
|
|
311
|
+
: join(dir, '.gitlab-ci.yml');
|
|
312
|
+
const sample = isGithub
|
|
313
|
+
? join(dir, '.github', 'workflows', 'clawform-cfn.clawform.yml')
|
|
314
|
+
: join(dir, '.gitlab-ci.clawform.yml');
|
|
315
|
+
|
|
316
|
+
await mkdir(dirname(primary), { recursive: true });
|
|
317
|
+
const exists = existsSync(primary);
|
|
318
|
+
// Never clobber an existing pipeline config silently — write a sample alongside
|
|
319
|
+
// it and let the user merge, unless they explicitly asked to overwrite.
|
|
320
|
+
const target = exists && !force ? sample : primary;
|
|
321
|
+
await writeFile(target, content, 'utf8');
|
|
322
|
+
|
|
323
|
+
logger.step(`clawform ci init`);
|
|
324
|
+
if (target === primary) {
|
|
325
|
+
logger.ok(`Wrote ${target}`);
|
|
326
|
+
} else {
|
|
327
|
+
logger.warn(`${primary} already exists — wrote a sample to ${target} instead. Merge it in (or re-run with --force to overwrite).`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
logger.info('');
|
|
331
|
+
logger.info('Next steps:');
|
|
332
|
+
logger.info(' 1. Stand up the pipeline stack FIRST (it creates the SourceBucket this CI uploads to):');
|
|
333
|
+
logger.info(' clawform deploy templates/cicd-pipeline.yaml <env> \\');
|
|
334
|
+
logger.info(' --stack-name <env>-<customer>-<project>-pipeline --project <project> \\');
|
|
335
|
+
logger.info(' --params params/<env>-pipeline.json --capabilities CAPABILITY_NAMED_IAM');
|
|
336
|
+
logger.info(' The params file must set ALL template parameters: EnvName, CustomerName, ProjectName,');
|
|
337
|
+
logger.info(' TargetStackName, TemplatePathInBundle, ApprovalEmail, CfnDeployRoleArn');
|
|
338
|
+
if (cfnDir !== 'cfn') {
|
|
339
|
+
logger.info(` — and CfnDirInBundle="${cfnDir}" (you used --cfn-dir, so the pipeline's lint stage must match).`);
|
|
340
|
+
} else {
|
|
341
|
+
logger.info(' (CfnDirInBundle defaults to "cfn" and matches this CI file).');
|
|
342
|
+
}
|
|
343
|
+
if (!sourceBucket) {
|
|
344
|
+
logger.warn(` 2. Replace ${BUCKET_PLACEHOLDER} in the generated file with the stack's SourceBucketName output`);
|
|
345
|
+
logger.info(' (or re-run: clawform ci init --source-bucket <name> --force).');
|
|
346
|
+
} else {
|
|
347
|
+
logger.info(` 2. SourceBucket is set to "${sourceBucket}".`);
|
|
348
|
+
}
|
|
349
|
+
if (isOidc) {
|
|
350
|
+
// Keyless: the AWS-side IAM OIDC provider + sub-scoped role are a one-time
|
|
351
|
+
// prerequisite the CLI cannot create. Emit an exact, copy-paste trust policy.
|
|
352
|
+
const issuer = isGithub ? 'token.actions.githubusercontent.com' : 'gitlab.com';
|
|
353
|
+
const sub = isGithub
|
|
354
|
+
? `repo:<OWNER>/<REPO>:ref:refs/heads/${branch}`
|
|
355
|
+
: `project_path:<GROUP>/<PROJECT>:ref_type:branch:ref:${branch}`;
|
|
356
|
+
logger.info(' 3. Create the AWS-side OIDC trust ONCE — the CLI does NOT create it:');
|
|
357
|
+
logger.info(` a. IAM → Identity providers → Add provider → OpenID Connect.`);
|
|
358
|
+
logger.info(` Provider URL: https://${issuer} Audience: sts.amazonaws.com`);
|
|
359
|
+
logger.info(` b. Give the role ${roleArn} this trust policy (the sub line is what scopes it):`);
|
|
360
|
+
logger.info(' {');
|
|
361
|
+
logger.info(' "Effect": "Allow",');
|
|
362
|
+
logger.info(` "Principal": { "Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/${issuer}" },`);
|
|
363
|
+
logger.info(' "Action": "sts:AssumeRoleWithWebIdentity",');
|
|
364
|
+
logger.info(' "Condition": { "StringEquals": {');
|
|
365
|
+
logger.info(` "${issuer}:aud": "sts.amazonaws.com",`);
|
|
366
|
+
logger.info(` "${issuer}:sub": "${sub}"`);
|
|
367
|
+
logger.info(' } }');
|
|
368
|
+
logger.info(' }');
|
|
369
|
+
logger.warn(' WARNING: an aud-only policy (no sub) trusts EVERY repo/project on the issuer — keep the sub line.');
|
|
370
|
+
logger.info(` c. Attach a permissions policy to ${roleArn} allowing s3:PutObject on the SourceBucket.`);
|
|
371
|
+
if (!isGithub) {
|
|
372
|
+
logger.info(' Note: OIDC targets gitlab.com only. On a self-managed GitLab, re-run with --auth keys.');
|
|
373
|
+
}
|
|
374
|
+
logger.info(' → CUTOVER once the keyless run works: delete the old IAM user access keys and remove the');
|
|
375
|
+
logger.info(' AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_DEFAULT_REGION CI variables/secrets.');
|
|
376
|
+
logger.info(' Re-running ci init writes a *.clawform sample unless you pass --force to regenerate in place.');
|
|
377
|
+
} else if (isGithub) {
|
|
378
|
+
logger.info(' 3. In GitHub → Settings → Secrets and variables → Actions, add:');
|
|
379
|
+
logger.info(' AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (IAM user scoped to s3:PutObject on the bucket),');
|
|
380
|
+
logger.info(' AWS_DEFAULT_REGION.');
|
|
381
|
+
} else {
|
|
382
|
+
logger.info(' 3. In GitLab → Settings → CI/CD → Variables, add (protected + masked):');
|
|
383
|
+
logger.info(' AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (IAM user scoped to s3:PutObject on the bucket),');
|
|
384
|
+
logger.info(' AWS_DEFAULT_REGION.');
|
|
385
|
+
}
|
|
386
|
+
logger.info(' 4. Know the pipeline\'s limits: it enforces the Rule #5 SHAPE (changeset → approval → execute)');
|
|
387
|
+
logger.info(' but NOT the CLI\'s other gates (account guard, legacy-freeze, export-lock, data-resource');
|
|
388
|
+
logger.info(' retype). The approver MUST open the change-set link and check for Replace on data');
|
|
389
|
+
logger.info(' resources before approving. Details: templates/cicd-pipeline.yaml header.');
|
|
390
|
+
logger.info(' 5. Older account that still has CodeCommit and prefers a Git mirror? Point GitLab repository');
|
|
391
|
+
logger.info(' mirroring at a CodeCommit repo and swap the pipeline Source action to CodeCommit — see');
|
|
392
|
+
logger.info(' the prose header in templates/cicd-pipeline.yaml.');
|
|
393
|
+
}
|