@marcusrbrown/infra 0.12.2 → 0.12.3
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
|
@@ -68,6 +68,8 @@ export function getGatewayDeployEnv(): Record<string, string> {
|
|
|
68
68
|
GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS: process.env.GATEWAY_OPERATOR_OAUTH_ALLOWED_RETURN_PATHS ?? '',
|
|
69
69
|
GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS: process.env.GATEWAY_OPERATOR_OAUTH_STATE_TTL_MS ?? '',
|
|
70
70
|
GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS: process.env.GATEWAY_OPERATOR_OAUTH_MAX_OUTSTANDING_ATTEMPTS ?? '',
|
|
71
|
+
GATEWAY_VPC_IP: process.env.GATEWAY_VPC_IP ?? '',
|
|
72
|
+
DASHBOARD_VPC_IP: process.env.DASHBOARD_VPC_IP ?? '',
|
|
71
73
|
GATEWAY_IMAGE_DIGEST: process.env.GATEWAY_IMAGE_DIGEST ?? '',
|
|
72
74
|
WORKSPACE_IMAGE_DIGEST: process.env.WORKSPACE_IMAGE_DIGEST ?? '',
|
|
73
75
|
}
|
package/src/conventions.test.ts
CHANGED
|
@@ -440,6 +440,35 @@ describe('per-app invariants', () => {
|
|
|
440
440
|
// SHA verification is wired in and was not accidentally stripped.
|
|
441
441
|
expect(buildTs).toMatch(/const\s+EXPECTED_SHA256\s*=/)
|
|
442
442
|
})
|
|
443
|
+
|
|
444
|
+
it('deploy-gateway.yaml does NOT install doctl (firewall is provisioning-only, not deploy-time)', async () => {
|
|
445
|
+
const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
|
|
446
|
+
// The DO Cloud Firewall is created in provisioning (provision-droplet.ts), not in the deploy hot path.
|
|
447
|
+
// The deploy job must NOT install doctl — it no longer needs it.
|
|
448
|
+
expect(text).not.toMatch(/uses:\s+digitalocean\/action-doctl@[a-f0-9]{40}/)
|
|
449
|
+
expect(text).not.toContain('digitalocean/action-doctl')
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('deploy-gateway.yaml does NOT forward DIGITALOCEAN_ACCESS_TOKEN to the deploy step', async () => {
|
|
453
|
+
const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
|
|
454
|
+
// DIGITALOCEAN_ACCESS_TOKEN is only needed for provisioning, not for the deploy hot path.
|
|
455
|
+
// Verify it is not forwarded to the deploy step env block.
|
|
456
|
+
const deployStepIndex = text.indexOf('name: Deploy gateway')
|
|
457
|
+
expect(deployStepIndex).toBeGreaterThan(-1)
|
|
458
|
+
const afterDeployStep = text.slice(deployStepIndex)
|
|
459
|
+
expect(afterDeployStep).not.toContain('DIGITALOCEAN_ACCESS_TOKEN')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('deploy-gateway.yaml still forwards GATEWAY_VPC_IP and DASHBOARD_VPC_IP to the deploy step', async () => {
|
|
463
|
+
const text = await Bun.file(resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')).text()
|
|
464
|
+
// GATEWAY_VPC_IP and DASHBOARD_VPC_IP are still needed by deploy.ts for the compose VPC-IP
|
|
465
|
+
// publish and the DOCKER-USER iptables rule.
|
|
466
|
+
const deployStepIndex = text.indexOf('name: Deploy gateway')
|
|
467
|
+
expect(deployStepIndex).toBeGreaterThan(-1)
|
|
468
|
+
const afterDeployStep = text.slice(deployStepIndex)
|
|
469
|
+
expect(afterDeployStep).toContain('GATEWAY_VPC_IP')
|
|
470
|
+
expect(afterDeployStep).toContain('DASHBOARD_VPC_IP')
|
|
471
|
+
})
|
|
443
472
|
})
|
|
444
473
|
|
|
445
474
|
describe('findCrossOrgSecretsInherit', () => {
|
|
@@ -645,10 +674,9 @@ describe('dorny/paths-filter quantifier guard', () => {
|
|
|
645
674
|
})
|
|
646
675
|
})
|
|
647
676
|
|
|
648
|
-
// ───
|
|
677
|
+
// ─── operator auth/config secrets and tuning vars ────────────────────────────
|
|
649
678
|
//
|
|
650
|
-
//
|
|
651
|
-
// These assert the four new auth/config secrets and three optional tuning vars are
|
|
679
|
+
// These assert the four operator auth/config secrets and three optional tuning vars are
|
|
652
680
|
// wired through deploy-gateway.yaml (workflow_call.secrets + inputs + deploy step env),
|
|
653
681
|
// deploy.yaml (fan-out secrets + with inputs), and the CLI passthrough.
|
|
654
682
|
|
|
@@ -843,6 +871,24 @@ describe('CLI getGatewayDeployEnv: operator auth/config passthrough', () => {
|
|
|
843
871
|
Object.assign(process.env, origEnv)
|
|
844
872
|
}
|
|
845
873
|
})
|
|
874
|
+
|
|
875
|
+
it('getGatewayDeployEnv includes GATEWAY_VPC_IP and DASHBOARD_VPC_IP (but NOT DIGITALOCEAN_ACCESS_TOKEN)', async () => {
|
|
876
|
+
const {getGatewayDeployEnv} = await import('./commands/gateway/deploy')
|
|
877
|
+
const origEnv = {...process.env}
|
|
878
|
+
process.env.PATH = '/usr/bin'
|
|
879
|
+
process.env.HOME = '/home/test'
|
|
880
|
+
process.env.SSH_AUTH_SOCK = '/tmp/ssh.sock'
|
|
881
|
+
try {
|
|
882
|
+
const env = getGatewayDeployEnv()
|
|
883
|
+
// VPC IPs are still needed by deploy.ts for compose publish + DOCKER-USER rule
|
|
884
|
+
expect(env).toHaveProperty('GATEWAY_VPC_IP')
|
|
885
|
+
expect(env).toHaveProperty('DASHBOARD_VPC_IP')
|
|
886
|
+
// DIGITALOCEAN_ACCESS_TOKEN is provisioning-only — must NOT be in the deploy env
|
|
887
|
+
expect(env).not.toHaveProperty('DIGITALOCEAN_ACCESS_TOKEN')
|
|
888
|
+
} finally {
|
|
889
|
+
Object.assign(process.env, origEnv)
|
|
890
|
+
}
|
|
891
|
+
})
|
|
846
892
|
})
|
|
847
893
|
|
|
848
894
|
// ─── deploy.yaml: aggregate router passes operator secrets to deploy-gateway ──
|
|
@@ -988,3 +1034,147 @@ describe('deploy-gateway.yaml: optional operator secret declarations (issue 1)',
|
|
|
988
1034
|
expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_OPERATOR_PUBLIC_ORIGIN')
|
|
989
1035
|
})
|
|
990
1036
|
})
|
|
1037
|
+
|
|
1038
|
+
// ─── VPC bridge secrets: CI-vs-local parity ──────────────────────────────────
|
|
1039
|
+
//
|
|
1040
|
+
// GATEWAY_VPC_IP and DASHBOARD_VPC_IP are optional (all-or-none via getOperatorVpcState)
|
|
1041
|
+
// but must be forwarded through CI so the operator VPC bridge is not silently disabled
|
|
1042
|
+
// when the secrets are set. DIGITALOCEAN_ACCESS_TOKEN is provisioning-only and must NOT
|
|
1043
|
+
// be forwarded to the deploy step.
|
|
1044
|
+
// These tests assert the end-to-end wiring without adding them to the required
|
|
1045
|
+
// validation preflight (they are opt-in, like the operator listener vars).
|
|
1046
|
+
|
|
1047
|
+
const VPC_GATEWAY_SECRETS = ['GATEWAY_VPC_IP', 'DASHBOARD_VPC_IP'] as const
|
|
1048
|
+
const VPC_DASHBOARD_SECRETS = ['GATEWAY_VPC_IP'] as const
|
|
1049
|
+
|
|
1050
|
+
describe('deploy-gateway.yaml: VPC bridge secrets wired end-to-end', () => {
|
|
1051
|
+
const DEPLOY_GATEWAY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-gateway.yaml')
|
|
1052
|
+
|
|
1053
|
+
for (const secret of VPC_GATEWAY_SECRETS) {
|
|
1054
|
+
it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
|
|
1055
|
+
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1056
|
+
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
1057
|
+
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
1058
|
+
expect(secrets).toHaveProperty(secret)
|
|
1059
|
+
expect(secrets[secret]?.required).toBe(false)
|
|
1060
|
+
})
|
|
1061
|
+
|
|
1062
|
+
it(`Deploy gateway step env forwards ${secret} from secrets context`, async () => {
|
|
1063
|
+
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1064
|
+
const parsed = parseYaml(text) as {
|
|
1065
|
+
jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
|
|
1066
|
+
}
|
|
1067
|
+
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
1068
|
+
const deployStep = steps.find(s => s.name === 'Deploy gateway')
|
|
1069
|
+
expect(deployStep).toBeDefined()
|
|
1070
|
+
expect(deployStep?.env).toHaveProperty(secret)
|
|
1071
|
+
expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
|
|
1072
|
+
})
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
it('VPC bridge secrets are NOT in the required-secret validation step env', async () => {
|
|
1076
|
+
const text = await Bun.file(DEPLOY_GATEWAY_WORKFLOW).text()
|
|
1077
|
+
const parsed = parseYaml(text) as {
|
|
1078
|
+
jobs?: {'deploy-gateway'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
|
|
1079
|
+
}
|
|
1080
|
+
const steps = parsed?.jobs?.['deploy-gateway']?.steps ?? []
|
|
1081
|
+
const validateStep = steps.find(s => s.name === 'Validate required secrets')
|
|
1082
|
+
expect(validateStep).toBeDefined()
|
|
1083
|
+
for (const secret of VPC_GATEWAY_SECRETS) {
|
|
1084
|
+
expect(validateStep?.env ?? {}).not.toHaveProperty(secret)
|
|
1085
|
+
}
|
|
1086
|
+
})
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
describe('deploy-dashboard.yaml: VPC bridge secret wired end-to-end', () => {
|
|
1090
|
+
const DEPLOY_DASHBOARD_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy-dashboard.yaml')
|
|
1091
|
+
|
|
1092
|
+
for (const secret of VPC_DASHBOARD_SECRETS) {
|
|
1093
|
+
it(`workflow_call.secrets declares ${secret} as optional (required: false)`, async () => {
|
|
1094
|
+
const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
|
|
1095
|
+
const parsed = parseYaml(text) as {on?: {workflow_call?: {secrets?: Record<string, {required?: boolean}>}}}
|
|
1096
|
+
const secrets = parsed?.on?.workflow_call?.secrets ?? {}
|
|
1097
|
+
expect(secrets).toHaveProperty(secret)
|
|
1098
|
+
expect(secrets[secret]?.required).toBe(false)
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
it(`Deploy step env forwards ${secret} from secrets context`, async () => {
|
|
1102
|
+
const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
|
|
1103
|
+
const parsed = parseYaml(text) as {
|
|
1104
|
+
jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>}[]}}
|
|
1105
|
+
}
|
|
1106
|
+
const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
|
|
1107
|
+
const deployStep = steps.find(s => s.name === 'Deploy')
|
|
1108
|
+
expect(deployStep).toBeDefined()
|
|
1109
|
+
expect(deployStep?.env).toHaveProperty(secret)
|
|
1110
|
+
expect(deployStep?.env?.[secret]).toMatch(/\$\{\{\s*secrets\./)
|
|
1111
|
+
})
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
it('GATEWAY_VPC_IP is NOT in the required-secret validation step env', async () => {
|
|
1115
|
+
const text = await Bun.file(DEPLOY_DASHBOARD_WORKFLOW).text()
|
|
1116
|
+
const parsed = parseYaml(text) as {
|
|
1117
|
+
jobs?: {'deploy-dashboard'?: {steps?: {name?: string; env?: Record<string, string>; run?: string}[]}}
|
|
1118
|
+
}
|
|
1119
|
+
const steps = parsed?.jobs?.['deploy-dashboard']?.steps ?? []
|
|
1120
|
+
const validateStep = steps.find(s => s.name === 'Validate required secrets')
|
|
1121
|
+
expect(validateStep).toBeDefined()
|
|
1122
|
+
expect(validateStep?.env ?? {}).not.toHaveProperty('GATEWAY_VPC_IP')
|
|
1123
|
+
})
|
|
1124
|
+
})
|
|
1125
|
+
|
|
1126
|
+
describe('deploy.yaml: aggregate router forwards VPC bridge secrets', () => {
|
|
1127
|
+
const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
|
|
1128
|
+
|
|
1129
|
+
for (const secret of VPC_GATEWAY_SECRETS) {
|
|
1130
|
+
it(`deploy-gateway job secrets block passes ${secret}`, async () => {
|
|
1131
|
+
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1132
|
+
const parsed = parseYaml(text) as {
|
|
1133
|
+
jobs?: {'deploy-gateway'?: {secrets?: Record<string, string>}}
|
|
1134
|
+
}
|
|
1135
|
+
const secrets = parsed?.jobs?.['deploy-gateway']?.secrets ?? {}
|
|
1136
|
+
expect(secrets).toHaveProperty(secret)
|
|
1137
|
+
expect(secrets[secret]).toContain(secret)
|
|
1138
|
+
})
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
for (const secret of VPC_DASHBOARD_SECRETS) {
|
|
1142
|
+
it(`deploy-dashboard job secrets block passes ${secret}`, async () => {
|
|
1143
|
+
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1144
|
+
const parsed = parseYaml(text) as {
|
|
1145
|
+
jobs?: {'deploy-dashboard'?: {secrets?: Record<string, string>}}
|
|
1146
|
+
}
|
|
1147
|
+
const secrets = parsed?.jobs?.['deploy-dashboard']?.secrets ?? {}
|
|
1148
|
+
expect(secrets).toHaveProperty(secret)
|
|
1149
|
+
expect(secrets[secret]).toContain(secret)
|
|
1150
|
+
})
|
|
1151
|
+
}
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
// ─── deploy.yaml: deploy-dashboard is decoupled from deploy-gateway ──────────
|
|
1155
|
+
//
|
|
1156
|
+
// The dashboard is a standalone deployment. A gateway failure or slowness must
|
|
1157
|
+
// not block dashboard deploys. deploy-dashboard must NOT declare needs: deploy-gateway,
|
|
1158
|
+
// and its if: must NOT reference needs.deploy-gateway — locking in the decoupling
|
|
1159
|
+
// so it cannot silently regress.
|
|
1160
|
+
|
|
1161
|
+
describe('deploy.yaml: deploy-dashboard is decoupled from deploy-gateway', () => {
|
|
1162
|
+
const DEPLOY_WORKFLOW = resolve(REPO_ROOT, '.github/workflows/deploy.yaml')
|
|
1163
|
+
|
|
1164
|
+
it('deploy-dashboard needs: does NOT include deploy-gateway (standalone job)', async () => {
|
|
1165
|
+
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1166
|
+
const parsed = parseYaml(text) as {
|
|
1167
|
+
jobs?: {'deploy-dashboard'?: {needs?: string | string[]}}
|
|
1168
|
+
}
|
|
1169
|
+
const needs = parsed?.jobs?.['deploy-dashboard']?.needs ?? []
|
|
1170
|
+
const needsArr = Array.isArray(needs) ? needs : [needs]
|
|
1171
|
+
expect(needsArr).not.toContain('deploy-gateway')
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it('deploy-dashboard if: does NOT reference needs.deploy-gateway (no gateway coupling)', async () => {
|
|
1175
|
+
const text = await Bun.file(DEPLOY_WORKFLOW).text()
|
|
1176
|
+
// The if: expression must not reference needs.deploy-gateway in any form —
|
|
1177
|
+
// gateway state must not gate the dashboard deploy.
|
|
1178
|
+
expect(text).not.toMatch(/needs\.deploy-gateway/)
|
|
1179
|
+
})
|
|
1180
|
+
})
|