create-interview-cockpit 0.29.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-interview-cockpit",
3
- "version": "0.29.0",
3
+ "version": "0.30.1",
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
+ };
@@ -236,18 +236,103 @@ function mapJobStatusToCheck(
236
236
  return s;
237
237
  }
238
238
 
239
- // Tiny grouped-by-folder list to keep the modal lean.
240
- function groupByFolder(paths: string[]): { folder: string; files: string[] }[] {
241
- const map = new Map<string, string[]>();
242
- for (const p of paths) {
243
- const idx = p.lastIndexOf("/");
244
- const folder = idx === -1 ? "" : p.slice(0, idx);
245
- if (!map.has(folder)) map.set(folder, []);
246
- map.get(folder)!.push(p);
239
+ interface FileTreeFileNode {
240
+ type: "file";
241
+ name: string;
242
+ path: string;
243
+ }
244
+
245
+ interface FileTreeFolderNode {
246
+ type: "folder";
247
+ displayName: string;
248
+ path: string;
249
+ children: FileTreeNode[];
250
+ }
251
+
252
+ type FileTreeNode = FileTreeFileNode | FileTreeFolderNode;
253
+
254
+ interface MutableFileTreeFolder {
255
+ name: string;
256
+ path: string;
257
+ files: FileTreeFileNode[];
258
+ folders: Map<string, MutableFileTreeFolder>;
259
+ }
260
+
261
+ function sortFileTreeNodes<T extends { name?: string; displayName?: string }>(
262
+ a: T,
263
+ b: T,
264
+ ) {
265
+ return (a.displayName ?? a.name ?? "").localeCompare(
266
+ b.displayName ?? b.name ?? "",
267
+ );
268
+ }
269
+
270
+ function getMutableFolderChildren(folder: MutableFileTreeFolder): FileTreeNode[] {
271
+ const files = [...folder.files].sort(sortFileTreeNodes);
272
+ const folders = Array.from(folder.folders.values())
273
+ .map(compactFileTreeFolder)
274
+ .sort(sortFileTreeNodes);
275
+ return [...files, ...folders];
276
+ }
277
+
278
+ function compactFileTreeFolder(
279
+ folder: MutableFileTreeFolder,
280
+ ): FileTreeFolderNode {
281
+ const names = [folder.name];
282
+ let current = folder;
283
+
284
+ while (current.files.length === 0 && current.folders.size === 1) {
285
+ const next = Array.from(current.folders.values())[0];
286
+ names.push(next.name);
287
+ current = next;
247
288
  }
248
- return Array.from(map.entries())
249
- .sort(([a], [b]) => a.localeCompare(b))
250
- .map(([folder, files]) => ({ folder, files: files.sort() }));
289
+
290
+ return {
291
+ type: "folder",
292
+ displayName: names.join("/"),
293
+ path: current.path,
294
+ children: getMutableFolderChildren(current),
295
+ };
296
+ }
297
+
298
+ function buildCompactFileTree(paths: string[]): FileTreeNode[] {
299
+ const root: MutableFileTreeFolder = {
300
+ name: "",
301
+ path: "",
302
+ files: [],
303
+ folders: new Map(),
304
+ };
305
+
306
+ for (const filePath of paths) {
307
+ const parts = filePath.split("/").filter(Boolean);
308
+ if (parts.length === 0) continue;
309
+
310
+ let current = root;
311
+ let currentPath = "";
312
+ for (let i = 0; i < parts.length - 1; i += 1) {
313
+ const name = parts[i];
314
+ currentPath = currentPath ? `${currentPath}/${name}` : name;
315
+ let next = current.folders.get(name);
316
+ if (!next) {
317
+ next = {
318
+ name,
319
+ path: currentPath,
320
+ files: [],
321
+ folders: new Map(),
322
+ };
323
+ current.folders.set(name, next);
324
+ }
325
+ current = next;
326
+ }
327
+
328
+ current.files.push({
329
+ type: "file",
330
+ name: parts[parts.length - 1],
331
+ path: filePath,
332
+ });
333
+ }
334
+
335
+ return getMutableFolderChildren(root);
251
336
  }
252
337
 
253
338
  // ─── Component ───────────────────────────────────────────────────────────
@@ -514,7 +599,7 @@ export default function GithubActionsLabModal() {
514
599
 
515
600
  // ── File operations ───────────────────────────────────────────────
516
601
  const fileOrder = useMemo(() => getGhaLabFileOrder(workspace), [workspace]);
517
- const grouped = useMemo(() => groupByFolder(fileOrder), [fileOrder]);
602
+ const fileTree = useMemo(() => buildCompactFileTree(fileOrder), [fileOrder]);
518
603
  const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
519
604
  () => new Set(),
520
605
  );
@@ -1706,6 +1791,159 @@ interface ImportMeta {
1706
1791
  minHeight: MIN_H,
1707
1792
  };
1708
1793
 
1794
+ const renderFileTreeNode = (node: FileTreeNode, depth: number) => {
1795
+ const paddingLeft = 6 + depth * 14;
1796
+
1797
+ if (node.type === "folder") {
1798
+ const collapsed = collapsedFolders.has(node.path);
1799
+ return (
1800
+ <div key={`folder:${node.path || node.displayName}`}>
1801
+ <button
1802
+ onClick={() => toggleFolder(node.path)}
1803
+ onDragOver={(e) => handleFolderDragOver(e, node.path)}
1804
+ onDragLeave={() =>
1805
+ setDragOverFolder((current) =>
1806
+ current === node.path ? null : current,
1807
+ )
1808
+ }
1809
+ onDrop={(e) => handleFolderDrop(e, node.path)}
1810
+ className="flex items-center gap-1 w-full pr-1 py-0.5 text-slate-400 hover:text-slate-200"
1811
+ style={{ paddingLeft }}
1812
+ title="Drop a file here to move it. Hold Option/Alt while dropping to copy."
1813
+ >
1814
+ {collapsed ? (
1815
+ <ChevronRight className="w-3 h-3 shrink-0" />
1816
+ ) : (
1817
+ <ChevronDown className="w-3 h-3 shrink-0" />
1818
+ )}
1819
+ <Folder className="w-3 h-3 shrink-0" />
1820
+ <span
1821
+ className={`truncate rounded px-1 ${
1822
+ dragOverFolder === node.path
1823
+ ? "bg-amber-500/15 text-amber-200"
1824
+ : ""
1825
+ }`}
1826
+ >
1827
+ {node.displayName}/
1828
+ </span>
1829
+ </button>
1830
+ {!collapsed &&
1831
+ node.children.map((child) =>
1832
+ renderFileTreeNode(child, depth + 1),
1833
+ )}
1834
+ </div>
1835
+ );
1836
+ }
1837
+
1838
+ const filePath = node.path;
1839
+ return (
1840
+ <div
1841
+ key={`file:${filePath}`}
1842
+ data-selected={selectedFiles.has(filePath)}
1843
+ draggable
1844
+ onDragStart={(e) => handleFileDragStart(e, filePath)}
1845
+ onDragEnd={() => {
1846
+ setDraggingFile(null);
1847
+ setDragOverFolder(null);
1848
+ }}
1849
+ className={`group relative flex items-center gap-1 pr-1 py-0.5 rounded cursor-pointer ${
1850
+ activeFile === filePath
1851
+ ? "bg-amber-500/15 text-amber-200"
1852
+ : selectedFiles.has(filePath)
1853
+ ? "bg-sky-500/10 text-sky-100 hover:bg-sky-500/15"
1854
+ : "text-slate-300 hover:bg-slate-800/40"
1855
+ }`}
1856
+ onClick={() => setActiveFile(filePath)}
1857
+ style={{ paddingLeft }}
1858
+ >
1859
+ {(selectMode || selectedFiles.has(filePath)) && (
1860
+ <input
1861
+ type="checkbox"
1862
+ checked={selectedFiles.has(filePath)}
1863
+ onClick={(e) => e.stopPropagation()}
1864
+ onChange={() => toggleFileSelection(filePath)}
1865
+ className="h-3 w-3 shrink-0 accent-amber-400"
1866
+ title="Select file"
1867
+ />
1868
+ )}
1869
+ <span className="truncate flex-1">{node.name}</span>
1870
+ <button
1871
+ onClick={(e) => {
1872
+ e.stopPropagation();
1873
+ setOpenFileMenu((current) =>
1874
+ current === filePath ? null : filePath,
1875
+ );
1876
+ setBulkMenuOpen(false);
1877
+ }}
1878
+ className="rounded px-1 text-slate-500 opacity-70 hover:bg-slate-800/70 hover:text-amber-200 group-hover:opacity-100"
1879
+ title="File actions"
1880
+ >
1881
+
1882
+ </button>
1883
+ {openFileMenu === filePath && (
1884
+ <div
1885
+ onClick={(e) => e.stopPropagation()}
1886
+ className="absolute right-1 top-6 z-40 w-40 overflow-hidden rounded-lg border border-slate-700 bg-slate-950 py-1 text-[11px] shadow-xl"
1887
+ >
1888
+ <button
1889
+ onClick={() => moveFile(filePath)}
1890
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1891
+ >
1892
+ <Pencil className="w-3 h-3 text-amber-300" />
1893
+ Move / rename…
1894
+ </button>
1895
+ <button
1896
+ onClick={() => copyFile(filePath)}
1897
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1898
+ >
1899
+ <Copy className="w-3 h-3 text-sky-300" />
1900
+ Copy to path…
1901
+ </button>
1902
+ <button
1903
+ onClick={() => {
1904
+ toggleFileSelection(filePath);
1905
+ setOpenFileMenu(null);
1906
+ }}
1907
+ className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1908
+ >
1909
+ <ListChecks className="w-3 h-3 text-amber-300" />
1910
+ {selectedFiles.has(filePath) ? "Deselect" : "Select"}
1911
+ </button>
1912
+ {selectedFileList.length > 1 && selectedFiles.has(filePath) && (
1913
+ <>
1914
+ <button
1915
+ onClick={() => {
1916
+ moveFilesToFolder(selectedFileList);
1917
+ setOpenFileMenu(null);
1918
+ }}
1919
+ className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1920
+ >
1921
+ Move selected…
1922
+ </button>
1923
+ <button
1924
+ onClick={() => {
1925
+ copyFilesToFolder(selectedFileList);
1926
+ setOpenFileMenu(null);
1927
+ }}
1928
+ className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
1929
+ >
1930
+ Copy selected…
1931
+ </button>
1932
+ </>
1933
+ )}
1934
+ <button
1935
+ onClick={() => deleteFile(filePath)}
1936
+ className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-red-300 hover:bg-red-500/10"
1937
+ >
1938
+ <Trash2 className="w-3 h-3" />
1939
+ Delete
1940
+ </button>
1941
+ </div>
1942
+ )}
1943
+ </div>
1944
+ );
1945
+ };
1946
+
1709
1947
  return (
1710
1948
  <div className="fixed inset-0 z-40 bg-black/40">
1711
1949
  <div
@@ -2021,155 +2259,7 @@ interface ImportMeta {
2021
2259
  in workspace root • hold Option/Alt to copy
2022
2260
  </div>
2023
2261
  )}
2024
- {grouped.map(({ folder, files }) => {
2025
- const collapsed = collapsedFolders.has(folder);
2026
- return (
2027
- <div key={folder || "root"} className="mb-1">
2028
- {folder && (
2029
- <button
2030
- onClick={() => toggleFolder(folder)}
2031
- onDragOver={(e) => handleFolderDragOver(e, folder)}
2032
- onDragLeave={() =>
2033
- setDragOverFolder((current) =>
2034
- current === folder ? null : current,
2035
- )
2036
- }
2037
- onDrop={(e) => handleFolderDrop(e, folder)}
2038
- className="flex items-center gap-1 w-full px-1 py-0.5 text-slate-400 hover:text-slate-200"
2039
- title="Drop a file here to move it. Hold Option/Alt while dropping to copy."
2040
- >
2041
- {collapsed ? (
2042
- <ChevronRight className="w-3 h-3" />
2043
- ) : (
2044
- <ChevronDown className="w-3 h-3" />
2045
- )}
2046
- <Folder className="w-3 h-3" />
2047
- <span
2048
- className={`truncate rounded px-1 ${
2049
- dragOverFolder === folder
2050
- ? "bg-amber-500/15 text-amber-200"
2051
- : ""
2052
- }`}
2053
- >
2054
- {folder}/
2055
- </span>
2056
- </button>
2057
- )}
2058
- {!collapsed &&
2059
- files.map((filePath) => (
2060
- <div
2061
- key={filePath}
2062
- data-selected={selectedFiles.has(filePath)}
2063
- draggable
2064
- onDragStart={(e) => handleFileDragStart(e, filePath)}
2065
- onDragEnd={() => {
2066
- setDraggingFile(null);
2067
- setDragOverFolder(null);
2068
- }}
2069
- className={`group relative flex items-center gap-1 pl-${folder ? 5 : 1} pr-1 py-0.5 rounded cursor-pointer ${
2070
- activeFile === filePath
2071
- ? "bg-amber-500/15 text-amber-200"
2072
- : selectedFiles.has(filePath)
2073
- ? "bg-sky-500/10 text-sky-100 hover:bg-sky-500/15"
2074
- : "text-slate-300 hover:bg-slate-800/40"
2075
- }`}
2076
- onClick={() => setActiveFile(filePath)}
2077
- style={{ paddingLeft: folder ? 20 : 6 }}
2078
- >
2079
- {(selectMode || selectedFiles.has(filePath)) && (
2080
- <input
2081
- type="checkbox"
2082
- checked={selectedFiles.has(filePath)}
2083
- onClick={(e) => e.stopPropagation()}
2084
- onChange={() => toggleFileSelection(filePath)}
2085
- className="h-3 w-3 shrink-0 accent-amber-400"
2086
- title="Select file"
2087
- />
2088
- )}
2089
- <span className="truncate flex-1">
2090
- {baseName(filePath)}
2091
- </span>
2092
- <button
2093
- onClick={(e) => {
2094
- e.stopPropagation();
2095
- setOpenFileMenu((current) =>
2096
- current === filePath ? null : filePath,
2097
- );
2098
- setBulkMenuOpen(false);
2099
- }}
2100
- className="rounded px-1 text-slate-500 opacity-70 hover:bg-slate-800/70 hover:text-amber-200 group-hover:opacity-100"
2101
- title="File actions"
2102
- >
2103
-
2104
- </button>
2105
- {openFileMenu === filePath && (
2106
- <div
2107
- onClick={(e) => e.stopPropagation()}
2108
- className="absolute right-1 top-6 z-40 w-40 overflow-hidden rounded-lg border border-slate-700 bg-slate-950 py-1 text-[11px] shadow-xl"
2109
- >
2110
- <button
2111
- onClick={() => moveFile(filePath)}
2112
- className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
2113
- >
2114
- <Pencil className="w-3 h-3 text-amber-300" />
2115
- Move / rename…
2116
- </button>
2117
- <button
2118
- onClick={() => copyFile(filePath)}
2119
- className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
2120
- >
2121
- <Copy className="w-3 h-3 text-sky-300" />
2122
- Copy to path…
2123
- </button>
2124
- <button
2125
- onClick={() => {
2126
- toggleFileSelection(filePath);
2127
- setOpenFileMenu(null);
2128
- }}
2129
- className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
2130
- >
2131
- <ListChecks className="w-3 h-3 text-amber-300" />
2132
- {selectedFiles.has(filePath)
2133
- ? "Deselect"
2134
- : "Select"}
2135
- </button>
2136
- {selectedFileList.length > 1 &&
2137
- selectedFiles.has(filePath) && (
2138
- <>
2139
- <button
2140
- onClick={() => {
2141
- moveFilesToFolder(selectedFileList);
2142
- setOpenFileMenu(null);
2143
- }}
2144
- className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
2145
- >
2146
- Move selected…
2147
- </button>
2148
- <button
2149
- onClick={() => {
2150
- copyFilesToFolder(selectedFileList);
2151
- setOpenFileMenu(null);
2152
- }}
2153
- className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-slate-200 hover:bg-slate-800/70"
2154
- >
2155
- Copy selected…
2156
- </button>
2157
- </>
2158
- )}
2159
- <button
2160
- onClick={() => deleteFile(filePath)}
2161
- className="flex w-full items-center gap-2 border-t border-slate-800 px-2 py-1.5 text-left text-red-300 hover:bg-red-500/10"
2162
- >
2163
- <Trash2 className="w-3 h-3" />
2164
- Delete
2165
- </button>
2166
- </div>
2167
- )}
2168
- </div>
2169
- ))}
2170
- </div>
2171
- );
2172
- })}
2262
+ {fileTree.map((node) => renderFileTreeNode(node, 0))}
2173
2263
  </div>
2174
2264
  </div>
2175
2265
 
@@ -8,12 +8,15 @@ import {
8
8
  parseInfraLabWorkspace,
9
9
  } from "../infraLab";
10
10
  import {
11
+ AWS_GOVERNANCE_GHA_LAB,
11
12
  DEFAULT_GHA_LAB,
13
+ EMPTY_GITHUB_GHA_LAB,
12
14
  GOVERNANCE_GHA_LAB,
13
15
  parseGhaLabWorkspace,
14
16
  REACT_VITE_TYPESCRIPT_GHA_LAB,
15
17
  } from "../githubActionsLab";
16
18
  import { ENTERPRISE_LOCAL_AUTH_LAB } from "../enterpriseLocalLab";
19
+ import { AWS_GOVERNANCE_IAM_LAB } from "../awsGovernanceIamLab";
17
20
  import {
18
21
  parseFrontendLabWorkspace,
19
22
  ISOLATED_MODULE_FEDERATION_LAB,
@@ -672,6 +675,12 @@ export default function LabsPanel() {
672
675
  "Terraform deploys NestJS BFF, OIDC mock, Redis & claims API",
673
676
  onClick: () => openInfraLab(ENTERPRISE_LOCAL_AUTH_LAB),
674
677
  },
678
+ {
679
+ label: "AWS Governance IAM",
680
+ description:
681
+ "Roles, policies, attachments, users & OIDC mirroring a real governance-iam module on LocalStack",
682
+ onClick: () => openInfraLab(AWS_GOVERNANCE_IAM_LAB),
683
+ },
675
684
  ]}
676
685
  onOpen={openInfraFile}
677
686
  openTitle="Open in Infrastructure Lab"
@@ -685,6 +694,12 @@ export default function LabsPanel() {
685
694
  origin="github-actions"
686
695
  emptyText="Save a GitHub lab to reopen it here"
687
696
  newLabMenu={[
697
+ {
698
+ label: "Empty GitHub Template",
699
+ description:
700
+ "Minimal repo with blank .github/workflows/ci.yml and CODEOWNERS files",
701
+ onClick: () => openGhaLab(EMPTY_GITHUB_GHA_LAB),
702
+ },
688
703
  {
689
704
  label: "React Vite TypeScript Starter",
690
705
  description:
@@ -703,6 +718,12 @@ export default function LabsPanel() {
703
718
  "PLF-style mono-repo: CODEOWNERS, PR template, Azure PIM/Policy + AWS IAM deploy workflows, offboarding",
704
719
  onClick: () => openGhaLab(GOVERNANCE_GHA_LAB),
705
720
  },
721
+ {
722
+ label: "AWS Governance IAM via GitHub Actions",
723
+ description:
724
+ "PR plan + main-branch apply workflows that drive a real Terraform IAM module against LocalStack via a reusable composite action",
725
+ onClick: () => openGhaLab(AWS_GOVERNANCE_GHA_LAB),
726
+ },
706
727
  ]}
707
728
  onOpen={openGhaFile}
708
729
  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
  //
@@ -512,6 +513,11 @@ li {
512
513
  `,
513
514
  };
514
515
 
516
+ const EMPTY_GITHUB_LAB_FILES: Record<string, string> = {
517
+ ".github/workflows/ci.yml": "",
518
+ ".github/CODEOWNERS": "",
519
+ };
520
+
515
521
  // ─── Platform Governance Template ────────────────────────────────────────
516
522
  //
517
523
  // Mirrors a real-world "PLF-governance" mono-repo: one repo that owns
@@ -2169,6 +2175,238 @@ Write-Host "\`nDone. DryRun=$DryRun"
2169
2175
  `,
2170
2176
  };
2171
2177
 
2178
+ // ─── AWS Governance IAM via GitHub Actions ───────────────────────────────
2179
+ //
2180
+ // Combines the Infra Lab's `governance-iam` Terraform module with a
2181
+ // GitHub-Actions-driven deploy flow that mirrors the real PLF setup, but
2182
+ // runs entirely locally via `act` + LocalStack:
2183
+ //
2184
+ // - PR opens / updates → `terraform-pr.yml` runs `tofu plan`
2185
+ // - merge to main → `terraform-ci.yml` runs `tofu apply`
2186
+ // - shared steps live in a composite action `./.github/actions/run-tofu-action`
2187
+ //
2188
+ // LocalStack is reachable from the act runner container at
2189
+ // `host.docker.internal:4566` on macOS / Windows, and via the
2190
+ // `--network host` runner override (or the literal IP) on Linux.
2191
+ const AWS_IAM_GHA_FILES: Record<string, string> = {
2192
+ "README.md": `# AWS Governance IAM — GitHub Actions Lab
2193
+
2194
+ End-to-end mimic of the real PLF \`governance-iam\` deploy flow, but driven
2195
+ by **GitHub Actions** (not Azure DevOps) and pointed at **LocalStack**
2196
+ instead of real AWS.
2197
+
2198
+ ## What this lab demonstrates
2199
+
2200
+ 1. \`PR opens\` → workflow runs \`tofu plan\` (read-only preview)
2201
+ 2. \`merge to main\` → workflow runs \`tofu apply\` (deploy)
2202
+ 3. **Composite action** \`./.github/actions/run-tofu-action\` factors out the
2203
+ init/plan/apply boilerplate, exactly like the real repo's reusable action.
2204
+ 4. **Auth** is faked with static \`test\` credentials and a LocalStack
2205
+ endpoint — the *shape* matches \`provider.tf\`'s assume-role pattern.
2206
+
2207
+ ## How to run it
2208
+
2209
+ In the right pane, pick an event + workflow and click **Run**. Useful combos:
2210
+
2211
+ - \`pull_request\` + \`terraform-pr.yml\` → simulates a plan run on a PR
2212
+ - \`push\` + \`terraform-ci.yml\` → simulates an apply on main
2213
+ - \`workflow_dispatch\` + either workflow → manual trigger
2214
+
2215
+ You also need LocalStack running on \`localhost:4566\` on your host. The
2216
+ runner container reaches it via \`host.docker.internal:4566\`.
2217
+
2218
+ ## How this maps to the real PLF setup
2219
+
2220
+ | Real PLF | This lab |
2221
+ | ------------------------------------- | ----------------------------------------------- |
2222
+ | Azure DevOps pipeline (\`pr.yml\`) | GitHub Actions workflow \`terraform-pr.yml\` |
2223
+ | Azure DevOps pipeline (\`ci.yml\`) | GitHub Actions workflow \`terraform-ci.yml\` |
2224
+ | Shared template \`deploy-aws.yml\` | Composite action \`run-tofu-action\` |
2225
+ | AWS service connection | Static \`test\` creds + LocalStack endpoint |
2226
+ | Azure OIDC for state backend | Local state file (no remote backend) |
2227
+ | \`assume_role\` to target accounts | Single LocalStack account, dual provider alias |
2228
+
2229
+ ## File map
2230
+
2231
+ - \`.github/workflows/terraform-pr.yml\` — plan on PR
2232
+ - \`.github/workflows/terraform-ci.yml\` — apply on push to main
2233
+ - \`.github/actions/run-tofu-action/\` — reusable plan/apply steps
2234
+ - \`.github/CODEOWNERS\` — PR review routing
2235
+ - \`terraform/\` — full governance-iam module
2236
+ `,
2237
+
2238
+ ".github/CODEOWNERS": `# Reviewers auto-requested when terraform/ changes.
2239
+ /terraform/ @acme/platform
2240
+ /.github/ @acme/platform
2241
+ *.md @acme/docs
2242
+ `,
2243
+
2244
+ ".github/pull_request_template.md": `## Summary
2245
+
2246
+ <!-- What does this change to the IAM module? -->
2247
+
2248
+ ## Plan output
2249
+
2250
+ \`\`\`
2251
+ <!-- Paste the relevant section of \`tofu plan\` here -->
2252
+ \`\`\`
2253
+
2254
+ ## Checklist
2255
+
2256
+ - [ ] PR title follows conventional-commit style
2257
+ - [ ] \`terraform-pr.yml\` shows a clean plan
2258
+ - [ ] No unintended role/policy deletions
2259
+ `,
2260
+
2261
+ ".github/workflows/terraform-pr.yml": `name: terraform-pr
2262
+
2263
+ # PR-only workflow. Mirrors the real PLF \`pr.yml\`: run a plan, never apply.
2264
+ on:
2265
+ pull_request:
2266
+ paths:
2267
+ - "terraform/**"
2268
+ - ".github/workflows/terraform-*.yml"
2269
+ - ".github/actions/run-tofu-action/**"
2270
+ workflow_dispatch:
2271
+
2272
+ permissions:
2273
+ contents: read
2274
+ pull-requests: write
2275
+
2276
+ jobs:
2277
+ plan:
2278
+ runs-on: ubuntu-latest
2279
+ steps:
2280
+ - uses: actions/checkout@v4
2281
+
2282
+ - name: Plan IAM module
2283
+ uses: ./.github/actions/run-tofu-action
2284
+ with:
2285
+ working-directory: terraform
2286
+ mode: plan
2287
+ `,
2288
+
2289
+ ".github/workflows/terraform-ci.yml": `name: terraform-ci
2290
+
2291
+ # Main-branch workflow. Mirrors the real PLF \`ci.yml\`: apply after merge.
2292
+ on:
2293
+ push:
2294
+ branches: [main]
2295
+ paths:
2296
+ - "terraform/**"
2297
+ - ".github/workflows/terraform-*.yml"
2298
+ - ".github/actions/run-tofu-action/**"
2299
+ workflow_dispatch:
2300
+
2301
+ permissions:
2302
+ contents: read
2303
+
2304
+ jobs:
2305
+ apply:
2306
+ runs-on: ubuntu-latest
2307
+ steps:
2308
+ - uses: actions/checkout@v4
2309
+
2310
+ - name: Apply IAM module
2311
+ uses: ./.github/actions/run-tofu-action
2312
+ with:
2313
+ working-directory: terraform
2314
+ mode: apply
2315
+ `,
2316
+
2317
+ ".github/actions/run-tofu-action/action.yml": `# Reusable composite action — same idea as the real repo's run-tofu-action.
2318
+ # Hides the OpenTofu install + init + plan/apply behind a single \`uses:\`.
2319
+
2320
+ name: "Run OpenTofu"
2321
+ description: "Install OpenTofu, init, then plan or apply against LocalStack."
2322
+
2323
+ inputs:
2324
+ working-directory:
2325
+ description: "Folder containing the Terraform/OpenTofu config."
2326
+ required: true
2327
+ mode:
2328
+ description: "Either 'plan' or 'apply'."
2329
+ required: true
2330
+ default: "plan"
2331
+
2332
+ runs:
2333
+ using: "composite"
2334
+ steps:
2335
+ - name: Install OpenTofu
2336
+ shell: bash
2337
+ run: |
2338
+ curl -fsSL https://get.opentofu.org/install-opentofu.sh -o /tmp/install-opentofu.sh
2339
+ chmod +x /tmp/install-opentofu.sh
2340
+ /tmp/install-opentofu.sh --install-method standalone --skip-verify
2341
+ tofu --version
2342
+
2343
+ - name: Tofu init
2344
+ shell: bash
2345
+ working-directory: \${{ inputs.working-directory }}
2346
+ env:
2347
+ AWS_ACCESS_KEY_ID: test
2348
+ AWS_SECRET_ACCESS_KEY: test
2349
+ AWS_DEFAULT_REGION: us-east-1
2350
+ run: tofu init -input=false
2351
+
2352
+ - name: Tofu plan
2353
+ if: \${{ inputs.mode == 'plan' }}
2354
+ shell: bash
2355
+ working-directory: \${{ inputs.working-directory }}
2356
+ env:
2357
+ AWS_ACCESS_KEY_ID: test
2358
+ AWS_SECRET_ACCESS_KEY: test
2359
+ AWS_DEFAULT_REGION: us-east-1
2360
+ run: tofu plan -input=false -no-color
2361
+
2362
+ - name: Tofu apply
2363
+ if: \${{ inputs.mode == 'apply' }}
2364
+ shell: bash
2365
+ working-directory: \${{ inputs.working-directory }}
2366
+ env:
2367
+ AWS_ACCESS_KEY_ID: test
2368
+ AWS_SECRET_ACCESS_KEY: test
2369
+ AWS_DEFAULT_REGION: us-east-1
2370
+ run: tofu apply -input=false -auto-approve -no-color
2371
+ `,
2372
+
2373
+ ".actrc": `# Pin the runner image so installs are reproducible across machines.
2374
+ -P ubuntu-latest=catthehacker/ubuntu:act-latest
2375
+ --container-architecture linux/amd64
2376
+ `,
2377
+
2378
+ // Inline the full governance-iam Terraform module under \`terraform/\` so
2379
+ // the workflows have something real to plan/apply against.
2380
+ ...Object.fromEntries(
2381
+ Object.entries(AWS_GOVERNANCE_IAM_FILES).map(([path, body]) => {
2382
+ // The IAM module assumes provider endpoints point at \`localhost:4566\`,
2383
+ // but the act runner is a separate container and can't reach
2384
+ // \`localhost\` on the host. Rewrite to host.docker.internal so the
2385
+ // workflow run actually succeeds.
2386
+ const rewritten =
2387
+ path === "provider.tf"
2388
+ ? body.replace(
2389
+ /http:\/\/localhost:4566/g,
2390
+ "http://host.docker.internal:4566",
2391
+ )
2392
+ : body;
2393
+ return [`terraform/${path}`, rewritten];
2394
+ }),
2395
+ ),
2396
+ };
2397
+
2398
+ export const AWS_GOVERNANCE_GHA_LAB: GithubActionsLabWorkspace = {
2399
+ version: 1,
2400
+ label: "AWS Governance IAM via GitHub Actions",
2401
+ activeFile: ".github/workflows/terraform-pr.yml",
2402
+ defaultEvent: "pull_request",
2403
+ defaultWorkflow: ".github/workflows/terraform-pr.yml",
2404
+ files: AWS_IAM_GHA_FILES,
2405
+ ghOrg: DEFAULT_GH_LAB_ORG,
2406
+ rulesets: DEFAULT_GH_LAB_RULESETS,
2407
+ pullRequest: DEFAULT_GH_LAB_PULL_REQUEST,
2408
+ };
2409
+
2172
2410
  export const GOVERNANCE_GHA_LAB: GithubActionsLabWorkspace = {
2173
2411
  version: 1,
2174
2412
  label: "Platform Governance Template",
@@ -2202,6 +2440,15 @@ export const REACT_VITE_TYPESCRIPT_GHA_LAB: GithubActionsLabWorkspace = {
2202
2440
  files: REACT_VITE_TYPESCRIPT_FILES,
2203
2441
  };
2204
2442
 
2443
+ export const EMPTY_GITHUB_GHA_LAB: GithubActionsLabWorkspace = {
2444
+ version: 1,
2445
+ label: "Empty GitHub Lab Template",
2446
+ activeFile: ".github/workflows/ci.yml",
2447
+ defaultEvent: "push",
2448
+ defaultWorkflow: ".github/workflows/ci.yml",
2449
+ files: EMPTY_GITHUB_LAB_FILES,
2450
+ };
2451
+
2205
2452
  // ─── Helpers (mirror infraLab.ts API surface) ────────────────────────────
2206
2453
 
2207
2454
  function cloneGhaLabEnvironment(
@@ -1 +1 @@
1
- {"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/enterpriselocallab.ts","./src/ghaconcurrency.ts","./src/githubactionslab.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/diagramsmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/ghaconcurrencypanel.tsx","./src/components/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/gitdiffpanel.tsx","./src/components/gitdiffviewermodal.tsx","./src/components/githubactionslabmodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
1
+ {"root":["./src/app.tsx","./src/api.ts","./src/awsgovernanceiamlab.ts","./src/browsersecuritytemplates.ts","./src/codeowners.ts","./src/enterpriselocallab.ts","./src/ghaconcurrency.ts","./src/githubactionslab.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/diagramsmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/ghaconcurrencypanel.tsx","./src/components/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/gitdiffpanel.tsx","./src/components/gitdiffviewermodal.tsx","./src/components/githubactionslabmodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/pullrequestpanel.tsx","./src/components/settingspanel.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.28.0"
2
+ "version": "0.30.0"
3
3
  }