@ankhorage/infra 1.0.2 → 1.0.4
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/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/adapters/minikube/base/index.d.ts.map +1 -1
- package/dist/adapters/minikube/base/index.js +19 -4
- package/dist/adapters/minikube/base/index.js.map +1 -1
- package/dist/adapters/minikube/base/supabaseMigrations.d.ts +2 -0
- package/dist/adapters/minikube/base/supabaseMigrations.d.ts.map +1 -0
- package/dist/adapters/minikube/base/supabaseMigrations.js +6 -0
- package/dist/adapters/minikube/base/supabaseMigrations.js.map +1 -0
- package/dist/adapters/minikube/secrets/supabase-vault/index.js +2 -1
- package/dist/adapters/minikube/secrets/supabase-vault/index.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/minikube/README.md +16 -3
- package/src/adapters/minikube/base/appImageBuild.test.ts +263 -0
- package/src/adapters/minikube/base/index.test.ts +45 -1
- package/src/adapters/minikube/base/index.ts +19 -4
- package/src/adapters/minikube/base/supabaseMigrations.test.ts +168 -0
- package/src/adapters/minikube/base/supabaseMigrations.ts +5 -0
- package/src/adapters/minikube/secrets/supabase-vault/index.ts +2 -1
- package/src/index.test.ts +4 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"supabaseMigrations.d.ts","sourceRoot":"","sources":["../../../../src/adapters/minikube/base/supabaseMigrations.ts"],"names":[],"mappings":"AAAA,wBAAgB,iCAAiC,IAAI,MAAM,CAI1D"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export function getSupabaseMigrationCommandScript() {
|
|
2
|
+
return `echo "Applying pending Supabase migrations..."
|
|
3
|
+
SUPABASE_TELEMETRY_DISABLED=1 supabase --yes migration up --db-url "\${SUPABASE_DB_URL}"
|
|
4
|
+
echo "Supabase migrations applied."`;
|
|
5
|
+
}
|
|
6
|
+
//# sourceMappingURL=supabaseMigrations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"supabaseMigrations.js","sourceRoot":"","sources":["../../../../src/adapters/minikube/base/supabaseMigrations.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,iCAAiC;IAC/C,OAAO;;oCAE2B,CAAC;AACrC,CAAC","sourcesContent":["export function getSupabaseMigrationCommandScript(): string {\n return `echo \"Applying pending Supabase migrations...\"\nSUPABASE_TELEMETRY_DISABLED=1 supabase --yes migration up --db-url \"\\${SUPABASE_DB_URL}\"\necho \"Supabase migrations applied.\"`;\n}\n"]}
|
|
@@ -93,7 +93,8 @@ ${SUPABASE_VAULT_MIGRATION_PATH}
|
|
|
93
93
|
\`\`\`
|
|
94
94
|
|
|
95
95
|
is applied by the Kubernetes-owned Supabase migration lifecycle during \`scripts/up.sh\`
|
|
96
|
-
with
|
|
96
|
+
with command-scoped CLI telemetry disabled around
|
|
97
|
+
\`supabase --yes migration up --db-url "$SUPABASE_DB_URL"\`.
|
|
97
98
|
|
|
98
99
|
## Security boundary
|
|
99
100
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../src/adapters/minikube/secrets/supabase-vault/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,4BAA4B,EAC5B,oCAAoC,GACrC,MAAM,2BAA2B,CAAC;AAKnC,MAAM,CAAC,MAAM,6BAA6B,GACxC,8EAA8E,CAAC;AAEjF,MAAM,UAAU,yCAAyC,CAAC,IAGzD;IACC,6BAA6B,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAE7C,OAAO;QACL,KAAK,EAAE;YACL;gBACE,IAAI,EAAE,6BAA6B;gBACnC,OAAO,EAAE,mCAAmC,EAAE;aAC/C;YACD;gBACE,IAAI,EAAE,0CAA0C;gBAChD,OAAO,EAAE,eAAe,CAAC,IAAI,CAAC,SAAS,CAAC;aACzC;SACF;QACD,SAAS,EAAE,EAAE;QACb,iBAAiB,EAAE,EAAE;QACrB,UAAU,EAAE,CAAC,yBAAyB,oCAAoC,EAAE,CAAC;QAC7E,QAAQ,EAAE,EAAE;KACb,CAAC;AACJ,CAAC;AAED,SAAS,mCAAmC;IAC1C,OAAO,GAAG,4BAA4B,CAAC,IAAI,EAAE;SAC1C,OAAO,CACN,uEAAuE,EACvE,GAAG,EAAE,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAuCR,CACC;SACA,IAAI,EAAE,IAAI,CAAC;AAChB,CAAC;AAED,SAAS,6BAA6B,CAAC,QAA4B;IACjE,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IACnC,IAAI,CAAC,KAAK,EAAE,OAAO;QAAE,OAAO;IAE5B,MAAM,gBAAgB,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,OAAO,KAAK,KAAK,CAAC,CAAC;IAC1F,IAAI,gBAAgB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;IACxE,CAAC;IAED,KAAK,MAAM,QAAQ,IAAI,gBAAgB,EAAE,CAAC;QACxC,IAAI,CAAC,QAAQ,CAAC,cAAc,EAAE,IAAI,EAAE,EAAE,CAAC;YACrC,MAAM,IAAI,KAAK,CACb,mBAAmB,QAAQ,CAAC,EAAE,kDAAkD,CACjF,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,eAAe,CAAC,SAAiB;IACxC,OAAO;;;;;+BAKsB,oCAAoC;;;;;;EAMjE,6BAA6B
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../src/adapters/minikube/secrets/supabase-vault/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,4BAA4B,EAC5B,oCAAoC,GACrC,MAAM,2BAA2B,CAAC;AAKnC,MAAM,CAAC,MAAM,6BAA6B,GACxC,8EAA8E,CAAC;AAEjF,MAAM,UAAU,yCAAyC,CAAC,IAGzD;IACC,6BAA6B,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAE7C,OAAO;QACL,KAAK,EAAE;YACL;gBACE,IAAI,EAAE,6BAA6B;gBACnC,OAAO,EAAE,mCAAmC,EAAE;aAC/C;YACD;gBACE,IAAI,EAAE,0CAA0C;gBAChD,OAAO,EAAE,eAAe,CAAC,IAAI,CAAC,SAAS,CAAC;aACzC;SACF;QACD,SAAS,EAAE,EAAE;QACb,iBAAiB,EAAE,EAAE;QACrB,UAAU,EAAE,CAAC,yBAAyB,oCAAoC,EAAE,CAAC;QAC7E,QAAQ,EAAE,EAAE;KACb,CAAC;AACJ,CAAC;AAED,SAAS,mCAAmC;IAC1C,OAAO,GAAG,4BAA4B,CAAC,IAAI,EAAE;SAC1C,OAAO,CACN,uEAAuE,EACvE,GAAG,EAAE,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAuCR,CACC;SACA,IAAI,EAAE,IAAI,CAAC;AAChB,CAAC;AAED,SAAS,6BAA6B,CAAC,QAA4B;IACjE,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IACnC,IAAI,CAAC,KAAK,EAAE,OAAO;QAAE,OAAO;IAE5B,MAAM,gBAAgB,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,OAAO,KAAK,KAAK,CAAC,CAAC;IAC1F,IAAI,gBAAgB,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAC;IACxE,CAAC;IAED,KAAK,MAAM,QAAQ,IAAI,gBAAgB,EAAE,CAAC;QACxC,IAAI,CAAC,QAAQ,CAAC,cAAc,EAAE,IAAI,EAAE,EAAE,CAAC;YACrC,MAAM,IAAI,KAAK,CACb,mBAAmB,QAAQ,CAAC,EAAE,kDAAkD,CACjF,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,eAAe,CAAC,SAAiB;IACxC,OAAO;;;;;+BAKsB,oCAAoC;;;;;;EAMjE,6BAA6B;;;;;;;;;;;;;;;;;;;;;;;oCAuBK,SAAS;CAC5C,CAAC;AACF,CAAC","sourcesContent":["import {\n SUPABASE_VAULT_MIGRATION_SQL,\n SUPABASE_VAULT_SECRET_STORE_PROVIDER,\n} from '@ankhorage/supabase-vault';\n\nimport type { InfraManifestInput } from '../../../../types';\nimport type { MinikubeAdapterArtifacts } from '../../contracts';\n\nexport const SUPABASE_VAULT_MIGRATION_PATH =\n 'infra/minikube/supabase/migrations/202607120001_ankhorage_supabase_vault.sql';\n\nexport function generateSupabaseVaultSecretStoreArtifacts(args: {\n readonly manifest: InfraManifestInput;\n readonly namespace: string;\n}): MinikubeAdapterArtifacts {\n validateOAuthSecretReferences(args.manifest);\n\n return {\n files: [\n {\n path: SUPABASE_VAULT_MIGRATION_PATH,\n content: getCurrentSupabaseVaultMigrationSql(),\n },\n {\n path: 'infra/minikube/secrets/supabase-vault.md',\n content: getRuntimeGuide(args.namespace),\n },\n ],\n resources: [],\n providerLifecycle: [],\n envEntries: [`SECRET_STORE_PROVIDER=${SUPABASE_VAULT_SECRET_STORE_PROVIDER}`],\n warnings: [],\n };\n}\n\nfunction getCurrentSupabaseVaultMigrationSql(): string {\n return `${SUPABASE_VAULT_MIGRATION_SQL.trim()\n .replace(\n 'create extension if not exists supabase_vault with schema extensions;',\n () => `create schema if not exists vault;\ndo $$\nbegin\n create extension if not exists supabase_vault with schema vault;\nexception\n when insufficient_privilege then\n create table if not exists vault.secrets (\n id uuid primary key default gen_random_uuid(),\n secret text not null,\n name text,\n description text,\n created_at timestamptz not null default now()\n );\n\n create or replace function vault.create_secret(\n secret text,\n name text default null,\n description text default null\n )\n returns uuid\n language plpgsql\n security definer\n set search_path = vault, public\n as $vault$\n declare\n secret_id uuid;\n begin\n insert into vault.secrets (secret, name, description)\n values (secret, name, description)\n returning id into secret_id;\n\n return secret_id;\n end;\n $vault$;\n\n create or replace view vault.decrypted_secrets as\n select id, secret as decrypted_secret\n from vault.secrets;\nend\n$$;`,\n )\n .trim()}\\n`;\n}\n\nfunction validateOAuthSecretReferences(manifest: InfraManifestInput): void {\n const oauth = manifest.auth?.oauth;\n if (!oauth?.enabled) return;\n\n const enabledProviders = oauth.providers.filter((provider) => provider.enabled !== false);\n if (enabledProviders.length === 0) {\n throw new Error('OAuth is enabled but no OAuth provider is enabled.');\n }\n\n for (const provider of enabledProviders) {\n if (!provider.credentialsRef?.trim()) {\n throw new Error(\n `OAuth provider \"${provider.id}\" is enabled but does not define credentialsRef.`,\n );\n }\n }\n}\n\nfunction getRuntimeGuide(namespace: string): string {\n return `# Supabase Vault secret store\n\nThis project selects the provider-neutral secret-store backend through:\n\n\\`\\`\\`text\ninfra.secretStore.provider = ${SUPABASE_VAULT_SECRET_STORE_PROVIDER}\n\\`\\`\\`\n\nThe immutable migration at:\n\n\\`\\`\\`text\n${SUPABASE_VAULT_MIGRATION_PATH}\n\\`\\`\\`\n\nis applied by the Kubernetes-owned Supabase migration lifecycle during \\`scripts/up.sh\\`\nwith command-scoped CLI telemetry disabled around\n\\`supabase --yes migration up --db-url \"$SUPABASE_DB_URL\"\\`.\n\n## Security boundary\n\n- Secret values are stored only through Supabase Vault.\n- Public manifests contain logical references such as \\`auth/oauth/google\\`, never values.\n- Project and environment scope are supplied by trusted server code.\n- The browser may receive secret metadata, but must never receive resolved values or Vault IDs.\n- Bootstrap database access must come from trusted process environment or workload identity.\n- Do not expose service-role keys, provider secrets, or resolved payloads through\n \\`NEXT_PUBLIC_*\\`, \\`EXPO_PUBLIC_*\\`, generated YAML, tracked JSON, or app bundles.\n\n## Trusted adapter composition\n\nUse \\`createInfraSecretStoreAdapter\\` from \\`@ankhorage/infra\\` in server-only code and pass\na trusted Supabase Vault SQL client. The app manifest chooses the provider; privileged connection\nconfiguration does not belong in the manifest.\n\nGenerated Kubernetes namespace: \\`${namespace}\\`.\n`;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/infra",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.4",
|
|
5
5
|
"description": "Project-aware infrastructure generation library and executable Ankh provider for Ankhorage.",
|
|
6
6
|
"homepage": "https://github.com/ankhorage/infra#readme",
|
|
7
7
|
"bugs": {
|
|
@@ -25,7 +25,7 @@ when provided by caller.
|
|
|
25
25
|
|
|
26
26
|
## Runtime Scripts
|
|
27
27
|
|
|
28
|
-
- `scripts/up.sh`: starts `minikube -p <slug>`, applies manifests, waits for Supabase, runs migrations with
|
|
28
|
+
- `scripts/up.sh`: starts `minikube -p <slug>`, applies manifests, waits for Supabase, runs migrations with CLI telemetry disabled for that command, builds/loads the app image, and starts slug-owned port-forwards.
|
|
29
29
|
- `scripts/down.sh`: stops slug-owned port-forwards, then runs `minikube stop -p <slug>`. Persistent profile data remains.
|
|
30
30
|
- `scripts/reset.sh`: requires `ANKH_RESET_CONFIRM=<slug>` and deletes/recreates namespaces `app` and `supabase`, including Supabase DB and Storage PVC data. It does not delete the Minikube profile.
|
|
31
31
|
- `scripts/destroy.sh`: stops slug-owned port-forwards and runs `minikube delete -p <slug>`.
|
|
@@ -37,7 +37,14 @@ when provided by caller.
|
|
|
37
37
|
|
|
38
38
|
Runtime ownership is Kubernetes. Migration authoring/history remains Supabase migration
|
|
39
39
|
files. Migration execution targets the Kubernetes Postgres endpoint via
|
|
40
|
-
`supabase migration up --db-url "$SUPABASE_DB_URL"`.
|
|
40
|
+
`SUPABASE_TELEMETRY_DISABLED=1 supabase --yes migration up --db-url "$SUPABASE_DB_URL"`.
|
|
41
|
+
The migration process does not require stdin. `--yes` remains defense in depth for future
|
|
42
|
+
CLI prompts; it is not the hang fix. Command-scoped telemetry disable avoids the unbounded
|
|
43
|
+
telemetry shutdown in Supabase CLI 2.106.0 when PostHog is blocked, without changing the
|
|
44
|
+
user's persistent telemetry preference. Migration failures remain fatal under
|
|
45
|
+
`set -Eeuo pipefail`, and the completion message is printed only after a zero exit status.
|
|
46
|
+
The lifecycle adds no migration deadline and supports the affected CLI version; using a
|
|
47
|
+
current Supabase CLI is still recommended.
|
|
41
48
|
|
|
42
49
|
The generated manifests are based on the current official Supabase self-hosting Docker
|
|
43
50
|
topology, service documentation, environment-variable contracts, and pinned official
|
|
@@ -48,7 +55,9 @@ official Supabase Kubernetes distribution.
|
|
|
48
55
|
|
|
49
56
|
- Default app image: `ankh/<slug>:dev`.
|
|
50
57
|
- By default `up.sh` triggers `build-app-image.sh` (`APP_BUILD_ENABLED=true`) before apply.
|
|
51
|
-
- `build-app-image.sh` runs `bunx expo export --platform web`, then builds via
|
|
58
|
+
- `build-app-image.sh` runs `bunx expo export --platform web --clear`, then builds via
|
|
59
|
+
`app-image/Dockerfile`. Clearing the Expo/Metro cache ensures rotated `EXPO_PUBLIC_*`
|
|
60
|
+
values replace cached transforms before the browser bundle and Docker image are created.
|
|
52
61
|
- `up.sh` syncs runtime image using `APP_IMAGE_SYNC_STRATEGY`:
|
|
53
62
|
- `docker-load` (default): loads existing local Docker image into the app profile.
|
|
54
63
|
- `minikube-build`: builds exported web artifacts directly into the profile image store.
|
|
@@ -74,6 +83,10 @@ For local Expo/Metro development, `up.sh` also mirrors only the browser-safe
|
|
|
74
83
|
app root `.env.local`, preserving unrelated entries. Restart Expo after Infra Up so the
|
|
75
84
|
client bundle sees the refreshed public Supabase values.
|
|
76
85
|
|
|
86
|
+
Only browser-safe `EXPO_PUBLIC_SUPABASE_URL` and `EXPO_PUBLIC_SUPABASE_ANON_KEY` values are
|
|
87
|
+
eligible for client bundling. Service-role and other privileged Supabase credentials remain
|
|
88
|
+
outside the app public environment and must never be referenced through `EXPO_PUBLIC_*` names.
|
|
89
|
+
|
|
77
90
|
## Practical Selector Rule
|
|
78
91
|
|
|
79
92
|
When building config UI or pickers, only show currently supported values from adapter-owned
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { generateInfrastructure } from '../../../index';
|
|
9
|
+
import { createAppManifest } from '../../../testSupport';
|
|
10
|
+
|
|
11
|
+
const APP_SLUG = 'cache-rotation';
|
|
12
|
+
const PUBLIC_URL = 'http://127.0.0.1:65431';
|
|
13
|
+
const PUBLIC_KEY_A = 'fixture-public-anon-key-a';
|
|
14
|
+
const PUBLIC_KEY_B = 'fixture-public-anon-key-b';
|
|
15
|
+
const PRIVATE_SERVICE_ROLE_KEY = 'fixture-private-service-role-key';
|
|
16
|
+
|
|
17
|
+
test('re-exports rotated public environment values before building the app image', async () => {
|
|
18
|
+
const workspaceRoot = await mkdtemp(path.join(tmpdir(), 'ankh-infra-app-image-'));
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const appRoot = path.join(workspaceRoot, 'apps', APP_SLUG);
|
|
22
|
+
const infraRoot = path.join(appRoot, 'infra', 'minikube');
|
|
23
|
+
const scriptsRoot = path.join(infraRoot, 'scripts');
|
|
24
|
+
const fakeBin = path.join(workspaceRoot, 'bin');
|
|
25
|
+
const buildScriptPath = path.join(scriptsRoot, 'build-app-image.sh');
|
|
26
|
+
const dockerfilePath = path.join(infraRoot, 'app-image', 'Dockerfile');
|
|
27
|
+
const metroCachePath = path.join(workspaceRoot, 'fake-metro-cache');
|
|
28
|
+
const bunxArgsPath = path.join(workspaceRoot, 'bunx-args');
|
|
29
|
+
const dockerArgsPath = path.join(workspaceRoot, 'docker-args');
|
|
30
|
+
const exportDir = path.join(appRoot, '.ankh', 'web-export');
|
|
31
|
+
const bundlePath = path.join(exportDir, '_expo', 'static', 'js', 'web', 'index-fixture.js');
|
|
32
|
+
const generated = generateInfrastructure(
|
|
33
|
+
{
|
|
34
|
+
deployment: { target: 'minikube', monitoring: false },
|
|
35
|
+
auth: { scope: 'global', provider: 'supabase' },
|
|
36
|
+
plugins: [],
|
|
37
|
+
},
|
|
38
|
+
{ appManifest: createAppManifest(APP_SLUG) },
|
|
39
|
+
);
|
|
40
|
+
const buildScript = getGeneratedFile(
|
|
41
|
+
generated.files,
|
|
42
|
+
'infra/minikube/scripts/build-app-image.sh',
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
await mkdir(path.dirname(dockerfilePath), { recursive: true });
|
|
46
|
+
await mkdir(fakeBin, { recursive: true });
|
|
47
|
+
await mkdir(scriptsRoot, { recursive: true });
|
|
48
|
+
await writeFile(path.join(appRoot, 'package.json'), '{}\n', 'utf8');
|
|
49
|
+
await writeFile(dockerfilePath, 'FROM scratch\n', 'utf8');
|
|
50
|
+
await writeFile(buildScriptPath, buildScript, 'utf8');
|
|
51
|
+
await chmod(buildScriptPath, 0o755);
|
|
52
|
+
await writeFakeBunx(path.join(fakeBin, 'bunx'));
|
|
53
|
+
await writeFakeDocker(path.join(fakeBin, 'docker'));
|
|
54
|
+
|
|
55
|
+
await writePublicEnvironment(appRoot, infraRoot, PUBLIC_KEY_A);
|
|
56
|
+
await runBuildScript({
|
|
57
|
+
appRoot,
|
|
58
|
+
buildScriptPath,
|
|
59
|
+
bunxArgsPath,
|
|
60
|
+
dockerArgsPath,
|
|
61
|
+
fakeBin,
|
|
62
|
+
metroCachePath,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const firstBundle = await readFile(bundlePath, 'utf8');
|
|
66
|
+
expect(firstBundle).toContain(PUBLIC_KEY_A);
|
|
67
|
+
expect(firstBundle).not.toContain(PRIVATE_SERVICE_ROLE_KEY);
|
|
68
|
+
|
|
69
|
+
await writePublicEnvironment(appRoot, infraRoot, PUBLIC_KEY_B);
|
|
70
|
+
await runBuildScript({
|
|
71
|
+
appRoot,
|
|
72
|
+
buildScriptPath,
|
|
73
|
+
bunxArgsPath,
|
|
74
|
+
dockerArgsPath,
|
|
75
|
+
fakeBin,
|
|
76
|
+
metroCachePath,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const secondBundle = await readFile(bundlePath, 'utf8');
|
|
80
|
+
expect(secondBundle).toContain(PUBLIC_KEY_B);
|
|
81
|
+
expect(secondBundle).not.toContain(PUBLIC_KEY_A);
|
|
82
|
+
expect(secondBundle).not.toContain(PRIVATE_SERVICE_ROLE_KEY);
|
|
83
|
+
expect((await readFile(bunxArgsPath, 'utf8')).trim().split('\n')).toEqual([
|
|
84
|
+
'expo',
|
|
85
|
+
'export',
|
|
86
|
+
'--platform',
|
|
87
|
+
'web',
|
|
88
|
+
'--clear',
|
|
89
|
+
'--output-dir',
|
|
90
|
+
'.ankh/web-export',
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const dockerArgs = (await readFile(dockerArgsPath, 'utf8')).trim().split('\n');
|
|
94
|
+
expect(dockerArgs.at(-1)).toBe(exportDir);
|
|
95
|
+
expect(dockerArgs).toContain(dockerfilePath);
|
|
96
|
+
} finally {
|
|
97
|
+
await rm(workspaceRoot, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
async function writePublicEnvironment(
|
|
102
|
+
appRoot: string,
|
|
103
|
+
infraRoot: string,
|
|
104
|
+
publicKey: string,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
await writeFile(
|
|
107
|
+
path.join(appRoot, '.env.local'),
|
|
108
|
+
`EXPO_PUBLIC_SUPABASE_URL=${PUBLIC_URL}\nEXPO_PUBLIC_SUPABASE_ANON_KEY=${publicKey}\n`,
|
|
109
|
+
'utf8',
|
|
110
|
+
);
|
|
111
|
+
await writeFile(
|
|
112
|
+
path.join(infraRoot, '.env'),
|
|
113
|
+
`ANKH_APP_SLUG=${APP_SLUG}
|
|
114
|
+
APP_BUILD_ENABLED=true
|
|
115
|
+
APP_WEB_EXPORT_DIR=.ankh/web-export
|
|
116
|
+
APP_IMAGE=ankh/${APP_SLUG}:test
|
|
117
|
+
EXPO_PUBLIC_SUPABASE_URL=${PUBLIC_URL}
|
|
118
|
+
EXPO_PUBLIC_SUPABASE_ANON_KEY=${publicKey}
|
|
119
|
+
SUPABASE_SERVICE_ROLE_KEY=${PRIVATE_SERVICE_ROLE_KEY}
|
|
120
|
+
`,
|
|
121
|
+
'utf8',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function writeFakeBunx(filePath: string): Promise<void> {
|
|
126
|
+
await writeFile(
|
|
127
|
+
filePath,
|
|
128
|
+
`#!/usr/bin/env bash
|
|
129
|
+
set -euo pipefail
|
|
130
|
+
|
|
131
|
+
printf '%s\\n' "$@" > "\${FAKE_BUNX_ARGS_PATH:?}"
|
|
132
|
+
|
|
133
|
+
if [[ "\${1:-}" != "expo" || "\${2:-}" != "export" ]]; then
|
|
134
|
+
echo "Expected expo export invocation." >&2
|
|
135
|
+
exit 64
|
|
136
|
+
fi
|
|
137
|
+
shift 2
|
|
138
|
+
|
|
139
|
+
clear_cache=false
|
|
140
|
+
output_dir=""
|
|
141
|
+
platform=""
|
|
142
|
+
while [[ "$#" -gt 0 ]]; do
|
|
143
|
+
case "$1" in
|
|
144
|
+
--platform)
|
|
145
|
+
platform="\${2:-}"
|
|
146
|
+
shift 2
|
|
147
|
+
;;
|
|
148
|
+
--clear)
|
|
149
|
+
clear_cache=true
|
|
150
|
+
shift
|
|
151
|
+
;;
|
|
152
|
+
--output-dir)
|
|
153
|
+
output_dir="\${2:-}"
|
|
154
|
+
shift 2
|
|
155
|
+
;;
|
|
156
|
+
*)
|
|
157
|
+
echo "Unexpected expo export argument: $1" >&2
|
|
158
|
+
exit 64
|
|
159
|
+
;;
|
|
160
|
+
esac
|
|
161
|
+
done
|
|
162
|
+
|
|
163
|
+
if [[ "\${platform}" != "web" || -z "\${output_dir}" ]]; then
|
|
164
|
+
echo "Expected web platform and output directory." >&2
|
|
165
|
+
exit 64
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
if [[ "\${clear_cache}" == "true" ]]; then
|
|
169
|
+
: > "\${FAKE_METRO_CACHE_PATH:?}"
|
|
170
|
+
fi
|
|
171
|
+
if [[ ! -s "\${FAKE_METRO_CACHE_PATH:?}" ]]; then
|
|
172
|
+
printf '%s' "\${EXPO_PUBLIC_SUPABASE_ANON_KEY:?}" > "\${FAKE_METRO_CACHE_PATH}"
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
cached_public_key="$(<"\${FAKE_METRO_CACHE_PATH}")"
|
|
176
|
+
bundle_dir="\${output_dir}/_expo/static/js/web"
|
|
177
|
+
mkdir -p "\${bundle_dir}"
|
|
178
|
+
printf '<script src="/_expo/static/js/web/index-fixture.js"></script>\\n' > "\${output_dir}/index.html"
|
|
179
|
+
printf 'const supabaseUrl = "%s";\\nconst supabaseAnonKey = "%s";\\n' \
|
|
180
|
+
"\${EXPO_PUBLIC_SUPABASE_URL:?}" \
|
|
181
|
+
"\${cached_public_key}" \
|
|
182
|
+
> "\${bundle_dir}/index-fixture.js"
|
|
183
|
+
`,
|
|
184
|
+
'utf8',
|
|
185
|
+
);
|
|
186
|
+
await chmod(filePath, 0o755);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function writeFakeDocker(filePath: string): Promise<void> {
|
|
190
|
+
await writeFile(
|
|
191
|
+
filePath,
|
|
192
|
+
`#!/usr/bin/env bash
|
|
193
|
+
set -euo pipefail
|
|
194
|
+
|
|
195
|
+
printf '%s\\n' "$@" > "\${FAKE_DOCKER_ARGS_PATH:?}"
|
|
196
|
+
context=""
|
|
197
|
+
for argument in "$@"; do
|
|
198
|
+
context="\${argument}"
|
|
199
|
+
done
|
|
200
|
+
|
|
201
|
+
if [[ ! -f "\${context}/index.html" ]]; then
|
|
202
|
+
echo "Docker context does not contain the fresh web export." >&2
|
|
203
|
+
exit 65
|
|
204
|
+
fi
|
|
205
|
+
`,
|
|
206
|
+
'utf8',
|
|
207
|
+
);
|
|
208
|
+
await chmod(filePath, 0o755);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function runBuildScript(args: {
|
|
212
|
+
appRoot: string;
|
|
213
|
+
buildScriptPath: string;
|
|
214
|
+
bunxArgsPath: string;
|
|
215
|
+
dockerArgsPath: string;
|
|
216
|
+
fakeBin: string;
|
|
217
|
+
metroCachePath: string;
|
|
218
|
+
}): Promise<void> {
|
|
219
|
+
const child = spawn('bash', [args.buildScriptPath], {
|
|
220
|
+
cwd: args.appRoot,
|
|
221
|
+
env: {
|
|
222
|
+
...process.env,
|
|
223
|
+
FAKE_BUNX_ARGS_PATH: args.bunxArgsPath,
|
|
224
|
+
FAKE_DOCKER_ARGS_PATH: args.dockerArgsPath,
|
|
225
|
+
FAKE_METRO_CACHE_PATH: args.metroCachePath,
|
|
226
|
+
PATH: `${args.fakeBin}:${process.env.PATH ?? ''}`,
|
|
227
|
+
},
|
|
228
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
229
|
+
});
|
|
230
|
+
const exitCodePromise = new Promise<number | null>((resolve, reject) => {
|
|
231
|
+
child.once('error', reject);
|
|
232
|
+
child.once('close', resolve);
|
|
233
|
+
});
|
|
234
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
235
|
+
readStream(child.stdout),
|
|
236
|
+
readStream(child.stderr),
|
|
237
|
+
exitCodePromise,
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
if (exitCode !== 0) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Generated build script exited with ${String(exitCode)}. stdout=${stdout} stderr=${stderr}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
|
|
248
|
+
let output = '';
|
|
249
|
+
stream.setEncoding('utf8');
|
|
250
|
+
for await (const chunk of stream) {
|
|
251
|
+
output += String(chunk);
|
|
252
|
+
}
|
|
253
|
+
return output;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getGeneratedFile(
|
|
257
|
+
files: readonly { path: string; content: string }[],
|
|
258
|
+
filePath: string,
|
|
259
|
+
): string {
|
|
260
|
+
const file = files.find((candidate) => candidate.path === filePath);
|
|
261
|
+
if (!file) throw new Error(`Missing generated file: ${filePath}`);
|
|
262
|
+
return file.content;
|
|
263
|
+
}
|
|
@@ -69,10 +69,22 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
|
|
|
69
69
|
const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
|
|
70
70
|
const readme = getFile(result.files, 'infra/minikube/README.md');
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
const migrationFunction = upScript.slice(
|
|
73
|
+
upScript.indexOf('run_supabase_migrations()'),
|
|
74
|
+
upScript.indexOf('reconcile_supabase_profile()'),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(migrationFunction).toContain(
|
|
78
|
+
'SUPABASE_TELEMETRY_DISABLED=1 supabase --yes migration up --db-url "${SUPABASE_DB_URL}"',
|
|
79
|
+
);
|
|
80
|
+
expect(migrationFunction).not.toContain('supabase migration up --db-url "${SUPABASE_DB_URL}"');
|
|
81
|
+
expect(migrationFunction).toContain('echo "Applying pending Supabase migrations..."');
|
|
82
|
+
expect(migrationFunction).toContain('echo "Supabase migrations applied."');
|
|
73
83
|
expect(upScript).toContain('export SUPABASE_DB_URL PGSSLMODE=disable');
|
|
74
84
|
expect(upScript).not.toContain('migration up --local');
|
|
75
85
|
expect(upScript).toContain('cd "${ROOT_DIR}"');
|
|
86
|
+
expect(migrationFunction).toContain('if [[ "${SUPABASE_RUNTIME_ENABLED}" != "true" ]]; then');
|
|
87
|
+
expect(migrationFunction).toContain('if [[ -d "${ROOT_DIR}/supabase/migrations" ]]; then');
|
|
76
88
|
expect(upScript).toContain('set_required_env_default POSTGRES_PASSWORD "$(random_hex 32)"');
|
|
77
89
|
expect(upScript).not.toContain('set_env_default POSTGRES_PASSWORD "$(random_base64');
|
|
78
90
|
expect(upScript).toContain(
|
|
@@ -82,6 +94,30 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
|
|
|
82
94
|
expect(readme).toContain('Migration authoring/history remains Supabase');
|
|
83
95
|
});
|
|
84
96
|
|
|
97
|
+
test('preserves Supabase-disabled migration behavior', () => {
|
|
98
|
+
const result = generateInfrastructure(
|
|
99
|
+
{
|
|
100
|
+
deployment: {
|
|
101
|
+
target: 'minikube',
|
|
102
|
+
monitoring: false,
|
|
103
|
+
},
|
|
104
|
+
plugins: [],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
appManifest: createAppManifest('frontend-only'),
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
|
|
111
|
+
const migrationFunction = upScript.slice(
|
|
112
|
+
upScript.indexOf('run_supabase_migrations()'),
|
|
113
|
+
upScript.indexOf('reconcile_supabase_profile()'),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(upScript).toContain('SUPABASE_RUNTIME_ENABLED="false"');
|
|
117
|
+
expect(migrationFunction).toContain('if [[ "${SUPABASE_RUNTIME_ENABLED}" != "true" ]]; then');
|
|
118
|
+
expect(migrationFunction).toContain('return 0');
|
|
119
|
+
});
|
|
120
|
+
|
|
85
121
|
test('lists and generates Supabase Kubernetes bootstrap and gateway contract files', () => {
|
|
86
122
|
const result = generateInfrastructure(createSupabaseManifest(), {
|
|
87
123
|
appManifest: createAppManifest('scanner'),
|
|
@@ -338,6 +374,8 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
|
|
|
338
374
|
appManifest: createAppManifest('books'),
|
|
339
375
|
});
|
|
340
376
|
const upScript = getFile(result.files, 'infra/minikube/scripts/up.sh');
|
|
377
|
+
const buildScript = getFile(result.files, 'infra/minikube/scripts/build-app-image.sh');
|
|
378
|
+
const readme = getFile(result.files, 'infra/minikube/README.md');
|
|
341
379
|
|
|
342
380
|
expect(upScript).toContain('sync_app_public_supabase_env()');
|
|
343
381
|
expect(upScript).toContain('local app_env_file="${APP_SOURCE_DIR}/.env.local"');
|
|
@@ -352,6 +390,12 @@ describe('generateMinikubeBaseArtifacts app-owned cluster model', () => {
|
|
|
352
390
|
upScript.indexOf('if [[ "${APP_BUILD_ENABLED}" == "true" ]]'),
|
|
353
391
|
);
|
|
354
392
|
expect(upScript).not.toContain('SUPABASE_SERVICE_ROLE_KEY" "${SUPABASE_SERVICE_ROLE_KEY}"');
|
|
393
|
+
expect(buildScript).toContain(
|
|
394
|
+
'bunx expo export --platform web --clear --output-dir "${APP_WEB_EXPORT_DIR}"',
|
|
395
|
+
);
|
|
396
|
+
expect(readme).toContain(
|
|
397
|
+
'`build-app-image.sh` clears the Expo/Metro bundler cache before every web export.',
|
|
398
|
+
);
|
|
355
399
|
});
|
|
356
400
|
|
|
357
401
|
test('starts Supabase schema owners before app migrations and profile reconciliation', () => {
|
|
@@ -3,6 +3,7 @@ import type { InfraManifestInput } from '../../../types';
|
|
|
3
3
|
import type { ResolvedProfileModel } from '../auth/supabase/profile';
|
|
4
4
|
import { resolveSupabaseProfileModel } from '../auth/supabase/profile';
|
|
5
5
|
import type { MinikubeProviderLifecycle } from '../contracts';
|
|
6
|
+
import { getSupabaseMigrationCommandScript } from './supabaseMigrations';
|
|
6
7
|
|
|
7
8
|
export const APP_NAMESPACE = 'app';
|
|
8
9
|
const SUPABASE_NAMESPACE = 'supabase';
|
|
@@ -379,7 +380,7 @@ host-level Supabase Compose runtime.
|
|
|
379
380
|
|
|
380
381
|
## Lifecycle Semantics
|
|
381
382
|
|
|
382
|
-
- \`up.sh\`: starts \`minikube -p ${appSlug}\`, deploys provider namespaces, runs migrations with
|
|
383
|
+
- \`up.sh\`: starts \`minikube -p ${appSlug}\`, deploys provider namespaces, runs migrations with CLI telemetry disabled for that command, starts slug-owned port-forwards, and deploys the app runtime.
|
|
383
384
|
- \`down.sh\`: stops slug-owned port-forwards, then stops \`minikube -p ${appSlug}\`. Persistent data remains in the profile.
|
|
384
385
|
- \`reset.sh\`: requires \`ANKH_RESET_CONFIRM=${appSlug}\`; ${resetDescription}. It does not delete the Minikube profile.
|
|
385
386
|
- \`destroy.sh\`: deletes only \`minikube -p ${appSlug}\`.
|
|
@@ -392,7 +393,13 @@ ${resourceLines}
|
|
|
392
393
|
|
|
393
394
|
Supabase runtime ownership is Kubernetes. Migration authoring/history remains Supabase
|
|
394
395
|
migration files. Migration execution targets the Kubernetes Postgres endpoint through
|
|
395
|
-
\`supabase migration up --db-url "$SUPABASE_DB_URL"\`.
|
|
396
|
+
\`SUPABASE_TELEMETRY_DISABLED=1 supabase --yes migration up --db-url "$SUPABASE_DB_URL"\`.
|
|
397
|
+
The migration process does not require stdin. \`--yes\` remains defense in depth for future
|
|
398
|
+
CLI prompts; it is not the hang fix. Command-scoped telemetry disable avoids the unbounded
|
|
399
|
+
telemetry shutdown in Supabase CLI 2.106.0 when PostHog is blocked, without changing the
|
|
400
|
+
user's persistent telemetry preference. Migration failures stop \`up.sh\`, and the completion
|
|
401
|
+
message is printed only after a zero exit status. No migration deadline is added; using a
|
|
402
|
+
current Supabase CLI remains recommended.
|
|
396
403
|
|
|
397
404
|
Supabase manifests are generated from the current official Supabase self-hosting Docker
|
|
398
405
|
topology, service documentation, environment-variable contracts, and pinned official images.
|
|
@@ -417,6 +424,13 @@ Pinned images:
|
|
|
417
424
|
- Studio: \`http://127.0.0.1:${supabaseHostPorts.studio}\`
|
|
418
425
|
- DB migration endpoint: \`127.0.0.1:${supabaseHostPorts.db}\`
|
|
419
426
|
|
|
427
|
+
## App Image Export
|
|
428
|
+
|
|
429
|
+
\`build-app-image.sh\` clears the Expo/Metro bundler cache before every web export. Expo
|
|
430
|
+
inlines \`EXPO_PUBLIC_*\` values into the browser bundle, so cache invalidation ensures a
|
|
431
|
+
rotated browser-safe Supabase URL or anon key replaces the previous value before the Docker
|
|
432
|
+
image is built. Privileged Supabase credentials are not written to the app's public env file.
|
|
433
|
+
|
|
420
434
|
## Runtime Conventions
|
|
421
435
|
|
|
422
436
|
- Monitoring requested: \`${monitoringEnabled ? 'true' : 'false'}\`
|
|
@@ -2266,6 +2280,7 @@ function getUpScript(args: {
|
|
|
2266
2280
|
oauthProviders,
|
|
2267
2281
|
secretStoreProvider,
|
|
2268
2282
|
});
|
|
2283
|
+
const supabaseMigrationCommand = getSupabaseMigrationCommandScript();
|
|
2269
2284
|
|
|
2270
2285
|
return `#!/usr/bin/env bash
|
|
2271
2286
|
set -Eeuo pipefail
|
|
@@ -2621,7 +2636,7 @@ run_supabase_migrations() {
|
|
|
2621
2636
|
require_command supabase
|
|
2622
2637
|
(
|
|
2623
2638
|
cd "\${ROOT_DIR}"
|
|
2624
|
-
|
|
2639
|
+
${indentShellBlock(supabaseMigrationCommand, 6)}
|
|
2625
2640
|
)
|
|
2626
2641
|
fi
|
|
2627
2642
|
}
|
|
@@ -2922,7 +2937,7 @@ fi
|
|
|
2922
2937
|
|
|
2923
2938
|
(
|
|
2924
2939
|
cd "\${APP_SOURCE_DIR}"
|
|
2925
|
-
bunx expo export --platform web --output-dir "\${APP_WEB_EXPORT_DIR}"
|
|
2940
|
+
bunx expo export --platform web --clear --output-dir "\${APP_WEB_EXPORT_DIR}"
|
|
2926
2941
|
)
|
|
2927
2942
|
|
|
2928
2943
|
if [[ "\${APP_WEB_EXPORT_DIR}" = /* ]]; then
|