@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -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
  }
@@ -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
- // ─── Unit 5: operator auth/config secrets and tuning vars ────────────────────
677
+ // ─── operator auth/config secrets and tuning vars ────────────────────────────
649
678
  //
650
- // RED tests for Unit 5 of 2026-06-18-002-feat-gateway-operator-auth-config-plan.md
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
+ })