create-interview-cockpit 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/client/src/awsGovernanceIamLab.ts +526 -0
- package/template/client/src/codeowners.ts +120 -0
- package/template/client/src/components/ChatView.tsx +18 -7
- package/template/client/src/components/GithubActionsLabModal.tsx +80 -39
- package/template/client/src/components/LabsPanel.tsx +21 -0
- package/template/client/src/components/PullRequestPanel.tsx +125 -0
- package/template/client/src/components/SettingsPanel.tsx +4 -1
- package/template/client/src/githubActionsLab.ts +1926 -0
- package/template/client/src/index.css +71 -0
- package/template/client/src/types.ts +6 -0
- 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
|
+
};
|
|
@@ -790,3 +790,123 @@ export function findCodeOwnersPath(
|
|
|
790
790
|
export function isCodeOwnersPath(path: string): boolean {
|
|
791
791
|
return (CODEOWNERS_LOCATIONS as readonly string[]).includes(path);
|
|
792
792
|
}
|
|
793
|
+
|
|
794
|
+
// ─── Pull request templates ───────────────────────────────────────────
|
|
795
|
+
//
|
|
796
|
+
// GitHub doesn't have a settings UI for PR templates — they're picked
|
|
797
|
+
// up purely from files in the repo. The lookup rules we mirror:
|
|
798
|
+
//
|
|
799
|
+
// 1. Single template: the first match wins, in priority order:
|
|
800
|
+
// .github/pull_request_template.md
|
|
801
|
+
// .github/PULL_REQUEST_TEMPLATE.md
|
|
802
|
+
// pull_request_template.md (repo root)
|
|
803
|
+
// PULL_REQUEST_TEMPLATE.md (repo root)
|
|
804
|
+
// docs/pull_request_template.md
|
|
805
|
+
// docs/PULL_REQUEST_TEMPLATE.md
|
|
806
|
+
// Lookup is also case-insensitive on the filename.
|
|
807
|
+
//
|
|
808
|
+
// 2. Multiple templates: any *.md (or *.txt) file inside
|
|
809
|
+
// .github/PULL_REQUEST_TEMPLATE/
|
|
810
|
+
// PULL_REQUEST_TEMPLATE/
|
|
811
|
+
// docs/PULL_REQUEST_TEMPLATE/
|
|
812
|
+
// Each becomes a pick-able template; on github.com you'd append
|
|
813
|
+
// ?template=foo.md to the compare URL. We just show a picker.
|
|
814
|
+
//
|
|
815
|
+
// If both kinds are present, github.com shows the directory picker but
|
|
816
|
+
// also still lists the single template; we follow the same behaviour.
|
|
817
|
+
|
|
818
|
+
export interface PullRequestTemplate {
|
|
819
|
+
/** Workspace path of the file. */
|
|
820
|
+
path: string;
|
|
821
|
+
/** Display name (file basename minus extension, prettified). */
|
|
822
|
+
name: string;
|
|
823
|
+
/** Raw markdown body of the template. */
|
|
824
|
+
body: string;
|
|
825
|
+
/** True if this is the repo's default single template. */
|
|
826
|
+
isDefault: boolean;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const SINGLE_PR_TEMPLATE_DIRS: readonly string[] = [".github/", "", "docs/"];
|
|
830
|
+
const MULTI_PR_TEMPLATE_DIRS: readonly string[] = [
|
|
831
|
+
".github/PULL_REQUEST_TEMPLATE/",
|
|
832
|
+
"PULL_REQUEST_TEMPLATE/",
|
|
833
|
+
"docs/PULL_REQUEST_TEMPLATE/",
|
|
834
|
+
];
|
|
835
|
+
|
|
836
|
+
function isPullRequestTemplateFilename(filename: string): boolean {
|
|
837
|
+
// GitHub matches pull_request_template.md case-insensitively, with .md
|
|
838
|
+
// or .markdown extensions. We accept both.
|
|
839
|
+
return /^pull_request_template\.(md|markdown)$/i.test(filename);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function prettifyTemplateName(filename: string): string {
|
|
843
|
+
const base = filename.replace(/\.(md|markdown|txt)$/i, "");
|
|
844
|
+
return base.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Discover all PR templates in the workspace, in github.com priority
|
|
849
|
+
* order. Returns an empty array if no templates exist.
|
|
850
|
+
*/
|
|
851
|
+
export function findPullRequestTemplates(
|
|
852
|
+
files: Record<string, string>,
|
|
853
|
+
): PullRequestTemplate[] {
|
|
854
|
+
const out: PullRequestTemplate[] = [];
|
|
855
|
+
const seen = new Set<string>();
|
|
856
|
+
|
|
857
|
+
// 1. Single-template lookup
|
|
858
|
+
for (const dir of SINGLE_PR_TEMPLATE_DIRS) {
|
|
859
|
+
for (const path of Object.keys(files)) {
|
|
860
|
+
if (seen.has(path)) continue;
|
|
861
|
+
if (!path.startsWith(dir)) continue;
|
|
862
|
+
const rest = path.slice(dir.length);
|
|
863
|
+
if (rest.includes("/")) continue; // not directly in this dir
|
|
864
|
+
if (!isPullRequestTemplateFilename(rest)) continue;
|
|
865
|
+
out.push({
|
|
866
|
+
path,
|
|
867
|
+
name: "Default",
|
|
868
|
+
body: files[path] ?? "",
|
|
869
|
+
isDefault: true,
|
|
870
|
+
});
|
|
871
|
+
seen.add(path);
|
|
872
|
+
break; // first match per dir; priority order handles the rest
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// 2. Multi-template directory lookup
|
|
877
|
+
for (const dir of MULTI_PR_TEMPLATE_DIRS) {
|
|
878
|
+
for (const path of Object.keys(files)) {
|
|
879
|
+
if (seen.has(path)) continue;
|
|
880
|
+
if (!path.startsWith(dir)) continue;
|
|
881
|
+
const rest = path.slice(dir.length);
|
|
882
|
+
if (!rest || rest.includes("/")) continue;
|
|
883
|
+
if (!/\.(md|markdown|txt)$/i.test(rest)) continue;
|
|
884
|
+
out.push({
|
|
885
|
+
path,
|
|
886
|
+
name: prettifyTemplateName(rest),
|
|
887
|
+
body: files[path] ?? "",
|
|
888
|
+
isDefault: false,
|
|
889
|
+
});
|
|
890
|
+
seen.add(path);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return out;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** True if the path is a recognised PR-template location. */
|
|
898
|
+
export function isPullRequestTemplatePath(path: string): boolean {
|
|
899
|
+
for (const dir of SINGLE_PR_TEMPLATE_DIRS) {
|
|
900
|
+
if (!path.startsWith(dir)) continue;
|
|
901
|
+
const rest = path.slice(dir.length);
|
|
902
|
+
if (!rest.includes("/") && isPullRequestTemplateFilename(rest)) return true;
|
|
903
|
+
}
|
|
904
|
+
for (const dir of MULTI_PR_TEMPLATE_DIRS) {
|
|
905
|
+
if (!path.startsWith(dir)) continue;
|
|
906
|
+
const rest = path.slice(dir.length);
|
|
907
|
+
if (rest && !rest.includes("/") && /\.(md|markdown|txt)$/i.test(rest)) {
|
|
908
|
+
return true;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return false;
|
|
912
|
+
}
|
|
@@ -482,13 +482,24 @@ export default function ChatView({ question }: Props) {
|
|
|
482
482
|
// and triggering an update loop.
|
|
483
483
|
const initialMessages = useMemo(
|
|
484
484
|
() =>
|
|
485
|
-
(question.messages ?? []).map((m) =>
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
485
|
+
(question.messages ?? []).map((m) => {
|
|
486
|
+
// Older saved messages persist as `{ content, parts: [] }` — the empty
|
|
487
|
+
// array is truthy so a plain `m.parts ?? fallback` keeps `parts`
|
|
488
|
+
// empty, which makes `getTextContent` return "" and breaks any feature
|
|
489
|
+
// that reads message text (annotations, copy-to-clipboard, etc.).
|
|
490
|
+
// Treat empty `parts` the same as missing and rebuild from `content`.
|
|
491
|
+
const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
|
|
492
|
+
const parts = hasParts
|
|
493
|
+
? (m.parts as UIMessage["parts"])
|
|
494
|
+
: ([
|
|
495
|
+
{ type: "text" as const, text: m.content ?? "" },
|
|
496
|
+
] as UIMessage["parts"]);
|
|
497
|
+
return {
|
|
498
|
+
id: m.id,
|
|
499
|
+
role: m.role as "user" | "assistant",
|
|
500
|
+
parts,
|
|
501
|
+
};
|
|
502
|
+
}),
|
|
492
503
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
493
504
|
[question.id],
|
|
494
505
|
);
|