@ankhorage/infra 0.4.2 → 1.0.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/dist/adapters/minikube/auth/supabase/index.d.ts.map +1 -1
  4. package/dist/adapters/minikube/auth/supabase/index.js +14 -39
  5. package/dist/adapters/minikube/auth/supabase/index.js.map +1 -1
  6. package/dist/adapters/minikube/auth/supabase/profile.d.ts +0 -1
  7. package/dist/adapters/minikube/auth/supabase/profile.d.ts.map +1 -1
  8. package/dist/adapters/minikube/auth/supabase/profile.js +1 -1
  9. package/dist/adapters/minikube/auth/supabase/profile.js.map +1 -1
  10. package/dist/adapters/minikube/authz/cerbos/index.d.ts.map +1 -1
  11. package/dist/adapters/minikube/authz/cerbos/index.js +22 -2
  12. package/dist/adapters/minikube/authz/cerbos/index.js.map +1 -1
  13. package/dist/adapters/minikube/base/index.d.ts +5 -2
  14. package/dist/adapters/minikube/base/index.d.ts.map +1 -1
  15. package/dist/adapters/minikube/base/index.js +2764 -1841
  16. package/dist/adapters/minikube/base/index.js.map +1 -1
  17. package/dist/adapters/minikube/contracts.d.ts +25 -0
  18. package/dist/adapters/minikube/contracts.d.ts.map +1 -1
  19. package/dist/adapters/minikube/contracts.js +1 -0
  20. package/dist/adapters/minikube/contracts.js.map +1 -1
  21. package/dist/adapters/minikube/index.d.ts.map +1 -1
  22. package/dist/adapters/minikube/index.js +18 -33
  23. package/dist/adapters/minikube/index.js.map +1 -1
  24. package/dist/adapters/minikube/secrets/supabase-vault/index.d.ts.map +1 -1
  25. package/dist/adapters/minikube/secrets/supabase-vault/index.js +49 -6
  26. package/dist/adapters/minikube/secrets/supabase-vault/index.js.map +1 -1
  27. package/dist/adapters/minikube/storage/supabase/index.d.ts.map +1 -1
  28. package/dist/adapters/minikube/storage/supabase/index.js +2 -1
  29. package/dist/adapters/minikube/storage/supabase/index.js.map +1 -1
  30. package/package.json +4 -1
  31. package/src/adapters/minikube/README.md +46 -38
  32. package/src/adapters/minikube/auth/supabase/index.ts +14 -40
  33. package/src/adapters/minikube/auth/supabase/profile.ts +1 -1
  34. package/src/adapters/minikube/authz/cerbos/index.ts +25 -3
  35. package/src/adapters/minikube/base/index.test.ts +399 -673
  36. package/src/adapters/minikube/base/index.ts +2969 -1874
  37. package/src/adapters/minikube/base/twoAppIsolation.integration.test.ts +636 -0
  38. package/src/adapters/minikube/contracts.ts +29 -0
  39. package/src/adapters/minikube/index.ts +18 -44
  40. package/src/adapters/minikube/secrets/index.test.ts +15 -7
  41. package/src/adapters/minikube/secrets/supabase-vault/index.ts +53 -6
  42. package/src/adapters/minikube/storage/supabase/index.ts +1 -5
  43. package/src/apis.test.ts +2 -1
  44. package/src/index.test.ts +64 -577
  45. package/src/minikubeImageLifecycle.test.ts +4 -3
@@ -1,429 +1,452 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
-
6
1
  import { describe, expect, test } from 'bun:test';
7
2
 
3
+ import { generateInfrastructure } from '../../../index';
4
+ import { createAppManifest } from '../../../testSupport';
8
5
  import type { InfraManifestInput } from '../../../types';
9
- import { generateMinikubeBaseArtifacts } from './index';
10
-
11
- describe('generateMinikubeBaseArtifacts Supabase local ports', () => {
12
- test('keeps legacy my-app defaults while allocating a different range for another app', () => {
13
- const myAppEnv = getGeneratedFileContent('my-app', 'infra/minikube/.env.example');
14
- const chessEnv = getGeneratedFileContent('chess', 'infra/minikube/.env.example');
15
-
16
- expect(myAppEnv).toContain('SUPABASE_LOCAL_PORT_BASE=55320');
17
- expect(myAppEnv).toContain('SUPABASE_LOCAL_SHADOW_PORT=55320');
18
- expect(myAppEnv).toContain('SUPABASE_LOCAL_API_PORT=55321');
19
- expect(myAppEnv).toContain('SUPABASE_LOCAL_DB_PORT=55322');
20
- expect(myAppEnv).toContain('SUPABASE_LOCAL_STUDIO_PORT=55323');
21
- expect(myAppEnv).toContain('SUPABASE_LOCAL_INBUCKET_PORT=55324');
22
- expect(myAppEnv).toContain('SUPABASE_LOCAL_ANALYTICS_PORT=55325');
23
-
24
- expect(chessEnv).toContain('SUPABASE_LOCAL_PORT_BASE=62200');
25
- expect(chessEnv).toContain('SUPABASE_LOCAL_SHADOW_PORT=62200');
26
- expect(chessEnv).toContain('SUPABASE_LOCAL_API_PORT=62201');
27
- expect(chessEnv).toContain('SUPABASE_LOCAL_DB_PORT=62202');
28
- expect(chessEnv).toContain('SUPABASE_LOCAL_STUDIO_PORT=62203');
29
- expect(chessEnv).toContain('SUPABASE_LOCAL_INBUCKET_PORT=62204');
30
- expect(chessEnv).toContain('SUPABASE_LOCAL_ANALYTICS_PORT=62205');
31
- expect(chessEnv).not.toContain('SUPABASE_LOCAL_DB_PORT=55322');
32
- });
33
-
34
- test('generates bootstrap logic that derives ports from the app port base and preflights collisions', () => {
35
- const script = getGeneratedFileContent('chess', 'infra/minikube/scripts/supabase-local-env.sh');
36
6
 
37
- expect(script).toContain('SUPABASE_LOCAL_PORT_BASE="${SUPABASE_LOCAL_PORT_BASE:-62200}"');
38
- expect(script).toContain(
39
- 'SUPABASE_LOCAL_DB_PORT="${SUPABASE_LOCAL_DB_PORT:-$((SUPABASE_LOCAL_PORT_BASE + 2))}"',
40
- );
41
- expect(script).toContain('assert_supabase_local_ports_available');
42
- expect(script).toContain('Supabase local port preflight failed for project');
43
- expect(script).toContain(
44
- 'Override SUPABASE_LOCAL_PORT_BASE or the specific SUPABASE_LOCAL_*_PORT values',
45
- );
46
- expect(script).toContain(
47
- 'upsert_env "${ENV_FILE}" "SUPABASE_LOCAL_PORT_BASE" "${SUPABASE_LOCAL_PORT_BASE}"',
48
- );
7
+ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
8
+ test('uses the canonical app slug as the only Minikube profile identity', () => {
9
+ const result = generateInfrastructure(createSupabaseManifest(), {
10
+ namespaceHint: 'ignored',
11
+ appManifest: createAppManifest('chess'),
12
+ });
13
+ const envExample = getFile(result.files, 'infra/minikube/.env.example');
14
+ const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
15
+ const downScript = getFile(result.files, 'infra/minikube/scripts/down.sh');
16
+ const destroyScript = getFile(result.files, 'infra/minikube/scripts/destroy.sh');
17
+
18
+ expect(envExample).toContain('ANKH_APP_SLUG=chess');
19
+ expect(envExample).not.toContain('MINIKUBE_PROFILE=');
20
+ expect(upScript).toContain('PROFILE="${ANKH_APP_SLUG:-chess}"');
21
+ expect(upScript).toContain('minikube start -p "${PROFILE}"');
22
+ expect(upScript).not.toContain('PROFILE="${MINIKUBE_PROFILE:-minikube}"');
23
+ expect(downScript).toContain('minikube stop -p "${PROFILE}"');
24
+ expect(destroyScript).toContain('minikube delete -p "${PROFILE}"');
49
25
  });
50
26
 
51
- test('uses app project identity for Supabase ports independently from namespace', () => {
52
- const scannerEnv = getGeneratedFileContent(
53
- 'local-example-test',
54
- 'infra/minikube/.env.example',
55
- {},
56
- 'scanner',
57
- );
58
- const scannerNamespace = getGeneratedFileContent(
59
- 'local-example-test',
60
- 'infra/minikube/k8s/namespace.yaml',
61
- {},
62
- 'scanner',
63
- );
64
- const chessEnv = getGeneratedFileContent(
65
- 'local-example-test',
66
- 'infra/minikube/.env.example',
67
- {},
68
- 'chess',
69
- );
70
-
71
- expect(scannerNamespace).toContain('name: local-example-test');
72
- expect(scannerEnv).toContain('SUPABASE_LOCAL_PORT_BASE=64020');
73
- expect(chessEnv).toContain('SUPABASE_LOCAL_PORT_BASE=62200');
74
- expect(scannerEnv).not.toContain('SUPABASE_LOCAL_PORT_BASE=62200');
27
+ test('generates fixed app and supabase namespaces instead of slug namespaces', () => {
28
+ const result = generateInfrastructure(createSupabaseManifest(), {
29
+ appManifest: createAppManifest('uwm'),
30
+ });
31
+ const paths = result.files.map((file) => file.path);
32
+ const appNamespace = getFile(result.files, 'infra/minikube/k8s/namespaces/app.yaml');
33
+ const supabaseNamespace = getFile(result.files, 'infra/minikube/k8s/namespaces/supabase.yaml');
34
+ const appDeployment = getFile(result.files, 'infra/minikube/k8s/app/deployment.yaml');
35
+ const postgres = getFile(result.files, 'infra/minikube/k8s/supabase/postgres.yaml');
36
+
37
+ expect(paths).toContain('infra/minikube/k8s/namespaces/app.yaml');
38
+ expect(paths).toContain('infra/minikube/k8s/namespaces/supabase.yaml');
39
+ expect(paths).not.toContain('infra/minikube/k8s/namespace.yaml');
40
+ expect(appNamespace).toContain('name: app');
41
+ expect(supabaseNamespace).toContain('name: supabase');
42
+ expect(appDeployment).toContain('namespace: app');
43
+ expect(appDeployment).toContain('name: supabase-public-runtime');
44
+ expect(postgres).toContain('namespace: supabase');
75
45
  });
76
46
 
77
- test('generates canonical Supabase project identity checks', () => {
78
- const script = getGeneratedFileContent(
79
- 'scanner',
47
+ test('removes host-level Supabase runtime ownership from generated files', () => {
48
+ const result = generateInfrastructure(createSupabaseManifest(), {
49
+ appManifest: createAppManifest('scanner'),
50
+ });
51
+ const serialized = JSON.stringify(result.files);
52
+
53
+ expect(result.files.map((file) => file.path)).not.toContain(
80
54
  'infra/minikube/scripts/supabase-local-env.sh',
81
55
  );
82
- const status = getGeneratedFileContent('scanner', 'infra/minikube/scripts/status.sh');
83
-
84
- expect(script).toContain('EXPECTED_SUPABASE_PROJECT_ID="scanner"');
85
- expect(script).toContain('write_supabase_project_identity_for_new_config');
86
- expect(script).toContain('validate_supabase_project_identity');
87
- expect(script).toContain('project_id = "{expected}"');
88
- expect(script).toContain(
89
- 'The existing local Supabase project belongs to a different identity.',
90
- );
91
- expect(script).toContain(
92
- 'Destroy the invalid local stack and its project-owned resources, then run Infra Up again.',
93
- );
94
- expect(script).toContain(
95
- 'supabase --workdir "${SUPABASE_PROJECT_DIR}" init --yes >/dev/null\n write_supabase_project_identity_for_new_config',
96
- );
97
- expect(script).toContain('validate_supabase_project_identity\nconfigure_supabase_local_ports');
98
- expect(script).toContain(
99
- 'validate_supabase_project_identity\n assert_supabase_local_ports_available',
100
- );
101
- expect(script).toContain('validate_supabase_project_identity\nrun_checked_command');
102
- expect(script).not.toContain('basename "${APP_SOURCE_DIR}"');
103
- expect(script).not.toContain('_minikube');
104
-
105
- expect(status).toContain('EXPECTED_SUPABASE_PROJECT_ID="scanner"');
106
- expect(status).toContain('reject_supabase_project_id_override');
107
- expect(status).toContain('validate_supabase_project_identity');
108
- expect(status).toContain('Supabase project identity mismatch.');
109
- expect(status).toContain('Destroy the invalid local stack and run Infra Up again.');
110
- expect(status).not.toContain('write_supabase_project_identity_for_new_config');
111
- expect(status).not.toContain('configure_supabase_local_ports');
112
- expect(status).not.toContain('_minikube');
56
+ expect(serialized).not.toContain('supabase start');
57
+ expect(serialized).not.toContain('supabase stop');
58
+ expect(serialized).not.toContain('supabase status');
59
+ expect(serialized).not.toContain('SUPABASE_PROJECT_DIR');
60
+ expect(serialized).not.toContain('SUPABASE_LOCAL_PORT');
61
+ expect(serialized).not.toContain('supabase_*_');
62
+ expect(serialized).not.toContain('supabase-auth-secrets');
113
63
  });
114
64
 
115
- test('patches only a newly initialized Supabase config to the canonical project identity', () => {
116
- const run = runGeneratedSupabaseScript({
117
- projectId: 'scanner',
118
- existingConfig: null,
65
+ test('keeps Supabase migration history but executes against the Kubernetes DB URL', () => {
66
+ const result = generateInfrastructure(createSupabaseManifest(), {
67
+ appManifest: createAppManifest('scanner'),
119
68
  });
120
-
121
- expect(run.status).toBe(0);
122
- expect(readFileSync(run.configPath, 'utf-8')).toContain('project_id = "scanner"');
123
- expect(readFileSync(run.supabaseLogPath, 'utf-8')).toContain(
124
- '--workdir ' + run.root + ' init --yes',
125
- );
69
+ const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
70
+ const readme = getFile(result.files, 'infra/minikube/README.md');
71
+
72
+ expect(upScript).toContain('supabase migration up --db-url "${SUPABASE_DB_URL}"');
73
+ expect(upScript).toContain('export SUPABASE_DB_URL PGSSLMODE=disable');
74
+ expect(upScript).not.toContain('migration up --local');
75
+ expect(upScript).toContain('cd "${ROOT_DIR}"');
76
+ expect(upScript).toContain('set_required_env_default POSTGRES_PASSWORD "$(random_hex 32)"');
77
+ expect(upScript).not.toContain('set_env_default POSTGRES_PASSWORD "$(random_base64');
78
+ expect(upScript).toContain(
79
+ 'set_required_env_default SUPABASE_DB_URL "postgres://postgres:${POSTGRES_PASSWORD}@127.0.0.1:${SUPABASE_DB_FORWARD_LOCAL_PORT}/postgres?sslmode=disable"',
80
+ );
81
+ expect(upScript).toContain('bootstrap_supabase_database');
82
+ expect(readme).toContain('Migration authoring/history remains Supabase');
126
83
  });
127
84
 
128
- test('rejects an existing Supabase config with a different project identity before lifecycle work', () => {
129
- const run = runGeneratedSupabaseScript({
130
- projectId: 'scanner',
131
- existingConfig: 'project_id = "minikube"\n[api]\nport = 54321\n',
85
+ test('lists and generates Supabase Kubernetes bootstrap and gateway contract files', () => {
86
+ const result = generateInfrastructure(createSupabaseManifest(), {
87
+ appManifest: createAppManifest('scanner'),
132
88
  });
133
-
134
- expect(run.status).not.toBe(0);
135
- expect(run.combinedOutput).toContain('Supabase project identity mismatch.');
136
- expect(run.combinedOutput).toContain('Expected "scanner", found "minikube".');
137
- expect(run.combinedOutput).toContain(
138
- 'Destroy the invalid local stack and its project-owned resources, then run Infra Up again.',
139
- );
140
- expect(readFileSync(run.configPath, 'utf-8')).toContain('project_id = "minikube"');
141
- expect(readFileSync(run.supabaseLogPath, 'utf-8')).not.toContain('--workdir');
89
+ const paths = result.files.map((file) => file.path);
90
+ const readme = getFile(result.files, 'infra/minikube/README.md');
91
+ const kustomization = getFile(result.files, 'infra/minikube/k8s/kustomization.yaml');
92
+ const bootstrap = getFile(result.files, 'infra/minikube/k8s/supabase/bootstrap.sql');
93
+ const postgresInit = getFile(
94
+ result.files,
95
+ 'infra/minikube/k8s/supabase/postgres.init.configmap.yaml',
96
+ );
97
+ const postgres = getFile(result.files, 'infra/minikube/k8s/supabase/postgres.yaml');
98
+ const auth = getFile(result.files, 'infra/minikube/k8s/supabase/auth.yaml');
99
+ const envExample = getFile(result.files, 'infra/minikube/.env.example');
100
+ const gatewayTemplate = getFile(
101
+ result.files,
102
+ 'infra/minikube/k8s/supabase/gateway.template.yml',
103
+ );
104
+ const gateway = getFile(result.files, 'infra/minikube/k8s/supabase/gateway.yaml');
105
+ const storage = getFile(result.files, 'infra/minikube/k8s/supabase/storage.yaml');
106
+ const kongEntrypoint = getFile(result.files, 'infra/minikube/k8s/supabase/kong-entrypoint.sh');
107
+
108
+ expect(paths).toContain('infra/minikube/k8s/supabase/bootstrap.sql');
109
+ expect(paths).toContain('infra/minikube/k8s/supabase/postgres.init.configmap.yaml');
110
+ expect(paths).toContain('infra/minikube/k8s/supabase/gateway.template.yml');
111
+ expect(paths).toContain('infra/minikube/k8s/supabase/kong-entrypoint.sh');
112
+ expect(paths).not.toContain('infra/minikube/k8s/supabase/secrets.yaml');
113
+ expect(readme).toContain('supabase/bootstrap.sql');
114
+ expect(readme).toContain('supabase/postgres.init.configmap.yaml');
115
+ expect(readme).toContain('supabase/gateway.template.yml');
116
+ expect(readme).toContain('supabase/kong-entrypoint.sh');
117
+ expect(kustomization).not.toContain('supabase/secrets.yaml');
118
+ expect(kustomization).not.toContain('gateway.configmap.yaml');
119
+ expect(kustomization).toContain('supabase/postgres.init.configmap.yaml');
120
+ expect(bootstrap).toContain('CREATE SCHEMA IF NOT EXISTS auth');
121
+ expect(bootstrap).toContain(
122
+ 'GRANT anon, authenticated, service_role TO supabase_storage_admin',
123
+ );
124
+ expect(postgresInit).toContain('alter role postgres with superuser');
125
+ expect(postgresInit).toContain('grant pg_read_server_files to supabase_admin');
126
+ expect(postgresInit).toContain('grant execute on function pg_read_file(text) to public');
127
+ expect(postgres).toContain('name: POSTGRES_USER');
128
+ expect(postgres).toContain('value: postgres');
129
+ expect(postgres).toContain('name: PGPORT');
130
+ expect(postgres).toContain(
131
+ 'mountPath: /docker-entrypoint-initdb.d/99-ankhorage-local-dev-extensions.sql',
132
+ );
133
+ expect(postgres).toContain('config_file=/etc/postgresql/postgresql.conf');
134
+ expect(gatewayTemplate).toContain('keyauth_credentials');
135
+ expect(gatewayTemplate).toContain('request-transformer');
136
+ expect(kongEntrypoint).toContain('LUA_AUTH_EXPR');
137
+ expect(auth).toContain('name: GOTRUE_JWT_EXP');
138
+ expect(auth).toContain('key: JWT_EXPIRY');
139
+ expect(auth).toContain(
140
+ 'postgres://supabase_auth_admin:$(POSTGRES_PASSWORD)@postgres.supabase.svc.cluster.local:5432/postgres?search_path=auth&sslmode=disable',
141
+ );
142
+ expect(storage).toContain(
143
+ 'postgres://supabase_storage_admin:$(POSTGRES_PASSWORD)@postgres.supabase.svc.cluster.local:5432/postgres?search_path=storage&sslmode=disable',
144
+ );
145
+ expect(envExample).toContain('PGRST_DB_SCHEMAS=public,storage,graphql_public');
146
+ expect(auth).toContain('name: GOTRUE_EXTERNAL_EMAIL_ENABLED');
147
+ expect(auth).toContain('key: ENABLE_EMAIL_SIGNUP');
148
+ expect(auth).toContain('name: GOTRUE_SMTP_HOST');
149
+ expect(auth).toContain('name: GOTRUE_SMTP_PORT');
150
+ expect(auth).toContain('name: GOTRUE_SMTP_USER');
151
+ expect(auth).toContain('name: GOTRUE_SMTP_PASS');
152
+ expect(gatewayTemplate).toContain('name: realtime-v1-rest-openapi');
153
+ expect(gatewayTemplate).toContain('paths: [/realtime/v1/api/openapi]');
154
+ expect(gatewayTemplate).toContain('name: realtime-v1-rest-tenants');
155
+ expect(gatewayTemplate).toContain('paths: [/realtime/v1/api/tenants]');
156
+ expect(gatewayTemplate).toContain('name: request-termination');
157
+ expect(gatewayTemplate).toContain('status_code: 403');
158
+ expect(gateway).toContain('mountPath: /home/kong/temp.yml');
159
+ expect(gateway).toContain('subPath: temp.yml');
160
+ expect(gateway).toContain('mountPath: /home/kong/kong-entrypoint.sh');
161
+ expect(gateway).toContain('command: ["kong", "health"]');
142
162
  });
143
163
 
144
- test('rejects an existing Supabase config with missing top-level project identity', () => {
145
- const run = runGeneratedSupabaseScript({
146
- projectId: 'scanner',
147
- existingConfig: '[api]\nport = 54321\n',
164
+ test('defines reset separately from down and destroy', () => {
165
+ const result = generateInfrastructure(createSupabaseManifest(), {
166
+ appManifest: createAppManifest('uwm'),
148
167
  });
149
-
150
- expect(run.status).not.toBe(0);
151
- expect(run.combinedOutput).toContain('Expected "scanner", found missing.');
152
- expect(readFileSync(run.configPath, 'utf-8')).not.toContain('project_id = "scanner"');
153
- expect(readFileSync(run.supabaseLogPath, 'utf-8')).not.toContain('--workdir');
168
+ const resetScript = getFile(result.files, 'infra/minikube/scripts/reset.sh');
169
+ const downScript = getFile(result.files, 'infra/minikube/scripts/down.sh');
170
+
171
+ expect(resetScript).toContain('ANKH_RESET_CONFIRM');
172
+ expect(resetScript).toContain('delete namespace app');
173
+ expect(resetScript).toContain('delete namespace supabase');
174
+ expect(resetScript).toContain('does not delete the Minikube profile');
175
+ expect(downScript).toContain('minikube stop -p "${PROFILE}"');
176
+ expect(downScript).not.toContain('delete namespace');
154
177
  });
155
178
 
156
- test('rejects ambient SUPABASE_PROJECT_ID before Supabase CLI operations', () => {
157
- const run = runGeneratedSupabaseScript({
158
- projectId: 'scanner',
159
- existingConfig: 'project_id = "scanner"\n[api]\nport = 54321\n',
160
- env: {
161
- SUPABASE_PROJECT_ID: 'minikube',
179
+ test('keeps reset scoped to app namespace when Supabase is not generated', () => {
180
+ const result = generateInfrastructure(
181
+ {
182
+ deployment: {
183
+ target: 'minikube',
184
+ monitoring: false,
185
+ },
186
+ },
187
+ {
188
+ appManifest: createAppManifest('plain'),
162
189
  },
163
- });
164
-
165
- expect(run.status).not.toBe(0);
166
- expect(run.combinedOutput).toContain(
167
- 'SUPABASE_PROJECT_ID must not be set for generated local Infra scripts.',
168
190
  );
169
- expect(readFileSync(run.supabaseLogPath, 'utf-8')).not.toContain('--workdir');
170
- });
191
+ const paths = result.files.map((file) => file.path);
192
+ const resetScript = getFile(result.files, 'infra/minikube/scripts/reset.sh');
193
+ const statusScript = getFile(result.files, 'infra/minikube/scripts/status.sh');
171
194
 
172
- test('rejects empty Supabase project identity before Supabase CLI operations', () => {
173
- const run = runGeneratedSupabaseScript({
174
- projectId: 'plain',
175
- supabaseProjectId: null,
176
- existingConfig: null,
177
- });
195
+ expect(paths).not.toContain('infra/minikube/k8s/namespaces/supabase.yaml');
196
+ expect(resetScript).toContain('delete namespace app');
197
+ expect(resetScript).not.toContain('delete namespace supabase');
198
+ expect(resetScript).not.toContain('namespaces/supabase.yaml');
199
+ expect(statusScript).toContain('for namespace in app; do');
200
+ });
178
201
 
179
- expect(run.status).not.toBe(0);
180
- expect(run.combinedOutput).toContain(
181
- 'Cannot run local Supabase infrastructure: expected Supabase project identity is empty.',
202
+ test('places standalone provider workloads in provider-owned namespaces', () => {
203
+ const result = generateInfrastructure(
204
+ {
205
+ ...createSupabaseManifest(),
206
+ auth: {
207
+ scope: 'global',
208
+ provider: 'supabase',
209
+ authorization: {
210
+ engine: 'cerbos',
211
+ kind: 'RBAC',
212
+ },
213
+ },
214
+ },
215
+ {
216
+ appManifest: createAppManifest('authz-app'),
217
+ },
182
218
  );
183
- expect(readFileSync(run.supabaseLogPath, 'utf-8')).not.toContain('--workdir');
184
- expect(readFileSync(run.supabaseLogPath, 'utf-8')).not.toContain('init --yes');
185
- expect(() => readFileSync(run.configPath, 'utf-8')).toThrow();
219
+ const paths = result.files.map((file) => file.path);
220
+ const kustomization = getFile(result.files, 'infra/minikube/k8s/kustomization.yaml');
221
+ const readme = getFile(result.files, 'infra/minikube/README.md');
222
+ const cerbosDeployment = getFile(
223
+ result.files,
224
+ 'infra/minikube/k8s/authz/cerbos/cerbos.deployment.yaml',
225
+ );
226
+ const envExample = getFile(result.files, 'infra/minikube/.env.example');
227
+ const resetScript = getFile(result.files, 'infra/minikube/scripts/reset.sh');
228
+ const statusScript = getFile(result.files, 'infra/minikube/scripts/status.sh');
229
+ const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
230
+
231
+ expect(paths).toContain('infra/minikube/k8s/namespaces/cerbos.yaml');
232
+ expect(kustomization).toContain('namespaces/cerbos.yaml');
233
+ expect(readme).toContain('- Provider namespaces: `cerbos`');
234
+ expect(readme).toContain('## Provider Lifecycle Contributions');
235
+ expect(readme).toContain('`cerbos`: namespace `cerbos`');
236
+ expect(readme).toContain('`deployment/cerbos` in namespace `cerbos`');
237
+ expect(readme).toContain('`http` = `http://cerbos.cerbos.svc.cluster.local:3592`');
238
+ expect(cerbosDeployment).toContain('namespace: cerbos');
239
+ expect(cerbosDeployment).not.toContain('namespace: app');
240
+ expect(envExample).toContain('CERBOS_URL=http://cerbos.cerbos.svc.cluster.local:3592');
241
+ expect(resetScript).toContain('delete namespace cerbos');
242
+ expect(upScript).toContain('wait_for_provider_readiness');
243
+ expect(upScript).toContain('run_provider_migrations');
244
+ expect(upScript).toContain('run_provider_reconciliation');
245
+ expect(upScript).toContain('rollout status "deployment/cerbos" --timeout=180s');
246
+ expect(upScript.indexOf('kubectl --context "${PROFILE}" apply -k "${K8S_DIR}"')).toBeLessThan(
247
+ upScript.lastIndexOf('wait_for_provider_readiness'),
248
+ );
249
+ expect(statusScript).toContain('for namespace in app supabase cerbos; do');
250
+ expect(statusScript).toContain(
251
+ '- provider cerbos endpoint/http: http://cerbos.cerbos.svc.cluster.local:3592',
252
+ );
253
+ expect(statusScript).toContain('- provider cerbos/cerbos: ready');
186
254
  });
187
255
 
188
- test('requires python before Supabase init mutates the workdir', () => {
189
- const run = runGeneratedSupabaseScript({
190
- projectId: 'scanner',
191
- existingConfig: null,
192
- withoutPython: true,
256
+ test('uses generated Supabase runtime ownership as an immutable script decision', () => {
257
+ const result = generateInfrastructure(createSupabaseManifest(), {
258
+ appManifest: createAppManifest('scanner'),
193
259
  });
260
+ const envExample = getFile(result.files, 'infra/minikube/.env.example');
261
+ const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
194
262
 
195
- expect(run.status).not.toBe(0);
196
- expect(run.combinedOutput).toContain(
197
- 'python3 is required to reconcile supabase/config.toml before local Supabase startup.',
198
- );
199
- expect(readFileSync(run.supabaseLogPath, 'utf-8')).not.toContain('--workdir');
200
- expect(readFileSync(run.supabaseLogPath, 'utf-8')).not.toContain('init --yes');
201
- expect(() => readFileSync(run.configPath, 'utf-8')).toThrow();
263
+ expect(envExample).not.toContain('SUPABASE_RUNTIME_ENABLED=');
264
+ expect(upScript).toContain('SUPABASE_RUNTIME_ENABLED="true"');
265
+ expect(upScript).not.toContain('SUPABASE_RUNTIME_ENABLED="${SUPABASE_RUNTIME_ENABLED');
202
266
  });
203
267
 
204
- test('generates bootstrap logic that retries unhealthy local Supabase startup once', () => {
205
- const script = getGeneratedFileContent('chess', 'infra/minikube/scripts/supabase-local-env.sh');
206
-
207
- expect(script).toContain('start_supabase_local_stack() {');
208
- expect(script).toContain(
209
- 'Supabase local start failed. Stopping stale local stack and retrying once...',
210
- );
211
- expect(script).toContain(
212
- 'supabase --workdir "${SUPABASE_PROJECT_DIR}" stop --no-backup >/dev/null 2>&1 || true',
268
+ test('wires configured OAuth providers into GoTrue runtime env without serializing secrets', () => {
269
+ const result = generateInfrastructure(
270
+ {
271
+ ...createSupabaseManifest(),
272
+ secretStore: {
273
+ provider: 'supabase-vault',
274
+ },
275
+ auth: {
276
+ scope: 'global',
277
+ provider: 'supabase',
278
+ oauth: {
279
+ enabled: true,
280
+ callbackRoute: '/auth/callback',
281
+ providers: [
282
+ {
283
+ id: 'google',
284
+ enabled: true,
285
+ credentialsRef: 'auth/oauth/google',
286
+ },
287
+ ],
288
+ },
289
+ },
290
+ },
291
+ {
292
+ appManifest: createAppManifest('oauth-app'),
293
+ },
213
294
  );
214
- expect(script).toContain('Supabase local start failed after retry.');
215
- expect(script).toContain('start_supabase_local_stack');
216
- expect(
217
- countOccurrences(script, 'supabase --workdir "${SUPABASE_PROJECT_DIR}" start >/dev/null'),
218
- ).toBe(2);
219
- expect(
220
- countOccurrences(script, 'supabase --workdir "${SUPABASE_PROJECT_DIR}" stop --no-backup'),
221
- ).toBe(1);
295
+ const envExample = getFile(result.files, 'infra/minikube/.env.example');
296
+ const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
297
+
298
+ expect(envExample).toContain('# google: auth/oauth/google');
299
+ expect(envExample).toContain('GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=');
300
+ expect(envExample).toContain('GOTRUE_EXTERNAL_GOOGLE_SECRET=');
301
+ expect(upScript).toContain('GOTRUE_EXTERNAL_GOOGLE_ENABLED=true');
302
+ expect(upScript).toContain(
303
+ 'GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID}',
304
+ );
305
+ expect(upScript).toContain('credentialsRef auth/oauth/google');
306
+ expect(upScript).toContain("resolve_vault_secret_field 'auth/oauth/google' 'clientSecret'");
307
+ expect(upScript).toContain(
308
+ 'set_required_env_default ADDITIONAL_REDIRECT_URLS "$(derive_additional_redirect_urls)"',
309
+ );
310
+ expect(upScript).toContain('site_url_without_slash}/${callback_path}');
311
+ expect(upScript).toContain('OAUTH_NATIVE_REDIRECT_URLS');
312
+ expect(upScript).not.toContain('write_env_value GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID');
313
+ expect(upScript).not.toContain('write_env_value GOTRUE_EXTERNAL_GOOGLE_SECRET');
314
+ expect(upScript).not.toContain('sentinel-client-secret-value');
222
315
  });
223
316
 
224
- test('generates canonical Supabase workdir and required CLI capability checks', () => {
225
- const script = getGeneratedFileContent('chess', 'infra/minikube/scripts/supabase-local-env.sh');
317
+ test('generates required secrets even when copied env example contains empty keys', () => {
318
+ const result = generateInfrastructure(createSupabaseManifest(), {
319
+ appManifest: createAppManifest('scanner'),
320
+ });
321
+ const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
226
322
 
227
- expect(script).toContain('SUPABASE_PROJECT_DIR="${SUPABASE_PROJECT_DIR:-${ROOT_DIR}}"');
228
- expect(script).toContain('require_expected_supabase_project_identity');
229
- expect(script).toContain(
230
- 'require_expected_supabase_project_identity\nreject_supabase_project_id_override\nrequire_supabase_cli_capabilities',
323
+ expect(upScript).toContain('load_env_file_preserving_process_env');
324
+ expect(upScript).toContain('APP_IMAGE="${APP_IMAGE:-}"');
325
+ expect(upScript).toContain(
326
+ 'load_env_file_preserving_process_env\nSUPABASE_RUNTIME_ENABLED="true"',
231
327
  );
232
- expect(script).toContain('supabase CLI >= 2.106.0 is required');
233
- expect(script).toContain('supabase CLI does not support required global --workdir flag');
234
- expect(script).toContain('supabase CLI does not support required migration up --local command');
235
- expect(script).toContain(
236
- 'python3 is required to reconcile supabase/config.toml before local Supabase startup.',
328
+ expect(upScript.indexOf('load_env_file_preserving_process_env')).toBeLessThan(
329
+ upScript.lastIndexOf('APP_IMAGE="${APP_IMAGE:-ankh/scanner:dev}"'),
237
330
  );
238
- expect(script).toContain('psql is required but not installed');
239
- expect(script).toContain('supabase --workdir "${SUPABASE_PROJECT_DIR}" init --yes');
240
- expect(script).toContain('supabase --workdir "${SUPABASE_PROJECT_DIR}" migration up --local');
241
- expect(script).toContain('psql "${SUPABASE_DB_URL}" -v ON_ERROR_STOP=1 -q -f "${sql_file}"');
331
+ expect(upScript).toContain('set_required_env_default POSTGRES_PASSWORD "$(random_hex 32)"');
332
+ expect(upScript).toContain('write_env_value "${key}" "${value}"');
333
+ expect(upScript).toContain('set_optional_env_default SMTP_USER ""');
242
334
  });
243
335
 
244
- test('generates profile reconciliation and live verification flow when profile table is configured', () => {
245
- const overrides = {
246
- auth: {
247
- profile: {
248
- table: 'profiles',
249
- fields: ['email', 'displayName'],
250
- },
251
- },
252
- } satisfies Partial<InfraManifestInput>;
253
- const script = getGeneratedFileContent(
254
- 'chess',
255
- 'infra/minikube/scripts/supabase-local-env.sh',
256
- overrides,
257
- );
258
- const status = getGeneratedFileContent('chess', 'infra/minikube/scripts/status.sh', overrides);
336
+ test('syncs browser-safe Supabase env into the generated app env.local before build', () => {
337
+ const result = generateInfrastructure(createSupabaseManifest(), {
338
+ appManifest: createAppManifest('books'),
339
+ });
340
+ const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
259
341
 
260
- expect(script).toContain('SUPABASE_PROFILE_ENABLED="true"');
261
- expect(script).toContain(
262
- 'SUPABASE_PROFILE_RECONCILE_FILE="${SUPABASE_PROJECT_DIR}/supabase/generated/auth_profiles.sql"',
263
- );
264
- expect(script).toContain('Generated profile reconciliation');
265
- expect(script).toContain('SQL file: ${sql_file}');
266
- expect(script).toContain('Supabase project workdir: ${SUPABASE_PROJECT_DIR}');
267
- expect(script).toContain('Exit status: ${status}');
268
- expect(script).toContain('ankhorage_internal.generated_schema_state');
269
- expect(script).toContain('check_immutable_migrations_applied');
270
- expect(status).toContain('- immutable migrations: applied');
271
- expect(status).toContain('- immutable migrations: pending');
272
- expect(status).toContain('- profile reconciliation: applied, checksum matches');
273
- expect(status).toContain('- profile schema: verified');
274
- expect(status).toContain('reserved conflicting identity table public.users exists');
275
- expect(status).toContain('stale managed profile column');
276
- expect(status).toContain('own-profile SELECT policy is missing or has unsafe definition');
277
- expect(status).toContain('unexpected profile table RLS policy exists');
278
- expect(status).toContain(
279
- "has_any_column_privilege('anon', format('public.%I', profile_table), 'SELECT')",
342
+ expect(upScript).toContain('sync_app_public_supabase_env()');
343
+ expect(upScript).toContain('local app_env_file="${APP_SOURCE_DIR}/.env.local"');
344
+ expect(upScript).toContain(
345
+ 'write_key_value_file_value "${app_env_file}" "EXPO_PUBLIC_SUPABASE_URL" "${EXPO_PUBLIC_SUPABASE_URL}"',
280
346
  );
281
- expect(status).toContain(
282
- "has_any_column_privilege('anon', format('public.%I', profile_table), 'INSERT')",
347
+ expect(upScript).toContain(
348
+ 'write_key_value_file_value "${app_env_file}" "EXPO_PUBLIC_SUPABASE_ANON_KEY" "${EXPO_PUBLIC_SUPABASE_ANON_KEY}"',
283
349
  );
284
- expect(status).toContain(
285
- "has_any_column_privilege('anon', format('public.%I', profile_table), 'UPDATE')",
350
+ expect(upScript).toContain('ensure_supabase_runtime_env\nsync_app_public_supabase_env');
351
+ expect(upScript.lastIndexOf('sync_app_public_supabase_env')).toBeLessThan(
352
+ upScript.indexOf('if [[ "${APP_BUILD_ENABLED}" == "true" ]]'),
286
353
  );
287
- expect(status).toContain(
288
- "has_any_column_privilege('anon', format('public.%I', profile_table), 'REFERENCES')",
289
- );
290
- expect(status).toContain(
291
- "has_any_column_privilege('authenticated', format('public.%I', profile_table), 'INSERT')",
292
- );
293
- expect(status).toContain(
294
- "has_any_column_privilege('authenticated', format('public.%I', profile_table), 'REFERENCES')",
295
- );
296
- expect(status).toContain(
297
- 'authenticated role must not have profile table INSERT, DELETE, or REFERENCES privilege',
298
- );
299
- expect(status).toContain(
300
- 'authenticated role has unexpected UPDATE privilege on profile column',
301
- );
302
- expect(status).toContain('generated trigger function execute privilege must be revoked');
354
+ expect(upScript).not.toContain('SUPABASE_SERVICE_ROLE_KEY" "${SUPABASE_SERVICE_ROLE_KEY}"');
303
355
  });
304
356
 
305
- test('generates relation-safe disabled profile verification for local env and status', () => {
306
- const script = getGeneratedFileContent('chess', 'infra/minikube/scripts/supabase-local-env.sh');
307
- const status = getGeneratedFileContent('chess', 'infra/minikube/scripts/status.sh');
308
- const expectedDynamicLookup = `execute
309
- 'select exists (
310
- select 1
311
- from ankhorage_internal.generated_schema_state
312
- where artifact_key = $1
313
- )'
314
- into has_profile_state
315
- using 'auth.profile';`;
316
-
317
- for (const generated of [script, status]) {
318
- expect(generated).toContain('has_profile_state boolean := false;');
319
- expect(generated).toContain(expectedDynamicLookup);
320
- expect(generated).toContain(
321
- 'manifest auth.profile.table was removed but local generated auth.profile state still exists; reset local Supabase or add an explicit cleanup migration',
322
- );
323
- expect(generated).not.toContain(
324
- `if to_regclass('ankhorage_internal.generated_schema_state') is not null
325
- and exists (`,
326
- );
327
- expect(generated.replace(expectedDynamicLookup, '')).not.toContain(
328
- `from ankhorage_internal.generated_schema_state
329
- where artifact_key = 'auth.profile'`,
330
- );
331
- }
332
-
333
- expect(script).toContain('Generated profile disabled-state verification');
334
- expect(status).toContain('- profile reconciliation: skipped (no profile table configured)');
335
- expect(status).toContain('- profile schema: skipped (no profile table configured)');
336
- });
337
-
338
- test('status succeeds for a clean disabled profile local Supabase stack', () => {
339
- const run = runGeneratedStatusScript({
340
- manifest: createInfraManifest(),
341
- supabaseProjectId: 'plain',
342
- env: {
343
- AUTH_RUNTIME_MODE: 'local',
344
- },
357
+ test('starts Supabase schema owners before app migrations and profile reconciliation', () => {
358
+ const result = generateInfrastructure(createSupabaseManifest(), {
359
+ appManifest: createAppManifest('scanner'),
345
360
  });
361
+ const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
346
362
 
347
- expect(run.status).toBe(0);
348
- expect(run.combinedOutput).toContain('- immutable migrations: applied');
349
- expect(run.combinedOutput).toContain(
350
- '- profile reconciliation: skipped (no profile table configured)',
363
+ expect(upScript).toContain('apply_supabase_runtime_workloads');
364
+ expect(upScript).toContain('exec deployment/postgres -- psql -U postgres');
365
+ expect(upScript).toContain('create schema if not exists vault');
366
+ expect(upScript).toContain('grant execute on function pg_read_file(text) to public');
367
+ expect(upScript.indexOf('postgres.init.configmap.yaml')).toBeLessThan(
368
+ upScript.indexOf('postgres.pvc.yaml'),
369
+ );
370
+ expect(upScript.lastIndexOf(' wait_for_supabase_database')).toBeLessThan(
371
+ upScript.lastIndexOf(' bootstrap_supabase_database'),
372
+ );
373
+ expect(upScript).toContain('rollout status deployment/postgres --timeout=900s');
374
+ expect(upScript).toContain('rollout status deployment/gateway --timeout=600s');
375
+ const runMigrationsCallIndex = upScript.lastIndexOf(' run_supabase_migrations');
376
+ const reconcileProfileCallIndex = upScript.lastIndexOf(' reconcile_supabase_profile');
377
+ const appRuntimeApplyIndex = upScript.lastIndexOf(
378
+ 'kubectl --context "${PROFILE}" apply -k "${K8S_DIR}"',
351
379
  );
352
- expect(run.combinedOutput).toContain('- profile schema: skipped (no profile table configured)');
353
- expect(run.supabaseLog).toContain('--workdir');
354
- });
355
-
356
- test('does not reject SUPABASE_PROJECT_ID for non-Supabase status checks', () => {
357
- const run = runGeneratedStatusScript({
358
- manifest: {
359
- deployment: {
360
- target: 'minikube',
361
- monitoring: false,
362
- },
363
- plugins: [],
364
- },
365
- supabaseProjectId: null,
366
- env: {
367
- SUPABASE_PROJECT_ID: 'ambient-project',
368
- },
369
- });
370
380
 
371
- expect(run.status).toBe(0);
372
- expect(run.combinedOutput).not.toContain(
373
- 'SUPABASE_PROJECT_ID must not be set for generated local Infra scripts.',
381
+ expect(upScript.lastIndexOf(' apply_supabase_runtime_workloads')).toBeLessThan(
382
+ upScript.indexOf('rollout status deployment/auth'),
383
+ );
384
+ expect(upScript.indexOf('rollout status deployment/auth')).toBeLessThan(runMigrationsCallIndex);
385
+ expect(upScript.indexOf('rollout status deployment/storage')).toBeLessThan(
386
+ runMigrationsCallIndex,
387
+ );
388
+ expect(upScript.indexOf(' reconcile_supabase_profile')).toBeLessThan(
389
+ upScript.indexOf(' reload_postgrest_schema'),
374
390
  );
375
- expect(run.kubectlLog).toContain('get all -n plain');
391
+ expect(upScript.indexOf('rollout status deployment/studio')).toBeLessThan(
392
+ upScript.indexOf('"${PORT_FORWARD_SCRIPT}" start studio'),
393
+ );
394
+ expect(runMigrationsCallIndex).toBeLessThan(reconcileProfileCallIndex);
395
+ expect(reconcileProfileCallIndex).toBeLessThan(appRuntimeApplyIndex);
376
396
  });
377
397
 
378
- test('rejects empty Supabase project identity before local Supabase status checks', () => {
379
- const run = runGeneratedStatusScript({
380
- manifest: {
381
- deployment: {
382
- target: 'minikube',
383
- monitoring: false,
384
- },
385
- plugins: [],
386
- },
387
- supabaseProjectId: null,
388
- env: {
389
- SUPABASE_LOCAL_ENABLED: 'true',
390
- },
398
+ test('treats port-forwards as slug-owned lifecycle resources', () => {
399
+ const result = generateInfrastructure(createSupabaseManifest(), {
400
+ appManifest: createAppManifest('uwm'),
391
401
  });
392
-
393
- expect(run.status).not.toBe(0);
394
- expect(run.combinedOutput).toContain(
395
- 'Cannot run local Supabase infrastructure: expected Supabase project identity is empty.',
396
- );
397
- expect(run.supabaseLog).toBe('');
402
+ const portForwardScript = getFile(result.files, 'infra/minikube/scripts/port-forward.sh');
403
+ const statusScript = getFile(result.files, 'infra/minikube/scripts/status.sh');
404
+ const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
405
+ const downScript = getFile(result.files, 'infra/minikube/scripts/down.sh');
406
+
407
+ expect(portForwardScript).toContain('service/app-runtime');
408
+ expect(portForwardScript).toContain('service/gateway');
409
+ expect(portForwardScript).toContain('service/studio');
410
+ expect(portForwardScript).toContain('service/postgres');
411
+ expect(portForwardScript).toContain('${PROFILE}-${1}.pid');
412
+ expect(portForwardScript).toContain('crashed stale_pid');
413
+ expect(portForwardScript).toContain('is_port_accepting()');
414
+ expect(portForwardScript).toContain('recent kubectl port-forward log');
415
+ expect(portForwardScript).toContain('did not become ready on local port');
416
+ expect(portForwardScript).toContain('not ready pid=');
417
+ expect(portForwardScript).toContain('terminate_forward_process()');
418
+ expect(portForwardScript).toContain('ps -p "${pid}" -o command=');
419
+ expect(portForwardScript).toContain('ps -p "${pid}" -o comm=');
420
+ expect(portForwardScript).toContain('is_owned_forward_process()');
421
+ expect(portForwardScript).toContain(
422
+ '*"kubectl --context ${PROFILE} -n ${namespace} port-forward ${resource} ${local_port}:${remote_port}"*',
423
+ );
424
+ expect(portForwardScript).toContain('kill -KILL "${pid}"');
425
+ expect(portForwardScript).toContain('failed to stop owned port-forward pid');
426
+ expect(portForwardScript).toContain('local port ${local_port} is still accepting connections');
427
+ expect(portForwardScript).toContain('refusing to terminate non-owned live pid');
428
+ expect(portForwardScript).toContain(
429
+ 'stale pid file pointed at non-owned live pid ${pid}; removing ownership record ${pid_file}',
430
+ );
431
+ expect(portForwardScript).toContain(
432
+ 'port occupied by non-owned process on local port ${local_port}',
433
+ );
434
+ expect(portForwardScript).toContain('stale_pid_non_owned=${pid}');
435
+ expect(statusScript).toContain('port-forward.sh" status all');
436
+ expect(upScript).toContain('set -Eeuo pipefail');
437
+ expect(upScript).toContain('trap cleanup_failed_up ERR');
438
+ expect(upScript).toContain('start supabase-gateway');
439
+ expect(upScript).toContain('start db-migration');
440
+ expect(upScript).toContain('Failed to start db-migration port-forward for Supabase bootstrap.');
441
+ expect(upScript.indexOf('trap cleanup_failed_up ERR')).toBeLessThan(
442
+ upScript.indexOf('require_command minikube'),
443
+ );
444
+ expect(upScript).toContain('trap - ERR');
445
+ expect(downScript).toContain('port-forward.sh" stop all');
398
446
  });
399
447
  });
400
448
 
401
- function getGeneratedFileContent(
402
- namespace: string,
403
- filePath: string,
404
- overrides: Partial<InfraManifestInput> = {},
405
- supabaseProjectId = namespace,
406
- ): string {
407
- const artifact = generateMinikubeBaseArtifacts({
408
- manifest: createInfraManifest(overrides),
409
- namespace,
410
- supabaseProjectId,
411
- extraResources: [],
412
- extraEnvEntries: [],
413
- }).find((file) => file.path === filePath);
414
-
415
- if (!artifact) {
416
- throw new Error(`Expected generated artifact at ${filePath}`);
417
- }
418
-
419
- return artifact.content;
420
- }
421
-
422
- function countOccurrences(source: string, needle: string): number {
423
- return source.split(needle).length - 1;
424
- }
425
-
426
- function createInfraManifest(overrides: Partial<InfraManifestInput> = {}): InfraManifestInput {
449
+ function createSupabaseManifest(): InfraManifestInput {
427
450
  return {
428
451
  deployment: {
429
452
  target: 'minikube',
@@ -432,318 +455,21 @@ function createInfraManifest(overrides: Partial<InfraManifestInput> = {}): Infra
432
455
  auth: {
433
456
  scope: 'global',
434
457
  provider: 'supabase',
435
- authorization: {
436
- kind: 'RBAC',
437
- engine: 'native',
438
- },
439
458
  },
440
459
  database: {
441
460
  provider: 'supabase',
442
461
  tier: 'dev',
443
462
  },
444
- networking: {
445
- cdn: false,
446
- },
447
- plugins: [],
448
- ...overrides,
449
- auth: {
450
- scope: 'global',
463
+ storage: {
451
464
  provider: 'supabase',
452
- authorization: {
453
- kind: 'RBAC',
454
- engine: 'native',
455
- },
456
- ...overrides.auth,
465
+ buckets: ['assets'],
457
466
  },
467
+ plugins: [],
458
468
  };
459
469
  }
460
470
 
461
- function runGeneratedSupabaseScript(args: {
462
- projectId: string;
463
- supabaseProjectId?: string | null;
464
- existingConfig: string | null;
465
- env?: Record<string, string>;
466
- withoutPython?: boolean;
467
- }): {
468
- status: number | null;
469
- combinedOutput: string;
470
- root: string;
471
- configPath: string;
472
- supabaseLogPath: string;
473
- } {
474
- const tempRoot = mkdtempSync(join(tmpdir(), 'infra-supabase-identity-'));
475
- const root = join(tempRoot, 'infra', 'minikube');
476
- const scriptsDir = join(root, 'scripts');
477
- const supabaseDir = join(root, 'supabase');
478
- const binDir = join(tempRoot, 'bin');
479
- const appSourceDir = join(tempRoot, 'app');
480
- const scriptPath = join(scriptsDir, 'supabase-local-env.sh');
481
- const configPath = join(supabaseDir, 'config.toml');
482
- const supabaseLogPath = join(tempRoot, 'supabase.log');
483
-
484
- mkdirSync(scriptsDir, { recursive: true });
485
- mkdirSync(binDir, { recursive: true });
486
- mkdirSync(appSourceDir, { recursive: true });
487
- writeFileSync(join(root, '.env.example'), '');
488
- writeFileSync(supabaseLogPath, '');
489
-
490
- if (args.existingConfig !== null) {
491
- mkdirSync(supabaseDir, { recursive: true });
492
- writeFileSync(configPath, args.existingConfig);
493
- }
494
-
495
- const script = getGeneratedFileContent(
496
- args.projectId,
497
- 'infra/minikube/scripts/supabase-local-env.sh',
498
- {},
499
- args.supabaseProjectId === undefined ? args.projectId : args.supabaseProjectId,
500
- );
501
- writeFileSync(scriptPath, script);
502
- chmodSync(scriptPath, 0o755);
503
-
504
- const supabaseStubPath = join(binDir, 'supabase');
505
- writeFileSync(
506
- supabaseStubPath,
507
- `#!/bin/bash
508
- set -euo pipefail
509
- printf '%s\\n' "$*" >> "\${SUPABASE_STUB_LOG}"
510
-
511
- if [[ "\${1:-}" == "--version" ]]; then
512
- echo "supabase 2.106.0"
513
- exit 0
514
- fi
515
-
516
- if [[ "\${1:-}" == "status" && "\${2:-}" == "--help" ]]; then
517
- echo "--workdir"
518
- exit 0
519
- fi
520
-
521
- if [[ "\${1:-}" == "migration" && "\${2:-}" == "up" && "\${3:-}" == "--help" ]]; then
522
- echo "--local"
523
- exit 0
524
- fi
525
-
526
- if [[ "\${1:-}" == "--workdir" && "\${3:-}" == "init" && "\${4:-}" == "--yes" ]]; then
527
- mkdir -p "\${2}/supabase"
528
- printf '%s\\n' 'project_id = "minikube"' '[api]' 'port = 54321' > "\${2}/supabase/config.toml"
529
- exit 0
530
- fi
531
-
532
- if [[ "\${1:-}" == "--workdir" && "\${3:-}" == "status" && "\${4:-}" == "-o" && "\${5:-}" == "env" ]]; then
533
- cat <<'ENV'
534
- DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
535
- API_URL=http://127.0.0.1:54321
536
- ANON_KEY=anon
537
- SERVICE_ROLE_KEY=service
538
- JWT_SECRET=secret
539
- ENV
540
- exit 0
541
- fi
542
-
543
- if [[ "\${1:-}" == "--workdir" && "\${3:-}" == "status" ]]; then
544
- exit 0
545
- fi
546
-
547
- if [[ "\${1:-}" == "--workdir" && "\${3:-}" == "migration" && "\${4:-}" == "up" && "\${5:-}" == "--local" ]]; then
548
- exit 0
549
- fi
550
-
551
- echo "unexpected supabase call: $*" >&2
552
- exit 1
553
- `,
554
- );
555
- chmodSync(supabaseStubPath, 0o755);
556
-
557
- const psqlStubPath = join(binDir, 'psql');
558
- writeFileSync(
559
- psqlStubPath,
560
- `#!/bin/bash
561
- exit 0
562
- `,
563
- );
564
- chmodSync(psqlStubPath, 0o755);
565
-
566
- const result = spawnSync('/bin/bash', [scriptPath], {
567
- cwd: tempRoot,
568
- env: {
569
- ...(args.withoutPython ? {} : process.env),
570
- PATH: args.withoutPython ? binDir : `${binDir}:${process.env.PATH ?? ''}`,
571
- APP_SOURCE_DIR: appSourceDir,
572
- SUPABASE_STUB_LOG: supabaseLogPath,
573
- ...args.env,
574
- },
575
- encoding: 'utf-8',
576
- });
577
-
578
- return {
579
- status: result.status,
580
- combinedOutput: `${result.stdout}${result.stderr}`,
581
- root,
582
- configPath,
583
- supabaseLogPath,
584
- };
585
- }
586
-
587
- function runGeneratedStatusScript(args: {
588
- manifest: InfraManifestInput;
589
- supabaseProjectId: string | null;
590
- env?: Record<string, string>;
591
- }): {
592
- status: number | null;
593
- combinedOutput: string;
594
- kubectlLog: string;
595
- supabaseLog: string;
596
- } {
597
- const tempRoot = mkdtempSync(join(tmpdir(), 'infra-status-'));
598
- const root = join(tempRoot, 'infra', 'minikube');
599
- const scriptsDir = join(root, 'scripts');
600
- const supabaseDir = join(root, 'supabase');
601
- const binDir = join(tempRoot, 'bin');
602
- const scriptPath = join(scriptsDir, 'status.sh');
603
- const kubectlLogPath = join(tempRoot, 'kubectl.log');
604
- const supabaseLogPath = join(tempRoot, 'supabase.log');
605
- const psqlLogPath = join(tempRoot, 'psql.log');
606
-
607
- mkdirSync(scriptsDir, { recursive: true });
608
- mkdirSync(binDir, { recursive: true });
609
- writeFileSync(join(root, '.env.example'), '');
610
- writeFileSync(kubectlLogPath, '');
611
- writeFileSync(supabaseLogPath, '');
612
- writeFileSync(psqlLogPath, '');
613
-
614
- if (args.supabaseProjectId !== null) {
615
- mkdirSync(supabaseDir, { recursive: true });
616
- writeFileSync(join(supabaseDir, 'config.toml'), `project_id = "${args.supabaseProjectId}"\n`);
617
- }
618
-
619
- const artifact = generateMinikubeBaseArtifacts({
620
- manifest: args.manifest,
621
- namespace: 'plain',
622
- supabaseProjectId: args.supabaseProjectId,
623
- extraResources: [],
624
- extraEnvEntries: [],
625
- }).find((file) => file.path === 'infra/minikube/scripts/status.sh');
626
-
627
- if (!artifact) {
628
- throw new Error('Expected generated status.sh artifact');
629
- }
630
-
631
- writeFileSync(scriptPath, artifact.content);
632
- chmodSync(scriptPath, 0o755);
633
-
634
- const kubectlStubPath = join(binDir, 'kubectl');
635
- writeFileSync(
636
- kubectlStubPath,
637
- `#!/bin/bash
638
- set -euo pipefail
639
- printf '%s\\n' "$*" >> "\${KUBECTL_STUB_LOG}"
640
-
641
- if [[ "\${1:-}" == "config" && "\${2:-}" == "current-context" ]]; then
642
- echo "minikube"
643
- exit 0
644
- fi
645
-
646
- if [[ "\${1:-}" == "cluster-info" ]]; then
647
- echo "Kubernetes control plane is running"
648
- exit 0
649
- fi
650
-
651
- if [[ "\${1:-}" == "get" && "\${2:-}" == "namespace" ]]; then
652
- exit 0
653
- fi
654
-
655
- if [[ "\${1:-}" == "get" && "\${2:-}" == "all" ]]; then
656
- echo "No resources found"
657
- exit 0
658
- fi
659
-
660
- echo "unexpected kubectl call: $*" >&2
661
- exit 1
662
- `,
663
- );
664
- chmodSync(kubectlStubPath, 0o755);
665
-
666
- const supabaseStubPath = join(binDir, 'supabase');
667
- writeFileSync(
668
- supabaseStubPath,
669
- `#!/bin/bash
670
- set -euo pipefail
671
- printf '%s\\n' "$*" >> "\${SUPABASE_STUB_LOG}"
672
-
673
- if [[ "\${1:-}" == "--version" ]]; then
674
- echo "supabase 2.106.0"
675
- exit 0
676
- fi
677
-
678
- if [[ "\${1:-}" == "status" && "\${2:-}" == "--help" ]]; then
679
- echo "--workdir"
680
- exit 0
681
- fi
682
-
683
- if [[ "\${1:-}" == "migration" && "\${2:-}" == "up" && "\${3:-}" == "--help" ]]; then
684
- echo "--local"
685
- exit 0
686
- fi
687
-
688
- if [[ "\${1:-}" == "--workdir" && "\${3:-}" == "status" && "\${4:-}" == "-o" && "\${5:-}" == "env" ]]; then
689
- cat <<'ENV'
690
- DB_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
691
- API_URL=http://127.0.0.1:54321
692
- ANON_KEY=anon
693
- SERVICE_ROLE_KEY=service
694
- JWT_SECRET=secret
695
- ENV
696
- exit 0
697
- fi
698
-
699
- if [[ "\${1:-}" == "--workdir" && "\${3:-}" == "status" ]]; then
700
- exit 0
701
- fi
702
-
703
- echo "unexpected supabase call: $*" >&2
704
- exit 1
705
- `,
706
- );
707
- chmodSync(supabaseStubPath, 0o755);
708
-
709
- const psqlStubPath = join(binDir, 'psql');
710
- writeFileSync(
711
- psqlStubPath,
712
- `#!/bin/bash
713
- set -euo pipefail
714
- printf '%s\\n' "$*" >> "\${PSQL_STUB_LOG}"
715
-
716
- if [[ "$*" == *"select version from supabase_migrations.schema_migrations"* ]]; then
717
- exit 0
718
- fi
719
-
720
- if [[ "$*" == *"-f"* ]]; then
721
- exit 0
722
- fi
723
-
724
- echo "unexpected psql call: $*" >&2
725
- exit 1
726
- `,
727
- );
728
- chmodSync(psqlStubPath, 0o755);
729
-
730
- const result = spawnSync('/bin/bash', [scriptPath], {
731
- cwd: tempRoot,
732
- env: {
733
- ...process.env,
734
- PATH: `${binDir}:${process.env.PATH ?? ''}`,
735
- KUBECTL_STUB_LOG: kubectlLogPath,
736
- SUPABASE_STUB_LOG: supabaseLogPath,
737
- PSQL_STUB_LOG: psqlLogPath,
738
- ...args.env,
739
- },
740
- encoding: 'utf-8',
741
- });
742
-
743
- return {
744
- status: result.status,
745
- combinedOutput: `${result.stdout}${result.stderr}`,
746
- kubectlLog: readFileSync(kubectlLogPath, 'utf-8'),
747
- supabaseLog: readFileSync(supabaseLogPath, 'utf-8'),
748
- };
471
+ function getFile(files: readonly { path: string; content: string }[], path: string): string {
472
+ const file = files.find((candidate) => candidate.path === path);
473
+ if (!file) throw new Error(`Missing generated file: ${path}`);
474
+ return file.content;
749
475
  }