create-interview-cockpit 0.29.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Scaffold a personal AI-powered interview prep cockpit",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,526 @@
1
+ import type { InfraLabWorkspace } from "./types";
2
+
3
+ // Mirrors the structure of a real `governance-iam` Terraform module
4
+ // (roles, policies, attachments, users, OIDC providers, multi-account
5
+ // `for_each` expansion) but trimmed down so it can run end-to-end
6
+ // against LocalStack inside the Infra Lab runner.
7
+ //
8
+ // What works on LocalStack (free):
9
+ // - aws_iam_policy / aws_iam_role / *_policy_attachment / aws_iam_user
10
+ // - aws_iam_openid_connect_provider (object exists, trust is not exercised)
11
+ //
12
+ // What does NOT work realistically on LocalStack (left as plan-only / read):
13
+ // - real cross-account assume-role via Organizations
14
+ // - real OIDC federation (Azure DevOps, EKS IRSA, etc.)
15
+ export const AWS_GOVERNANCE_IAM_FILES: Record<string, string> = {
16
+ "README.md": `# AWS Governance IAM Lab
17
+
18
+ Practice the *shape* of an enterprise \`governance-iam\` Terraform module
19
+ against LocalStack. You get the same patterns the real module uses:
20
+
21
+ 1. **OIDC providers** (\`aws_iam_openid_connect_provider\`)
22
+ 2. **Custom IAM policies** loaded from JSON files in \`policy/\`
23
+ 3. **IAM roles** with trust documents from \`assume_role/\`
24
+ 4. **Policy attachments** (custom + AWS-managed)
25
+ 5. **IAM users** with attachments
26
+ 6. **Multi-account expansion** via \`for_each\` keyed as \`account/name\`
27
+
28
+ ## How to run it
29
+
30
+ In the Infra Lab console, one command at a time:
31
+
32
+ 1. \`terraform init\`
33
+ 2. \`terraform validate\`
34
+ 3. \`terraform plan -out=tfplan\`
35
+ 4. \`terraform apply -auto-approve\`
36
+ 5. Inspect with \`terraform state list\` and \`terraform show\`
37
+ 6. \`terraform destroy -auto-approve\` when done
38
+
39
+ ## What's real vs simulated
40
+
41
+ LocalStack accepts the IAM CRUD calls, so all roles/policies/users/attachments
42
+ are actually created. The OIDC providers are stored as objects, but no real
43
+ external identity (Azure DevOps, EKS, GKE) can federate into them here.
44
+
45
+ For the real federation handshake, you'd point this at a free-tier AWS
46
+ account by replacing the \`endpoints\` block in \`provider.tf\` and giving
47
+ real credentials.
48
+
49
+ ## Multi-account note
50
+
51
+ LocalStack free tier is single-account, so the two "accounts"
52
+ (\`access\`, \`workload\`) are simulated by provider aliases pointing at the
53
+ same LocalStack endpoint. The \`for_each\` and \`account/name\` key shape is
54
+ the same as in a real Organizations setup — flip the endpoints and you can
55
+ reuse it as-is.
56
+
57
+ ## Suggested experiments
58
+
59
+ - Add a new JSON file in \`policy/\` and reference it in \`terraform.tfvars\`.
60
+ - Add a new role in \`terraform.tfvars\` and watch the keyspace expand.
61
+ - Change a trust policy in \`assume_role/\` and run \`terraform plan\` to see
62
+ the in-place update.
63
+ - Try \`terraform plan\` after removing an attachment to learn how detach
64
+ works without destroying the role.
65
+ `,
66
+ "provider.tf": `terraform {
67
+ required_version = ">= 1.5.0"
68
+
69
+ required_providers {
70
+ aws = {
71
+ source = "hashicorp/aws"
72
+ version = "~> 5.0"
73
+ }
74
+ }
75
+ }
76
+
77
+ # Two provider aliases simulate the multi-account fan-out used by the real
78
+ # governance-iam module. Both point at the same LocalStack endpoint here;
79
+ # in production they would target different AWS accounts via assume-role.
80
+ provider "aws" {
81
+ alias = "access"
82
+ region = "us-east-1"
83
+ access_key = "test"
84
+ secret_key = "test"
85
+ skip_credentials_validation = true
86
+ skip_metadata_api_check = true
87
+ skip_requesting_account_id = true
88
+
89
+ endpoints {
90
+ iam = "http://localhost:4566"
91
+ sts = "http://localhost:4566"
92
+ }
93
+ }
94
+
95
+ provider "aws" {
96
+ alias = "workload"
97
+ region = "us-east-1"
98
+ access_key = "test"
99
+ secret_key = "test"
100
+ skip_credentials_validation = true
101
+ skip_metadata_api_check = true
102
+ skip_requesting_account_id = true
103
+
104
+ endpoints {
105
+ iam = "http://localhost:4566"
106
+ sts = "http://localhost:4566"
107
+ }
108
+ }
109
+ `,
110
+ "variables.tf": `# Logical inputs that mirror the real module. Swap values in
111
+ # terraform.tfvars to re-shape the deployment without touching main.tf.
112
+
113
+ variable "accounts" {
114
+ description = "Logical AWS account name -> provider alias key."
115
+ type = map(string)
116
+ default = {
117
+ access = "access"
118
+ workload = "workload"
119
+ }
120
+ }
121
+
122
+ variable "policies" {
123
+ description = "Custom policies to create. Keyed as <account>/<name>."
124
+ type = map(object({
125
+ path = string
126
+ description = string
127
+ }))
128
+ default = {}
129
+ }
130
+
131
+ variable "roles" {
132
+ description = "Roles to create. Keyed as <account>/<name>."
133
+ type = map(object({
134
+ name = string
135
+ assume_role_policy_file = string
136
+ assume_role_policy_variable = map(string)
137
+ custom_policies = list(string) # tf_policy_keys to attach
138
+ managed_policy_arns = list(string) # AWS-managed ARNs to attach
139
+ }))
140
+ default = {}
141
+ }
142
+
143
+ variable "users" {
144
+ description = "IAM users to create. Keyed as <account>/<name>."
145
+ type = map(object({
146
+ name = string
147
+ custom_policies = list(string)
148
+ managed_policy_arns = list(string)
149
+ }))
150
+ default = {}
151
+ }
152
+
153
+ variable "oidc_identity" {
154
+ description = "OIDC providers to register. Keyed as <account>/<label>."
155
+ type = map(object({
156
+ account = string
157
+ oidc_url = string
158
+ audiences = list(string)
159
+ thumbprint = list(string)
160
+ }))
161
+ default = {}
162
+ }
163
+ `,
164
+ "local.tf": `# Real governance-iam splits a single declaration into per-account
165
+ # resources. We do the same here; the keys are "<account>/<name>" so a
166
+ # role declared in two accounts becomes two distinct resources.
167
+ locals {
168
+ account_policies = var.policies
169
+ account_roles = var.roles
170
+ account_users = var.users
171
+
172
+ # Flatten role -> custom policy attachments.
173
+ custom_role_attachments = merge([
174
+ for role_key, role in var.roles : {
175
+ for pol_key in role.custom_policies :
176
+ "\${role_key}::\${pol_key}" => {
177
+ tf_role_key = role_key
178
+ tf_policy_key = pol_key
179
+ }
180
+ }
181
+ ]...)
182
+
183
+ # Flatten role -> AWS-managed policy attachments.
184
+ managed_role_attachments = merge([
185
+ for role_key, role in var.roles : {
186
+ for arn in role.managed_policy_arns :
187
+ "\${role_key}::\${arn}" => {
188
+ tf_role_key = role_key
189
+ policy_arn = arn
190
+ }
191
+ }
192
+ ]...)
193
+
194
+ custom_user_attachments = merge([
195
+ for user_key, user in var.users : {
196
+ for pol_key in user.custom_policies :
197
+ "\${user_key}::\${pol_key}" => {
198
+ tf_user_key = user_key
199
+ tf_policy_key = pol_key
200
+ }
201
+ }
202
+ ]...)
203
+
204
+ managed_user_attachments = merge([
205
+ for user_key, user in var.users : {
206
+ for arn in user.managed_policy_arns :
207
+ "\${user_key}::\${arn}" => {
208
+ tf_user_key = user_key
209
+ policy_arn = arn
210
+ }
211
+ }
212
+ ]...)
213
+ }
214
+ `,
215
+ "main.tf": `# @ref: aws-oidc-provider
216
+ # Trust links so external systems (ADO, EKS, GKE) can federate without
217
+ # storing AWS keys. On LocalStack the object is created but no real JWT
218
+ # is verified against it.
219
+ resource "aws_iam_openid_connect_provider" "oidc" {
220
+ for_each = var.oidc_identity
221
+ provider = aws.access
222
+
223
+ url = each.value.oidc_url
224
+ client_id_list = each.value.audiences
225
+ thumbprint_list = each.value.thumbprint
226
+ }
227
+
228
+ # @ref: aws-custom-policy
229
+ # Custom permission documents loaded from JSON files in ./policy.
230
+ # The split() trick is what the real module uses to derive a name from
231
+ # the "<account>/<group>/<file>.json" key shape.
232
+ resource "aws_iam_policy" "policies" {
233
+ for_each = local.account_policies
234
+ provider = aws.access
235
+
236
+ name = split("/", each.key)[1]
237
+ description = each.value.description
238
+ policy = file("\${path.module}/policy/\${each.value.path}")
239
+ }
240
+
241
+ # @ref: aws-iam-role
242
+ # Trust policy != permission policy. The trust policy answers
243
+ # "who is allowed to become this role?". Permissions come from attachments.
244
+ resource "aws_iam_role" "roles" {
245
+ for_each = local.account_roles
246
+ provider = aws.access
247
+
248
+ name = each.value.name
249
+ assume_role_policy = templatefile(
250
+ "\${path.module}/assume_role/\${each.value.assume_role_policy_file}",
251
+ each.value.assume_role_policy_variable,
252
+ )
253
+ }
254
+
255
+ # @ref: aws-role-policy-attachment (custom)
256
+ resource "aws_iam_role_policy_attachment" "custom_role_policy" {
257
+ for_each = local.custom_role_attachments
258
+ provider = aws.access
259
+
260
+ role = aws_iam_role.roles[each.value.tf_role_key].name
261
+ policy_arn = aws_iam_policy.policies[each.value.tf_policy_key].arn
262
+ }
263
+
264
+ # @ref: aws-role-policy-attachment (managed)
265
+ resource "aws_iam_role_policy_attachment" "managed_role_policy" {
266
+ for_each = local.managed_role_attachments
267
+ provider = aws.access
268
+
269
+ role = aws_iam_role.roles[each.value.tf_role_key].name
270
+ policy_arn = each.value.policy_arn
271
+ }
272
+
273
+ # @ref: aws-iam-user
274
+ # Long-lived identity. Kept as an exercise; modern setups prefer roles
275
+ # + federation, but the real module still supports both.
276
+ resource "aws_iam_user" "users" {
277
+ for_each = local.account_users
278
+ provider = aws.access
279
+
280
+ name = each.value.name
281
+ path = "/"
282
+ force_destroy = true
283
+ }
284
+
285
+ resource "aws_iam_user_policy_attachment" "custom_user_policy" {
286
+ for_each = local.custom_user_attachments
287
+ provider = aws.access
288
+
289
+ user = aws_iam_user.users[each.value.tf_user_key].name
290
+ policy_arn = aws_iam_policy.policies[each.value.tf_policy_key].arn
291
+ }
292
+
293
+ resource "aws_iam_user_policy_attachment" "managed_user_policy" {
294
+ for_each = local.managed_user_attachments
295
+ provider = aws.access
296
+
297
+ user = aws_iam_user.users[each.value.tf_user_key].name
298
+ policy_arn = each.value.policy_arn
299
+ }
300
+ `,
301
+ "ado.tf": `# @ref: aws-ado-role
302
+ # Mirrors the real ado.tf: a per-account role assumed by an Azure DevOps
303
+ # pipeline via OIDC, with AdministratorAccess attached. On LocalStack the
304
+ # role exists but the actual federation handshake is not exercised.
305
+ locals {
306
+ ado_accounts = ["access", "workload"]
307
+ }
308
+
309
+ resource "aws_iam_role" "ado_role" {
310
+ for_each = toset(local.ado_accounts)
311
+ provider = aws.access
312
+
313
+ name = "ado-role-\${each.key}"
314
+ assume_role_policy = templatefile(
315
+ "\${path.module}/assume_role/ado-assume-role.json",
316
+ {
317
+ account_id = "000000000000"
318
+ environment = "lab"
319
+ },
320
+ )
321
+ }
322
+
323
+ resource "aws_iam_role_policy_attachment" "ado_admin" {
324
+ for_each = toset(local.ado_accounts)
325
+ provider = aws.access
326
+
327
+ role = aws_iam_role.ado_role[each.key].name
328
+ policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
329
+ }
330
+
331
+ resource "aws_iam_role" "ado_readonly" {
332
+ for_each = toset(local.ado_accounts)
333
+ provider = aws.access
334
+
335
+ name = "ado-role-readonly-\${each.key}"
336
+ assume_role_policy = templatefile(
337
+ "\${path.module}/assume_role/ado-assume-role.json",
338
+ {
339
+ account_id = "000000000000"
340
+ environment = "lab"
341
+ },
342
+ )
343
+ }
344
+
345
+ resource "aws_iam_role_policy_attachment" "ado_readonly" {
346
+ for_each = toset(local.ado_accounts)
347
+ provider = aws.access
348
+
349
+ role = aws_iam_role.ado_readonly[each.key].name
350
+ policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
351
+ }
352
+ `,
353
+ "outputs.tf": `output "policy_arns" {
354
+ description = "ARNs of all custom policies that were created."
355
+ value = { for k, p in aws_iam_policy.policies : k => p.arn }
356
+ }
357
+
358
+ output "role_arns" {
359
+ description = "ARNs of all roles that were created."
360
+ value = { for k, r in aws_iam_role.roles : k => r.arn }
361
+ }
362
+
363
+ output "ado_role_arns" {
364
+ description = "ARNs of the Azure DevOps deployment roles."
365
+ value = { for k, r in aws_iam_role.ado_role : k => r.arn }
366
+ }
367
+ `,
368
+ "terraform.tfvars": `# Sample data that drives the module. Everything below is what would
369
+ # normally live in variables.<env>.tfvars in the real repo.
370
+
371
+ policies = {
372
+ "access/external-dns-policy-v1" = {
373
+ path = "external-dns-policy-v1.json"
374
+ description = "Allows external-dns to manage Route53 records."
375
+ }
376
+ "access/cloudwatch-exporter-policy-v1" = {
377
+ path = "cloudwatch-exporter-policy-v1.json"
378
+ description = "Allows the cloudwatch exporter to read metrics."
379
+ }
380
+ }
381
+
382
+ roles = {
383
+ "access/external-dns" = {
384
+ name = "external-dns-role"
385
+ assume_role_policy_file = "eks-irsa-assume-role.json"
386
+ assume_role_policy_variable = {
387
+ account_id = "000000000000"
388
+ oidc_provider = "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633"
389
+ namespace = "kube-system"
390
+ service_account = "external-dns"
391
+ }
392
+ custom_policies = [
393
+ "access/external-dns-policy-v1",
394
+ ]
395
+ managed_policy_arns = []
396
+ }
397
+ "access/cw-exporter" = {
398
+ name = "cloudwatch-exporter-role"
399
+ assume_role_policy_file = "eks-irsa-assume-role.json"
400
+ assume_role_policy_variable = {
401
+ account_id = "000000000000"
402
+ oidc_provider = "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633"
403
+ namespace = "monitoring"
404
+ service_account = "cloudwatch-exporter"
405
+ }
406
+ custom_policies = [
407
+ "access/cloudwatch-exporter-policy-v1",
408
+ ]
409
+ managed_policy_arns = [
410
+ "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess",
411
+ ]
412
+ }
413
+ }
414
+
415
+ users = {
416
+ "access/break-glass" = {
417
+ name = "break-glass-admin"
418
+ custom_policies = []
419
+ managed_policy_arns = [
420
+ "arn:aws:iam::aws:policy/ReadOnlyAccess",
421
+ ]
422
+ }
423
+ }
424
+
425
+ oidc_identity = {
426
+ "access/eks-main" = {
427
+ account = "access"
428
+ oidc_url = "https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLED539D4633"
429
+ audiences = ["sts.amazonaws.com"]
430
+ thumbprint = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"]
431
+ }
432
+ "access/azure-devops" = {
433
+ account = "access"
434
+ oidc_url = "https://vstoken.dev.azure.com/00000000-0000-0000-0000-000000000000"
435
+ audiences = ["api://AzureADTokenExchange"]
436
+ thumbprint = ["9e99a48a9960b14926bb7f3b02e22da2b0ab7280"]
437
+ }
438
+ }
439
+ `,
440
+ "policy/external-dns-policy-v1.json": `{
441
+ "Version": "2012-10-17",
442
+ "Statement": [
443
+ {
444
+ "Effect": "Allow",
445
+ "Action": [
446
+ "route53:ChangeResourceRecordSets"
447
+ ],
448
+ "Resource": "arn:aws:route53:::hostedzone/*"
449
+ },
450
+ {
451
+ "Effect": "Allow",
452
+ "Action": [
453
+ "route53:ListHostedZones",
454
+ "route53:ListResourceRecordSets"
455
+ ],
456
+ "Resource": "*"
457
+ }
458
+ ]
459
+ }
460
+ `,
461
+ "policy/cloudwatch-exporter-policy-v1.json": `{
462
+ "Version": "2012-10-17",
463
+ "Statement": [
464
+ {
465
+ "Effect": "Allow",
466
+ "Action": [
467
+ "cloudwatch:GetMetricData",
468
+ "cloudwatch:GetMetricStatistics",
469
+ "cloudwatch:ListMetrics",
470
+ "tag:GetResources"
471
+ ],
472
+ "Resource": "*"
473
+ }
474
+ ]
475
+ }
476
+ `,
477
+ "assume_role/eks-irsa-assume-role.json": `{
478
+ "Version": "2012-10-17",
479
+ "Statement": [
480
+ {
481
+ "Effect": "Allow",
482
+ "Principal": {
483
+ "Federated": "arn:aws:iam::\${account_id}:oidc-provider/\${oidc_provider}"
484
+ },
485
+ "Action": "sts:AssumeRoleWithWebIdentity",
486
+ "Condition": {
487
+ "StringEquals": {
488
+ "\${oidc_provider}:sub": "system:serviceaccount:\${namespace}:\${service_account}",
489
+ "\${oidc_provider}:aud": "sts.amazonaws.com"
490
+ }
491
+ }
492
+ }
493
+ ]
494
+ }
495
+ `,
496
+ "assume_role/ado-assume-role.json": `{
497
+ "Version": "2012-10-17",
498
+ "Statement": [
499
+ {
500
+ "Effect": "Allow",
501
+ "Principal": {
502
+ "Federated": "arn:aws:iam::\${account_id}:oidc-provider/vstoken.dev.azure.com/00000000-0000-0000-0000-000000000000"
503
+ },
504
+ "Action": "sts:AssumeRoleWithWebIdentity",
505
+ "Condition": {
506
+ "StringEquals": {
507
+ "vstoken.dev.azure.com/00000000-0000-0000-0000-000000000000:aud": "api://AzureADTokenExchange"
508
+ },
509
+ "StringLike": {
510
+ "vstoken.dev.azure.com/00000000-0000-0000-0000-000000000000:sub": "sc://my-org/my-project/\${environment}-*"
511
+ }
512
+ }
513
+ }
514
+ ]
515
+ }
516
+ `,
517
+ };
518
+
519
+ export const AWS_GOVERNANCE_IAM_LAB: InfraLabWorkspace = {
520
+ version: 1,
521
+ label: "AWS Governance IAM Lab",
522
+ provider: "aws",
523
+ executionMode: "localstack",
524
+ activeFile: "README.md",
525
+ files: AWS_GOVERNANCE_IAM_FILES,
526
+ };
@@ -8,12 +8,14 @@ import {
8
8
  parseInfraLabWorkspace,
9
9
  } from "../infraLab";
10
10
  import {
11
+ AWS_GOVERNANCE_GHA_LAB,
11
12
  DEFAULT_GHA_LAB,
12
13
  GOVERNANCE_GHA_LAB,
13
14
  parseGhaLabWorkspace,
14
15
  REACT_VITE_TYPESCRIPT_GHA_LAB,
15
16
  } from "../githubActionsLab";
16
17
  import { ENTERPRISE_LOCAL_AUTH_LAB } from "../enterpriseLocalLab";
18
+ import { AWS_GOVERNANCE_IAM_LAB } from "../awsGovernanceIamLab";
17
19
  import {
18
20
  parseFrontendLabWorkspace,
19
21
  ISOLATED_MODULE_FEDERATION_LAB,
@@ -672,6 +674,12 @@ export default function LabsPanel() {
672
674
  "Terraform deploys NestJS BFF, OIDC mock, Redis & claims API",
673
675
  onClick: () => openInfraLab(ENTERPRISE_LOCAL_AUTH_LAB),
674
676
  },
677
+ {
678
+ label: "AWS Governance IAM",
679
+ description:
680
+ "Roles, policies, attachments, users & OIDC mirroring a real governance-iam module on LocalStack",
681
+ onClick: () => openInfraLab(AWS_GOVERNANCE_IAM_LAB),
682
+ },
675
683
  ]}
676
684
  onOpen={openInfraFile}
677
685
  openTitle="Open in Infrastructure Lab"
@@ -703,6 +711,12 @@ export default function LabsPanel() {
703
711
  "PLF-style mono-repo: CODEOWNERS, PR template, Azure PIM/Policy + AWS IAM deploy workflows, offboarding",
704
712
  onClick: () => openGhaLab(GOVERNANCE_GHA_LAB),
705
713
  },
714
+ {
715
+ label: "AWS Governance IAM via GitHub Actions",
716
+ description:
717
+ "PR plan + main-branch apply workflows that drive a real Terraform IAM module against LocalStack via a reusable composite action",
718
+ onClick: () => openGhaLab(AWS_GOVERNANCE_GHA_LAB),
719
+ },
706
720
  ]}
707
721
  onOpen={openGhaFile}
708
722
  openTitle="Open in GitHub Lab"
@@ -12,6 +12,7 @@ import type {
12
12
  GithubLabRulesetRules,
13
13
  } from "./types";
14
14
  import { rulesetFromLegacyProtection } from "./codeowners";
15
+ import { AWS_GOVERNANCE_IAM_FILES } from "./awsGovernanceIamLab";
15
16
 
16
17
  // ─── Default GitHub Lab "org" roster ────────────────────────────────────
17
18
  //
@@ -2169,6 +2170,238 @@ Write-Host "\`nDone. DryRun=$DryRun"
2169
2170
  `,
2170
2171
  };
2171
2172
 
2173
+ // ─── AWS Governance IAM via GitHub Actions ───────────────────────────────
2174
+ //
2175
+ // Combines the Infra Lab's `governance-iam` Terraform module with a
2176
+ // GitHub-Actions-driven deploy flow that mirrors the real PLF setup, but
2177
+ // runs entirely locally via `act` + LocalStack:
2178
+ //
2179
+ // - PR opens / updates → `terraform-pr.yml` runs `tofu plan`
2180
+ // - merge to main → `terraform-ci.yml` runs `tofu apply`
2181
+ // - shared steps live in a composite action `./.github/actions/run-tofu-action`
2182
+ //
2183
+ // LocalStack is reachable from the act runner container at
2184
+ // `host.docker.internal:4566` on macOS / Windows, and via the
2185
+ // `--network host` runner override (or the literal IP) on Linux.
2186
+ const AWS_IAM_GHA_FILES: Record<string, string> = {
2187
+ "README.md": `# AWS Governance IAM — GitHub Actions Lab
2188
+
2189
+ End-to-end mimic of the real PLF \`governance-iam\` deploy flow, but driven
2190
+ by **GitHub Actions** (not Azure DevOps) and pointed at **LocalStack**
2191
+ instead of real AWS.
2192
+
2193
+ ## What this lab demonstrates
2194
+
2195
+ 1. \`PR opens\` → workflow runs \`tofu plan\` (read-only preview)
2196
+ 2. \`merge to main\` → workflow runs \`tofu apply\` (deploy)
2197
+ 3. **Composite action** \`./.github/actions/run-tofu-action\` factors out the
2198
+ init/plan/apply boilerplate, exactly like the real repo's reusable action.
2199
+ 4. **Auth** is faked with static \`test\` credentials and a LocalStack
2200
+ endpoint — the *shape* matches \`provider.tf\`'s assume-role pattern.
2201
+
2202
+ ## How to run it
2203
+
2204
+ In the right pane, pick an event + workflow and click **Run**. Useful combos:
2205
+
2206
+ - \`pull_request\` + \`terraform-pr.yml\` → simulates a plan run on a PR
2207
+ - \`push\` + \`terraform-ci.yml\` → simulates an apply on main
2208
+ - \`workflow_dispatch\` + either workflow → manual trigger
2209
+
2210
+ You also need LocalStack running on \`localhost:4566\` on your host. The
2211
+ runner container reaches it via \`host.docker.internal:4566\`.
2212
+
2213
+ ## How this maps to the real PLF setup
2214
+
2215
+ | Real PLF | This lab |
2216
+ | ------------------------------------- | ----------------------------------------------- |
2217
+ | Azure DevOps pipeline (\`pr.yml\`) | GitHub Actions workflow \`terraform-pr.yml\` |
2218
+ | Azure DevOps pipeline (\`ci.yml\`) | GitHub Actions workflow \`terraform-ci.yml\` |
2219
+ | Shared template \`deploy-aws.yml\` | Composite action \`run-tofu-action\` |
2220
+ | AWS service connection | Static \`test\` creds + LocalStack endpoint |
2221
+ | Azure OIDC for state backend | Local state file (no remote backend) |
2222
+ | \`assume_role\` to target accounts | Single LocalStack account, dual provider alias |
2223
+
2224
+ ## File map
2225
+
2226
+ - \`.github/workflows/terraform-pr.yml\` — plan on PR
2227
+ - \`.github/workflows/terraform-ci.yml\` — apply on push to main
2228
+ - \`.github/actions/run-tofu-action/\` — reusable plan/apply steps
2229
+ - \`.github/CODEOWNERS\` — PR review routing
2230
+ - \`terraform/\` — full governance-iam module
2231
+ `,
2232
+
2233
+ ".github/CODEOWNERS": `# Reviewers auto-requested when terraform/ changes.
2234
+ /terraform/ @acme/platform
2235
+ /.github/ @acme/platform
2236
+ *.md @acme/docs
2237
+ `,
2238
+
2239
+ ".github/pull_request_template.md": `## Summary
2240
+
2241
+ <!-- What does this change to the IAM module? -->
2242
+
2243
+ ## Plan output
2244
+
2245
+ \`\`\`
2246
+ <!-- Paste the relevant section of \`tofu plan\` here -->
2247
+ \`\`\`
2248
+
2249
+ ## Checklist
2250
+
2251
+ - [ ] PR title follows conventional-commit style
2252
+ - [ ] \`terraform-pr.yml\` shows a clean plan
2253
+ - [ ] No unintended role/policy deletions
2254
+ `,
2255
+
2256
+ ".github/workflows/terraform-pr.yml": `name: terraform-pr
2257
+
2258
+ # PR-only workflow. Mirrors the real PLF \`pr.yml\`: run a plan, never apply.
2259
+ on:
2260
+ pull_request:
2261
+ paths:
2262
+ - "terraform/**"
2263
+ - ".github/workflows/terraform-*.yml"
2264
+ - ".github/actions/run-tofu-action/**"
2265
+ workflow_dispatch:
2266
+
2267
+ permissions:
2268
+ contents: read
2269
+ pull-requests: write
2270
+
2271
+ jobs:
2272
+ plan:
2273
+ runs-on: ubuntu-latest
2274
+ steps:
2275
+ - uses: actions/checkout@v4
2276
+
2277
+ - name: Plan IAM module
2278
+ uses: ./.github/actions/run-tofu-action
2279
+ with:
2280
+ working-directory: terraform
2281
+ mode: plan
2282
+ `,
2283
+
2284
+ ".github/workflows/terraform-ci.yml": `name: terraform-ci
2285
+
2286
+ # Main-branch workflow. Mirrors the real PLF \`ci.yml\`: apply after merge.
2287
+ on:
2288
+ push:
2289
+ branches: [main]
2290
+ paths:
2291
+ - "terraform/**"
2292
+ - ".github/workflows/terraform-*.yml"
2293
+ - ".github/actions/run-tofu-action/**"
2294
+ workflow_dispatch:
2295
+
2296
+ permissions:
2297
+ contents: read
2298
+
2299
+ jobs:
2300
+ apply:
2301
+ runs-on: ubuntu-latest
2302
+ steps:
2303
+ - uses: actions/checkout@v4
2304
+
2305
+ - name: Apply IAM module
2306
+ uses: ./.github/actions/run-tofu-action
2307
+ with:
2308
+ working-directory: terraform
2309
+ mode: apply
2310
+ `,
2311
+
2312
+ ".github/actions/run-tofu-action/action.yml": `# Reusable composite action — same idea as the real repo's run-tofu-action.
2313
+ # Hides the OpenTofu install + init + plan/apply behind a single \`uses:\`.
2314
+
2315
+ name: "Run OpenTofu"
2316
+ description: "Install OpenTofu, init, then plan or apply against LocalStack."
2317
+
2318
+ inputs:
2319
+ working-directory:
2320
+ description: "Folder containing the Terraform/OpenTofu config."
2321
+ required: true
2322
+ mode:
2323
+ description: "Either 'plan' or 'apply'."
2324
+ required: true
2325
+ default: "plan"
2326
+
2327
+ runs:
2328
+ using: "composite"
2329
+ steps:
2330
+ - name: Install OpenTofu
2331
+ shell: bash
2332
+ run: |
2333
+ curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh
2334
+ chmod +x /tmp/install-opentofu.sh
2335
+ /tmp/install-opentofu.sh --install-method standalone --skip-verify
2336
+ tofu --version
2337
+
2338
+ - name: Tofu init
2339
+ shell: bash
2340
+ working-directory: \${{ inputs.working-directory }}
2341
+ env:
2342
+ AWS_ACCESS_KEY_ID: test
2343
+ AWS_SECRET_ACCESS_KEY: test
2344
+ AWS_DEFAULT_REGION: us-east-1
2345
+ run: tofu init -input=false
2346
+
2347
+ - name: Tofu plan
2348
+ if: \${{ inputs.mode == 'plan' }}
2349
+ shell: bash
2350
+ working-directory: \${{ inputs.working-directory }}
2351
+ env:
2352
+ AWS_ACCESS_KEY_ID: test
2353
+ AWS_SECRET_ACCESS_KEY: test
2354
+ AWS_DEFAULT_REGION: us-east-1
2355
+ run: tofu plan -input=false -no-color
2356
+
2357
+ - name: Tofu apply
2358
+ if: \${{ inputs.mode == 'apply' }}
2359
+ shell: bash
2360
+ working-directory: \${{ inputs.working-directory }}
2361
+ env:
2362
+ AWS_ACCESS_KEY_ID: test
2363
+ AWS_SECRET_ACCESS_KEY: test
2364
+ AWS_DEFAULT_REGION: us-east-1
2365
+ run: tofu apply -input=false -auto-approve -no-color
2366
+ `,
2367
+
2368
+ ".actrc": `# Pin the runner image so installs are reproducible across machines.
2369
+ -P ubuntu-latest=catthehacker/ubuntu:act-latest
2370
+ --container-architecture linux/amd64
2371
+ `,
2372
+
2373
+ // Inline the full governance-iam Terraform module under \`terraform/\` so
2374
+ // the workflows have something real to plan/apply against.
2375
+ ...Object.fromEntries(
2376
+ Object.entries(AWS_GOVERNANCE_IAM_FILES).map(([path, body]) => {
2377
+ // The IAM module assumes provider endpoints point at \`localhost:4566\`,
2378
+ // but the act runner is a separate container and can't reach
2379
+ // \`localhost\` on the host. Rewrite to host.docker.internal so the
2380
+ // workflow run actually succeeds.
2381
+ const rewritten =
2382
+ path === "provider.tf"
2383
+ ? body.replace(
2384
+ /http:\/\/localhost:4566/g,
2385
+ "http://host.docker.internal:4566",
2386
+ )
2387
+ : body;
2388
+ return [`terraform/${path}`, rewritten];
2389
+ }),
2390
+ ),
2391
+ };
2392
+
2393
+ export const AWS_GOVERNANCE_GHA_LAB: GithubActionsLabWorkspace = {
2394
+ version: 1,
2395
+ label: "AWS Governance IAM via GitHub Actions",
2396
+ activeFile: ".github/workflows/terraform-pr.yml",
2397
+ defaultEvent: "pull_request",
2398
+ defaultWorkflow: ".github/workflows/terraform-pr.yml",
2399
+ files: AWS_IAM_GHA_FILES,
2400
+ ghOrg: DEFAULT_GH_LAB_ORG,
2401
+ rulesets: DEFAULT_GH_LAB_RULESETS,
2402
+ pullRequest: DEFAULT_GH_LAB_PULL_REQUEST,
2403
+ };
2404
+
2172
2405
  export const GOVERNANCE_GHA_LAB: GithubActionsLabWorkspace = {
2173
2406
  version: 1,
2174
2407
  label: "Platform Governance Template",
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.28.0"
2
+ "version": "0.29.0"
3
3
  }