@rulebricks/cli 2.3.1 → 2.3.2

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.
@@ -8,8 +8,15 @@ import YAML from "yaml";
8
8
  // Names of the Kubernetes Secrets the CLI creates in k8s secret mode. Shared by
9
9
  // the value generator (which sets the secretRef fields) and src/lib/secrets.ts
10
10
  // (which creates the Secrets) so they always agree.
11
+ //
12
+ // The base MUST be the Helm release name, not config.name. Most chart consumers
13
+ // read the secretRef *value* (name-agnostic), but a few templates hardcode the
14
+ // canonical <release>-* name — e.g. templates/migration-job.yaml derives
15
+ // DB_PASSWORD from `{{ .Release.Name }}-supabase-db`. Naming these secrets with
16
+ // the release name keeps the CLI a faithful drop-in for the unmodified chart so
17
+ // we never have to customize the chart to match the CLI.
11
18
  export function deploymentSecretNames(config) {
12
- const base = config.name;
19
+ const base = getReleaseName(config.name);
13
20
  return {
14
21
  app: `${base}-app-secrets`,
15
22
  db: `${base}-supabase-db`,
@@ -2075,7 +2082,19 @@ export function buildHelmValues(config, options = {}) {
2075
2082
  ingress: {
2076
2083
  enabled: true,
2077
2084
  className: "traefik",
2078
- annotations: {},
2085
+ // The supabase subchart's kong ingress does NOT emit Traefik's
2086
+ // router.entrypoints/router.tls annotations the way the app
2087
+ // ingress does — without them Traefik only builds a web (HTTP)
2088
+ // router, so https://supabase.<domain> 404s and the app can't
2089
+ // reach Supabase. Inject them via the subchart's annotations
2090
+ // passthrough (kong/ingress.yaml ranges over these), matching
2091
+ // charts/rulebricks/templates/ingress.yaml.
2092
+ annotations: {
2093
+ "traefik.ingress.kubernetes.io/router.entrypoints": tlsEnabled ? "websecure" : "web",
2094
+ "traefik.ingress.kubernetes.io/router.tls": tlsEnabled
2095
+ ? "true"
2096
+ : "false",
2097
+ },
2079
2098
  },
2080
2099
  },
2081
2100
  studio: {
@@ -2247,6 +2266,35 @@ export function buildHelmValues(config, options = {}) {
2247
2266
  enabled: false,
2248
2267
  },
2249
2268
  };
2269
+ // The managed-Postgres migration hook (templates/migration-job.yaml) reads the
2270
+ // DB host/port from .Values.migrations.externalDb — a SEPARATE seam from
2271
+ // supabase.externalDatabase.* — and its `pg_isready -h $DB_HOST` loop hangs
2272
+ // forever (empty host) if it is unset. Wire it for external Postgres. We only
2273
+ // set host/port: DB_PASSWORD falls back to the <release>-supabase-db secret and
2274
+ // DB_USER/DB_NAME default to "postgres", which match deploymentSecretNames()
2275
+ // and the bootstrap app role.
2276
+ const migrationsPgExt = config.database.type === "self-hosted" &&
2277
+ config.externalServices?.postgres?.mode === "external"
2278
+ ? config.externalServices.postgres.external
2279
+ : undefined;
2280
+ if (migrationsPgExt) {
2281
+ values.migrations = {
2282
+ externalDb: {
2283
+ host: migrationsPgExt.host ?? "",
2284
+ // Chart schema requires a string here (the template quotes it).
2285
+ port: String(migrationsPgExt.port ?? 5432),
2286
+ // Run migrations as the master/app_role. The bootstrap hook creates the
2287
+ // service login roles (authenticator, supabase_auth_admin, …) with the
2288
+ // service password but deliberately does NOT change the master's
2289
+ // password (bootstrap.sql runs "as the master user (named postgres)").
2290
+ // So the migrate hook must authenticate with the MASTER credential, not
2291
+ // the service password in <release>-supabase-db (that would 401). Point
2292
+ // DB_PASSWORD at the bootstrap Secret's master-password.
2293
+ existingSecret: deploymentSecretNames(config).dbBootstrap,
2294
+ existingSecretKey: "master-password",
2295
+ },
2296
+ };
2297
+ }
2250
2298
  // In k8s secret mode, the CLI creates Kubernetes Secrets and the chart reads
2251
2299
  // them by reference. Point the chart's secretRef seams at those Secrets and
2252
2300
  // strip every plaintext secret out of the generated values.
@@ -2277,7 +2325,13 @@ export function redactSecretsToRefs(values, config) {
2277
2325
  }
2278
2326
  if (global.supabase) {
2279
2327
  delete global.supabase.jwtSecret;
2280
- delete global.supabase.anonKey;
2328
+ // NOTE: anonKey is intentionally NOT stripped. It is the *public* Supabase
2329
+ // key that app-configmap.yaml embeds into the Next.js client bundle
2330
+ // (SUPABASE_PUBLIC_KEY / NEXT_PUBLIC_SUPABASE_PUBLIC_KEY). That ConfigMap
2331
+ // reads global.supabase.anonKey at TEMPLATE time and there is no secretRef
2332
+ // seam for it, so stripping it leaves the browser client with an empty key.
2333
+ // It is a public token (safe in a ConfigMap by design) and never appears in
2334
+ // the k8s-mode secret-leak checks.
2281
2335
  delete global.supabase.serviceKey;
2282
2336
  delete global.supabase.accessToken;
2283
2337
  }
@@ -2287,7 +2341,16 @@ export function redactSecretsToRefs(values, config) {
2287
2341
  delete global.sso.clientId;
2288
2342
  delete global.sso.clientSecret;
2289
2343
  }
2290
- delete global.licenseKey;
2344
+ // NOTE: licenseKey is intentionally NOT stripped. The (standard) chart builds
2345
+ // the image-pull secret <release>-regcred from inline global.licenseKey at
2346
+ // TEMPLATE time (templates/registry-secret.yaml -> imagePullSecret helper). A
2347
+ // Kubernetes imagePullSecret cannot be sourced from a secretRef, so the chart
2348
+ // has no k8s-mode seam for it — stripping it makes the chart fall back to the
2349
+ // "evaluation" placeholder -> dckr_pat_evaluation -> 401 on every private
2350
+ // rulebricks/* image. Standalone chart users set global.licenseKey in their own
2351
+ // values for exactly this reason; the CLI must do the same to stay compatible
2352
+ // with the unmodified chart. It is a Docker Hub read-only PAT and already lives
2353
+ // in the deployment's config.yaml, so keeping it inline adds no new exposure.
2291
2354
  // Supabase subchart: replace each inline secret block with a secretRef.
2292
2355
  if (supabase.secret) {
2293
2356
  const dbSecret = { secretRef: names.db };
@@ -2381,6 +2444,22 @@ export async function updateHelmValuesForTLS(deploymentName, tlsEnabled) {
2381
2444
  }
2382
2445
  }
2383
2446
  }
2447
+ // Keep the supabase kong ingress on the right Traefik entrypoint. The
2448
+ // subchart doesn't emit router.entrypoints/tls itself, so on the TLS-toggle
2449
+ // path (not a full regen) HTTPS to supabase.<domain> would 404 without this.
2450
+ // Mirrors what buildHelmValues sets on the kong ingress annotations.
2451
+ const supabase = values.supabase;
2452
+ const kongIngress = supabase?.kong
2453
+ ?.ingress;
2454
+ if (kongIngress && typeof kongIngress === "object") {
2455
+ kongIngress.annotations = {
2456
+ ...kongIngress.annotations,
2457
+ "traefik.ingress.kubernetes.io/router.entrypoints": tlsEnabled
2458
+ ? "websecure"
2459
+ : "web",
2460
+ "traefik.ingress.kubernetes.io/router.tls": tlsEnabled ? "true" : "false",
2461
+ };
2462
+ }
2384
2463
  // Save updated values
2385
2464
  await fs.writeFile(valuesPath, YAML.stringify(values), "utf8");
2386
2465
  }
@@ -723,7 +723,7 @@ test("external Postgres k8s secret mode keeps compatibility and uses secret refs
723
723
  // the Secret keys below.
724
724
  assert.equal(sb.externalDatabase.host, "db.cluster-xxxx.us-east-1.rds.amazonaws.com");
725
725
  assert.equal(sb.externalDatabase.port, 5432);
726
- assert.equal(sb.externalDatabase.secretRef, `${config.name}-supabase-db`);
726
+ assert.equal(sb.externalDatabase.secretRef, `${getReleaseName(config.name)}-supabase-db`);
727
727
  assert.deepEqual(sb.externalDatabase.secretRefKey, {
728
728
  host: "host",
729
729
  port: "port",
@@ -731,9 +731,9 @@ test("external Postgres k8s secret mode keeps compatibility and uses secret refs
731
731
  password: "password",
732
732
  database: "database",
733
733
  });
734
- assert.equal(sb.externalDatabase.bootstrap.secretRef, `${config.name}-supabase-db-bootstrap`);
734
+ assert.equal(sb.externalDatabase.bootstrap.secretRef, `${getReleaseName(config.name)}-supabase-db-bootstrap`);
735
735
  assert.equal(sb.externalDatabase.bootstrap.masterPassword, undefined);
736
- assert.equal(sb.secret.db.secretRef, `${config.name}-supabase-db`);
736
+ assert.equal(sb.secret.db.secretRef, `${getReleaseName(config.name)}-supabase-db`);
737
737
  assert.deepEqual(sb.secret.db.secretRefKey, {
738
738
  host: "host",
739
739
  port: "port",
@@ -750,7 +750,7 @@ test("embedded Postgres still deploys the bundled database", () => {
750
750
  });
751
751
  import { buildDeploymentSecrets } from "./secrets.js";
752
752
  import { deriveRealtimeSecrets } from "./helmValues.js";
753
- test("k8s secret mode: secretRefs set, zero plaintext secrets in values", () => {
753
+ test("k8s secret mode: secretRefs set, app secrets kept out of values (license stays inline for the pull secret)", () => {
754
754
  const config = cloneFixture("aws-self-hosted-minimal");
755
755
  config.features.ai = {
756
756
  enabled: true,
@@ -765,22 +765,27 @@ test("k8s secret mode: secretRefs set, zero plaintext secrets in values", () =>
765
765
  const schemaResult = validateHelmValues(values);
766
766
  assert.ok(schemaResult.valid, `k8s secret-mode values should satisfy chart schema:\n${schemaResult.errors.join("\n")}`);
767
767
  // secretRef seams point at the CLI-created Secrets
768
- assert.equal(values.global.secrets.secretRef, `${config.name}-app-secrets`);
769
- assert.equal(values.supabase.secret.db.secretRef, `${config.name}-supabase-db`);
770
- assert.equal(values.supabase.secret.jwt.secretRef, `${config.name}-supabase-jwt`);
771
- assert.equal(values.supabase.secret.dashboard.secretRef, `${config.name}-supabase-dashboard`);
772
- assert.equal(values.supabase.secret.realtime.secretRef, `${config.name}-supabase-realtime`);
773
- // inline plaintext stripped
768
+ assert.equal(values.global.secrets.secretRef, `${getReleaseName(config.name)}-app-secrets`);
769
+ assert.equal(values.supabase.secret.db.secretRef, `${getReleaseName(config.name)}-supabase-db`);
770
+ assert.equal(values.supabase.secret.jwt.secretRef, `${getReleaseName(config.name)}-supabase-jwt`);
771
+ assert.equal(values.supabase.secret.dashboard.secretRef, `${getReleaseName(config.name)}-supabase-dashboard`);
772
+ assert.equal(values.supabase.secret.realtime.secretRef, `${getReleaseName(config.name)}-supabase-realtime`);
773
+ // Genuinely-sensitive app secrets are stripped (delivered via secretRef).
774
774
  assert.equal(values.global.supabase.jwtSecret, undefined);
775
775
  assert.equal(values.global.ai.openaiApiKey, undefined);
776
- assert.equal(values.global.licenseKey, undefined);
777
- // no secret value appears anywhere in the generated values
776
+ // These two MUST stay inline: the standard (unmodified) chart consumes them at
777
+ // Helm TEMPLATE time with no secretRef seam.
778
+ // - licenseKey -> registry-secret.yaml builds the <release>-regcred pull
779
+ // secret. Stripping it => dckr_pat_evaluation => 401 on every private image.
780
+ // - anonKey (public) -> app-configmap.yaml NEXT_PUBLIC_SUPABASE_PUBLIC_KEY.
781
+ assert.equal(values.global.licenseKey, license);
782
+ assert.ok(values.global.supabase.anonKey, "public anonKey must remain inline in k8s mode for the app ConfigMap");
783
+ // The genuinely-sensitive app secrets never appear in the generated values.
778
784
  const dump = JSON.stringify(values);
779
785
  for (const [label, secret] of [
780
786
  ["db password", dbPw],
781
787
  ["jwt secret", jwt],
782
788
  ["dashboard password", dashPw],
783
- ["license key", license],
784
789
  ["openai key", openai],
785
790
  ]) {
786
791
  assert.ok(!dump.includes(secret), `${label} leaked into k8s-mode values`);
@@ -797,7 +802,7 @@ test("k8s secret mode: SSO + AI configs validate against the chart schema", () =
797
802
  assert.equal(values.global.sso.clientId, undefined);
798
803
  assert.equal(values.global.sso.clientSecret, undefined);
799
804
  assert.equal(values.global.ai.openaiApiKey, undefined);
800
- assert.equal(values.global.secrets.secretRef, `${config.name}-app-secrets`);
805
+ assert.equal(values.global.secrets.secretRef, `${getReleaseName(config.name)}-app-secrets`);
801
806
  });
802
807
  test("k8s secret mode: managed Supabase config validates against the chart schema", () => {
803
808
  // Managed (Supabase Cloud) redacts the access token into the app Secret; the
@@ -808,7 +813,7 @@ test("k8s secret mode: managed Supabase config validates against the chart schem
808
813
  assert.ok(result.valid, `k8s-mode managed-Supabase values should satisfy the chart schema:\n${result.errors.join("\n")}`);
809
814
  assert.equal(values.global.supabase.accessToken, undefined);
810
815
  assert.ok(values.global.supabase.url);
811
- assert.equal(values.global.secrets.secretRef, `${config.name}-app-secrets`);
816
+ assert.equal(values.global.secrets.secretRef, `${getReleaseName(config.name)}-app-secrets`);
812
817
  });
813
818
  test("inline secret mode keeps secrets in values (dev path)", () => {
814
819
  const config = cloneFixture("aws-self-hosted-minimal");
@@ -824,7 +829,7 @@ test("buildDeploymentSecrets: app + supabase secrets with JWT-derived keys", ()
824
829
  const config = cloneFixture("aws-self-hosted-minimal");
825
830
  const jwt = config.database.supabaseJwtSecret;
826
831
  const byName = Object.fromEntries(buildDeploymentSecrets(config).map((s) => [s.name, s.stringData]));
827
- const base = config.name;
832
+ const base = getReleaseName(config.name);
828
833
  assert.equal(byName[`${base}-app-secrets`].LICENSE_KEY, config.licenseKey);
829
834
  assert.equal(byName[`${base}-supabase-db`].password, config.database.supabaseDbPassword);
830
835
  assert.equal(byName[`${base}-supabase-jwt`].anonKey, signSupabaseJwt("anon", jwt));
@@ -837,7 +842,7 @@ test("buildDeploymentSecrets: app + supabase secrets with JWT-derived keys", ()
837
842
  test("buildDeploymentSecrets includes external Postgres host/port and bootstrap creds", () => {
838
843
  const config = cloneFixture("aws-external-postgres");
839
844
  const byName = Object.fromEntries(buildDeploymentSecrets(config).map((s) => [s.name, s.stringData]));
840
- const base = config.name;
845
+ const base = getReleaseName(config.name);
841
846
  assert.deepEqual(byName[`${base}-supabase-db`], {
842
847
  username: "postgres",
843
848
  password: config.database.supabaseDbPassword,
@@ -851,6 +856,47 @@ test("buildDeploymentSecrets includes external Postgres host/port and bootstrap
851
856
  "service-password": config.database.supabaseDbPassword,
852
857
  });
853
858
  });
859
+ test("external Postgres wires migrations.externalDb host/port for the migration hook", () => {
860
+ // templates/migration-job.yaml reads DB_HOST from .Values.migrations.externalDb
861
+ // (not supabase.externalDatabase). If unset, pg_isready gets an empty host and
862
+ // the migrate hook hangs until Helm times out. Guards that regression.
863
+ const config = cloneFixture("aws-external-postgres");
864
+ const values = buildHelmValues(config, { secretMode: "k8s" });
865
+ assert.ok(values.migrations?.externalDb, "migrations.externalDb must be set for external Postgres");
866
+ assert.ok(values.migrations.externalDb.host, "migration-hook DB_HOST must be non-empty");
867
+ assert.equal(values.migrations.externalDb.host, values.supabase.externalDatabase.host);
868
+ assert.equal(values.migrations.externalDb.port, "5432");
869
+ // Migrations run as the master (bootstrap only sets service-role passwords, not
870
+ // the master's), so DB_PASSWORD must come from the bootstrap Secret's
871
+ // master-password, NOT the service password in <release>-supabase-db.
872
+ assert.equal(values.migrations.externalDb.existingSecret, `${getReleaseName(config.name)}-supabase-db-bootstrap`);
873
+ assert.equal(values.migrations.externalDb.existingSecretKey, "master-password");
874
+ // Bundled-Postgres deploys must NOT set it (chart uses the internal service).
875
+ const internal = buildHelmValues(cloneFixture("aws-self-hosted-minimal"), {
876
+ secretMode: "k8s",
877
+ });
878
+ assert.equal(internal.migrations?.externalDb, undefined);
879
+ });
880
+ test("supabase kong ingress carries Traefik websecure router annotations under TLS", () => {
881
+ // The supabase subchart's kong ingress doesn't emit router.entrypoints/tls
882
+ // itself, so Traefik only builds a web router and https://supabase.<domain>
883
+ // 404s. The CLI must inject them (via the subchart's annotations passthrough).
884
+ const config = cloneFixture("aws-self-hosted-minimal");
885
+ const tls = buildHelmValues(config, {
886
+ tlsEnabled: true,
887
+ secretMode: "k8s",
888
+ });
889
+ const a = tls.supabase.kong.ingress.annotations;
890
+ assert.equal(a["traefik.ingress.kubernetes.io/router.entrypoints"], "websecure");
891
+ assert.equal(a["traefik.ingress.kubernetes.io/router.tls"], "true");
892
+ const notls = buildHelmValues(config, {
893
+ tlsEnabled: false,
894
+ secretMode: "k8s",
895
+ });
896
+ const b = notls.supabase.kong.ingress.annotations;
897
+ assert.equal(b["traefik.ingress.kubernetes.io/router.entrypoints"], "web");
898
+ assert.equal(b["traefik.ingress.kubernetes.io/router.tls"], "false");
899
+ });
854
900
  // ===========================================================================
855
901
  // Image registry / digest pinning (docker.io/rulebricks/* + global.imageRegistry)
856
902
  // ===========================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulebricks/cli",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
4
4
  "description": "CLI for deploying and managing private Rulebricks instances",
5
5
  "type": "module",
6
6
  "bin": {