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 +1 -1
- package/template/client/src/awsGovernanceIamLab.ts +526 -0
- package/template/client/src/components/GithubActionsLabModal.tsx +251 -161
- package/template/client/src/components/LabsPanel.tsx +21 -0
- package/template/client/src/githubActionsLab.ts +247 -0
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
-
{
|
|
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"}
|
package/template/cockpit.json
CHANGED