Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/cli/src/command-internal/stack-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getDefaultCliConfig, type CliConfig } from "@supabase/config";
import { resolveCliConfigSubtree } from "@supabase/config/internal";
import { ENV_CAPTURE_REGEX, resolveCliConfigSubtree } from "@supabase/config/internal";
import { validateCliConfig } from "@supabase/config/effect";
import {
DEFAULT_LOCAL_JWT_SECRET,
Expand Down Expand Up @@ -717,6 +717,14 @@ const unsupportedConfigPaths = [
"experimental.s3_secret_key",
] as const;

/** An unset `env(NAME)` stays as that literal, which is not a configured value. */
const unresolvedEnvLiteral = (value: unknown): boolean => {
if (typeof value === "string") return ENV_CAPTURE_REGEX.test(value);
if (!Redacted.isRedacted(value)) return false;
const inner = Redacted.value(value);
return typeof inner === "string" && ENV_CAPTURE_REGEX.test(inner);
};

const pathValue = (value: unknown, path: string): unknown => {
let current = value;
for (const segment of path.split(".")) {
Expand Down Expand Up @@ -779,6 +787,7 @@ const configValidationError = (config: CliConfig): string | undefined => {
for (const path of unsupportedConfigPaths) {
if (path.startsWith("auth.") && !config.auth.enabled) continue;
const value = pathValue(config, path);
if (path.startsWith("experimental.s3_") && unresolvedEnvLiteral(value)) continue;
const baseline = pathValue(defaults, path);
const difference = firstDifference(value, baseline, path);
if (difference !== undefined) return `${difference} is unsupported by the experimental stack`;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BunServices } from "@effect/platform-bun";
import { describe, expect, it } from "@effect/vitest";
import { afterEach, describe, expect, it, vi } from "@effect/vitest";
import { DEFAULT_LOCAL_JWT_SECRET, DEFAULT_POSTGRES_ROOT_KEY } from "@supabase/stack";
import { Effect, Exit, Layer, Redacted, Schema } from "effect";
import { ServiceCreation } from "../../../../../../packages/stack/src/services/Catalog.ts";
Expand All @@ -23,6 +23,10 @@ const byService = (services: ReadonlyArray<Schema.Schema.Type<typeof ServiceCrea
new Map(services.map((service) => [service.service, service]));

describe("loadStackConfig", () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it.live("decodes the default recipe and leaves listeners automatic", () =>
Effect.gen(function* () {
const root = yield* project(`project_id = "stack-config-defaults"
Expand Down Expand Up @@ -172,6 +176,35 @@ major_version = 14
}).pipe(Effect.provide(BunServices.layer)),
);

it.live("ignores unresolved experimental S3 env placeholders", () => {
for (const name of ["s3_host", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"])
vi.stubEnv(name, "");
return Effect.gen(function* () {
const root = yield* project(`project_id = "stack-config-s3-placeholder"
[experimental]
s3_host = "env(s3_host)"
s3_region = "env(S3_REGION)"
s3_access_key = "env(S3_ACCESS_KEY)"
s3_secret_key = "env(S3_SECRET_KEY)"
`);
const config = yield* load(root);
const services = yield* config.creations("stack-s3-placeholder");
expect(services.some((service) => service.service === "database")).toBe(true);
}).pipe(Effect.provide(BunServices.layer));
});

it.live("still rejects an unresolved auth service role key", () =>
Effect.gen(function* () {
const root = yield* project(`project_id = "stack-config-auth-key"
[auth]
service_role_key = "env(SUPABASE_AUTH_SERVICE_ROLE_KEY)"
`);
const exit = yield* load(root).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("auth.service_role_key");
}).pipe(Effect.provide(BunServices.layer)),
);

it.live("rejects unsupported OrioleDB and experimental S3 settings", () =>
Effect.gen(function* () {
const orioledb = yield* project(`project_id = "stack-config-orioledb"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { endpointReports } from "../stack-endpoints.format.ts";
import { readStackFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts";
import { defaultStackRuntime } from "../../../../command-internal/stack-runtime.ts";
import { Effect, Equal, FileSystem, Fiber, Option, Path, Redacted, Ref } from "effect";
import type { ServiceCreation, Stack } from "@supabase/stack/effect";
import { nativePostgresRootError, type ServiceCreation, type Stack } from "@supabase/stack/effect";
import { Output } from "../../../../shared/output/output.service.ts";
import {
OutputFlag,
Expand Down Expand Up @@ -207,6 +207,10 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
}),
),
);
const selectedRuntime = target.runtime ?? defaultStackRuntime(runtime);
const rootError = nativePostgresRootError(selectedRuntime, process.getuid?.());
Comment thread
avallete marked this conversation as resolved.
Resolved
if (rootError !== undefined)
return yield* new StackCommandStartError({ reason: "lifecycle", message: rootError });
Comment thread
avallete marked this conversation as resolved.
Resolved
const stateRoot = path.join(settings.supabaseHome, "stacks");
const cacheRoot = path.join(settings.supabaseHome, "cache", "stack");
const stack =
Expand All @@ -216,7 +220,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
projectRoot: target.projectRoot,
stateRoot,
cacheRoot,
runtime: target.runtime ?? defaultStackRuntime(runtime),
runtime: selectedRuntime,
...(target.name === undefined ? {} : { name: target.name }),
})
.pipe(Effect.mapError(stackError))
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0026-slim-artifact-mirrors.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,22 @@

Slim service images publish to `ghcr.io/supabase/cli/<service>:<version>`. Native archives publish to GitHub Releases. A broken build must be replaceable under the **same** upstream version string (`force=true`); bumping the tag is not an option.

Some consumers cannot reach GitHub release assets or a single registry blob CDN. Publishing one digest to more than one host is the prerequisite for later client fail-through; this ADR covers the publish path only.
Some consumers cannot reach GitHub release assets or a single registry blob CDN. This ADR covers publishing those copies and the local stack's fail-through.

## Decision

Publish each slim **image** to GHCR and AWS ECR Public (`public.ecr.aws/supabase/cli/<service>`), digest-preserving, via `mirror-slim-image.yml` and `.github/scripts/mirror-slim-image.ts`. Publish each slim **native** archive as an OCI artifact on the same two repositories under `:version-native-<target>` (not `:version-linux-*`, which are image platform tags). GitHub Releases remain the human HTTPS copy and `--clobber` on force.

`force=true` always requests the ECR copy, including when the image digest is unchanged (natives may have moved). The **image** destination digest gates `publish-release` once the dispatch token exists. Native ECR copy is best-effort (`continue-on-error`) and must not fail that release. Native tags ride in a separate `natives[]` payload field. This mirror does not open a pull request to pin `packages/stack/src/Artifacts.ts`. A Dependabot Dockerfile bump commits that catalog pin onto the same pull request, so the docker.io tag, the slim image, and the native archive stay on one version. A PostgreSQL release line that is not the Dockerfile `pg` tag, and a same-version digest republish, stay put until that Dockerfile line changes. Do not prune untagged GHCR or ECR manifests: already-shipped CLIs still pin old image digests until that pull request merges. Natives follow the moved tag immediately.

ECR Public tags are always mutable; `ecr-public create-repository` has no immutability flag. Reuse the existing `PROD_AWS_ROLE` in `supabase/cli`. Do not add a public S3 bucket or a second cloud vendor for this cut.
ECR Public tags are always mutable; `ecr-public create-repository` has no immutability flag. Reuse the existing `PROD_AWS_ROLE` in `supabase/cli`.

Client fail-through (ordered GHCR / ECR / GitHub candidates in the CLI and stack) is out of scope here and lands in a follow-up PR.
A public S3 bucket on `*.amazonaws.com` holds native archives for hosts that cannot reach GitHub Releases ([infra/cli-artifacts](../../infra/cli-artifacts/README.md)). It is not a checksum authority. The stack tries the GitHub Release first and falls back to that bucket. The expected checksum comes from the release `SHA256SUMS` or, when that is blocked, from the archive layer of the `:version-native-<target>` artifact on GHCR. An archive from either host is accepted only when it matches.

The container runtime pulls a catalog image from GHCR first and falls back to the same reference on ECR Public. A failing fallback, for an image or a native archive, still reports the primary's original error.

## Follow-up

- Consume the mirrored image tags and `:version-native-<target>` artifacts from the CLI and local stack, with host-aware order and digest pins preserved ([ADR 0017](0017-simplified-managed-stack-architecture.md)).
- Claude Trusted lists `public.ecr.aws` and `ghcr.io` but 403s blob CDNs (`*.cloudfront.net`, `pkg-containers.githubusercontent.com`) and GitHub release assets for unattached repos ([claude-code#71629](https://github.com/anthropics/claude-code/issues/71629)). Later options: S3 HTTPS on `*.amazonaws.com`, or Anthropic adding blob hosts.
- Native ECR copy is best-effort; a daily mirror audit should report native drift. That audit is not defined in this repository yet.

## Rationale
Expand All @@ -32,14 +32,14 @@ ECR Public mirroring already exists for other CLI images and needs no new vendor
## Consequences

- Same-version overwrite works on GHCR, GitHub Releases, and ECR Public.
- Until the follow-up client PR, local stack and CLI still pull the GHCR catalog pin and GitHub Releases for natives.
- Images come from the GHCR catalog pin with ECR Public as fallback, and natives from GitHub Releases with the S3 bucket as fallback.
- A GitHub native can be live while the ECR native is stale.
- Old CLI releases keep pulling the previous image digest.

## Alternatives considered

1. **Google Artifact Registry / GCS** — blob host `storage.googleapis.com` is on at least one major sandbox Trusted list. Rejected for this cut: new company-wide vendor.
2. **Public S3 bucket** — HTTPS GET on `*.amazonaws.com` can work without CloudFront. Rejected for this cut: new public bucket, IAM, and security review on `PROD_AWS_ROLE`.
2. **Public S3 bucket** — HTTPS GET on `*.amazonaws.com` can work without CloudFront. Adopted for native archives. The bucket is not a checksum authority, and image pulls stay on GHCR and ECR Public.
3. **npm packages** — allowlisted widely, but a published version cannot be replaced.
4. **Docker Hub `supabase/cli-*`** — deferred; blob CDN is also off some default allowlists, and names collide with upstream `supabase/<service>`.
5. **Unpin slim images** — would make old CLIs see a moved tag. Rejected: conflicts with ADR 0017.
Expand Down
46 changes: 42 additions & 4 deletions packages/stack/src/Artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,27 @@ const platformText = (platform: { readonly os: string; readonly arch: string }):
const errorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : typeof cause === "string" ? cause : String(cause);

const SLIM_NATIVE_GITHUB_RELEASES = "https://github.com/supabase/slim-services/releases/download";

/**
* Public S3 copy of the release assets for hosts that block GitHub release downloads, such as
* agent sandboxes that allow `*.amazonaws.com`.
* @see ../../../infra/cli-artifacts/README.md
*/
const SLIM_NATIVE_SUPABASE_S3_MIRROR = "https://supabase-cli-artifacts.s3.us-east-1.amazonaws.com";

const SLIM_NATIVE_GHCR_REGISTRY = "ghcr.io";
const SLIM_NATIVE_GHCR_REPOSITORY = "supabase/cli";

const SLIM_IMAGE_GHCR_REGISTRY = "ghcr.io/supabase/cli/";
const SLIM_IMAGE_SUPABASE_ECR_MIRROR = "public.ecr.aws/supabase/cli/";

/** Mirrors carrying a catalog slim image under the same tag and digest, in fallback order. */
export const slimImageMirrors = (image: string): ReadonlyArray<string> =>
image.startsWith(SLIM_IMAGE_GHCR_REGISTRY)
? [`${SLIM_IMAGE_SUPABASE_ECR_MIRROR}${image.slice(SLIM_IMAGE_GHCR_REGISTRY.length)}`]
: [];
Comment thread
avallete marked this conversation as resolved.
Resolved
Comment thread
avallete marked this conversation as resolved.
Resolved

const artifactFor = (
service: ServiceKind,
resolved: ArtifactResolution,
Expand All @@ -165,7 +186,8 @@ const artifactFor = (
const sourceService = definitions[service].sourceService;
const releaseTag = `${sourceService}-${resolved.version}`;
const assetName = `${releaseTag}-${target}`;
const base = `https://github.com/supabase/slim-services/releases/download/${releaseTag}`;
const githubRelease = `${SLIM_NATIVE_GITHUB_RELEASES}/${releaseTag}`;
const supabaseS3 = `${SLIM_NATIVE_SUPABASE_S3_MIRROR}/${sourceService}/${resolved.version}`;
return {
provider: "supabase/slim-services",
service: sourceService,
Expand All @@ -174,9 +196,25 @@ const artifactFor = (
target,
archive: "tar.zst",
assetName,
downloadUrl: `${base}/${assetName}.tar.zst`,
manifestUrl: `${base}/${assetName}.manifest.json`,
checksumUrl: `${base}/SHA256SUMS`,
checksums: [
{ kind: "sha256sums", url: `${githubRelease}/SHA256SUMS` },
{
kind: "oci",
registry: SLIM_NATIVE_GHCR_REGISTRY,
repository: `${SLIM_NATIVE_GHCR_REPOSITORY}/${sourceService}`,
tag: `${resolved.version}-native-${target}`,
},
],
mirrors: [
{
downloadUrl: `${githubRelease}/${assetName}.tar.zst`,
manifestUrl: `${githubRelease}/${assetName}.manifest.json`,
},
{
downloadUrl: `${supabaseS3}/${assetName}.tar.zst`,
manifestUrl: `${supabaseS3}/${assetName}.manifest.json`,
},
],
requiredRuntimePaths: resolved.requiredRuntimePaths,
executablePath: resolved.executablePath,
};
Expand Down
16 changes: 16 additions & 0 deletions packages/stack/src/Artifacts.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { expect, it } from "@effect/vitest";
import { slimImageMirrors } from "./Artifacts.ts";

it("rewrites a GHCR catalog image onto ECR Public and keeps the tag and digest", () => {
expect(
slimImageMirrors(
"ghcr.io/supabase/cli/postgres:17.6.1.173@sha256:1581c433d71a48a81e356a3ed2d4aa5ecfc8fc0465ea98661da7a88023317dcf",
),
).toEqual([
"public.ecr.aws/supabase/cli/postgres:17.6.1.173@sha256:1581c433d71a48a81e356a3ed2d4aa5ecfc8fc0465ea98661da7a88023317dcf",
]);
});

it("returns no mirror for an image outside the slim catalog registry", () => {
expect(slimImageMirrors("public.ecr.aws/supabase/postgres:17.6.1.173")).toEqual([]);
});
Loading
Loading