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
21 changes: 13 additions & 8 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,20 +226,25 @@ commands retain their existing behavior.
`functions serve` requires a running project stack. It attaches to the existing Functions member,
waking it when needed. If Functions was excluded, it creates a temporary standalone Functions
instance on the stack's API listener without changing the saved composition. Ctrl-C destroys that
temporary instance; an existing member remains available. `--no-verify-jwt` and `--env-file` apply
for the serving session and the previous configuration is restored on exit. Ordinary code edits
temporary instance; an existing member remains available. On a running stack, any difference
between the configuration serve resolves (env files, per-function `.env`, `[functions.<name>]`
settings, `--env-file`, `--debug`, `--no-verify-jwt`) and the saved one restarts Functions for
the serving session, and the saved configuration is restored on exit. Edits made while serving
apply on the next `functions serve`, because the stack backend does not watch files. Ordinary code edits
are picked up by the next invocation without a runtime restart. Import-map and inspector flags
and multiline environment values are currently unsupported on this backend. Normal exit owns cleanup; forcibly killing the CLI
are currently unsupported on this backend. Normal exit owns cleanup; forcibly killing the CLI
cannot guarantee restoration or removal of its temporary instance. Concurrent override sessions
for the same Functions instance are unsupported. Cleanup waits for submitted lifecycle operations;
additional signals do not force it to abandon cleanup. Restoration restores configuration and keeps
the service running; it does not return it to a sleeping state.

Stack mode reads the shared `supabase/functions/.env` file when creating Functions; `--env-file`
replaces those custom values for the session. A normal `start` applies changes to the shared file
and restores default JWT verification on the same Functions instance. Per-function
`.env` files are not loaded. Reserved
`SUPABASE_*` values in these files are ignored because the runtime supplies them. With
Stack mode resolves the Functions env like legacy `start` and `functions serve`: the shared
`supabase/functions/.env` file overrides `[edge_runtime.secrets]`, and `functions serve` also
reads each enabled function's own `.env` on every invocation; `--env-file` replaces those files
for the session. Multiline values are supported. `start` applies changes to the shared file and
restores default JWT verification only when it creates or replans the stack; a `start` against a
running stack keeps the current Functions configuration. Reserved `SUPABASE_*`
values in these files are skipped with a warning because the runtime supplies them. With
`--output-format stream-json`, readiness emits a result containing the instance ID and URL,
followed by live log events.

Expand Down
4 changes: 1 addition & 3 deletions apps/cli/src/command-internal/stack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")(
return yield* new StackConfigError({
message: `failed to parse config: ${functionsEnvResult.error}`,
});
const functionsEnv = Object.fromEntries(
Object.entries(functionsEnvResult.value).filter(([name]) => !name.startsWith("SUPABASE_")),
);
const functionsEnv = functionsEnvResult.value;
const storageFileSizeLimit = yield* Effect.try({
try: () => String(parseFileSizeLimit(validatedConfig.storage.file_size_limit)),
catch: (cause) =>
Expand Down
86 changes: 20 additions & 66 deletions apps/cli/src/command-internal/stack-functions-env.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,27 @@
import { Data, Effect, FileSystem, Predicate } from "effect";
import { Effect, Option } from "effect";
import type { ServiceCreationInput } from "@supabase/stack/effect";
import { parseDotEnv } from "../shared/config/dotenv.ts";
import {
actionability,
type CliErrorActionabilityDeclaration,
ErrorActionabilityId,
} from "../shared/telemetry/error-actionability.ts";
import { resolveFunctionsEnv } from "../shared/functions/functions-config.ts";

export class StackFunctionsEnvError extends Data.TaggedError("StackFunctionsEnvError")<{
readonly message: string;
readonly path: string;
readonly cause?: unknown;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.invalidConfig;
}
}

/** Reads a Functions dotenv file and removes reserved Stack-provided variables. */
export const readStackFunctionsEnv = Effect.fn("StackFunctionsEnv.read")(function* (
filePath: string,
optional: boolean,
) {
const fs = yield* FileSystem.FileSystem;
const contents = yield* fs.readFileString(filePath).pipe(
Effect.catchTag("PlatformError", (error) =>
optional && Predicate.isTagged(error.reason, "NotFound")
? Effect.succeed("")
: Effect.fail(
new StackFunctionsEnvError({
path: filePath,
message: `Unable to load environment file ${filePath}: ${error.message}`,
cause: error,
}),
),
),
);
const parsed = yield* Effect.try({
try: () => parseDotEnv(contents),
catch: (cause) =>
new StackFunctionsEnvError({
path: filePath,
message: `Unable to parse environment file ${filePath}: ${cause instanceof Error ? cause.message : String(cause)}`,
cause,
}),
});
const env = Object.fromEntries(
Object.entries(parsed).filter(([key]) => !key.startsWith("SUPABASE_")),
);
if (Object.keys(env).some((key) => !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)))
return yield* new StackFunctionsEnvError({
path: filePath,
message: `Environment names in ${filePath} must start with a letter or underscore and contain only letters, digits, and underscores.`,
});
if (Object.values(env).some((value) => /[\0\r\n]/u.test(value)))
return yield* new StackFunctionsEnvError({
path: filePath,
message: `Multiline environment values in ${filePath} are not supported by the stack backend.`,
});
return env;
});

/** Adds the project Functions dotenv values to a Functions creation; configured values win. */
export const withProjectFunctionsEnv = (creation: ServiceCreationInput) =>
/** Adds the project Functions env, plus `debug` when set, to a Functions creation. */
export const withProjectFunctionsConfig = (
creation: ServiceCreationInput,
options: { readonly warn: boolean; readonly debug: boolean },
) =>
creation.service === "functions"
? readStackFunctionsEnv(`${creation.config.functionsRoot}/.env`, true).pipe(
Effect.map((env): ServiceCreationInput => ({
? resolveFunctionsEnv({
secrets: creation.config.env ?? {},
functionsDir: creation.config.functionsRoot,
envFile: Option.none(),
functionSlugs: [],
warn: options.warn,
}).pipe(
Effect.map(({ shared }): ServiceCreationInput => ({
...creation,
config: { ...creation.config, env: { ...env, ...creation.config.env } },
config: {
...creation.config,
env: shared,
...(options.debug ? { debug: true } : {}),
},
})),
)
: Effect.succeed(creation);
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { mockOutput, mockTty } from "../../tests/helpers/mocks.ts";
import { CliArgs } from "../shared/cli/cli-args.service.ts";
import { stdinLayer } from "../shared/runtime/stdin.layer.ts";
import { runtimeInfoLayer } from "../shared/runtime/runtime-info.layer.ts";
import { ExperimentalFlag, YesFlag } from "./global-flags.ts";
import { DebugFlag, ExperimentalFlag, YesFlag } from "./global-flags.ts";
import { stackStart } from "../commands/experimental/stack/start/start.handler.ts";
import { destroyTestStacks } from "../../tests/helpers/stack-cleanup.ts";
import { cliConfigValuesTestLayer } from "../../tests/helpers/config-values-layer.ts";
Expand Down Expand Up @@ -93,6 +93,7 @@ const makeLayers = (root: string) => {
stackCatalogSetupLayer,
dbConnectionLayer,
Layer.succeed(ExperimentalFlag, false),
Layer.succeed(DebugFlag, false),
Layer.succeed(YesFlag, false),
Layer.succeed(CliArgs, { args: ["stack", "start"] }),
Layer.succeed(CommandPlatformApiFactory, { make: Effect.die("unused") }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ SHARED = "encrypted:BOsrXIZY2BNTW43BeRhMbfvlOIUjwI7GCyFHxJD/Ik+UQ4mqkgVl2+61WWhE
}).pipe(Effect.provide(layer)),
);

it.effect("uppercases edge runtime secret names and drops unset or SUPABASE_ ones", () =>
it.effect("uppercases edge runtime secret names and drops unset ones", () =>
Effect.gen(function* () {
const root = yield* createStackConfigProject(
`project_id = "functions-secret-names"
Expand All @@ -240,6 +240,7 @@ supabase_internal_wallclock_limit_sec = "7"
expect(services.find((service) => service.service === "functions")?.config.env).toEqual({
MY_LOWER: "lower-value",
FROM_ENV: "from-env",
SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC: "7",
});
}).pipe(Effect.provide(layer)),
);
Expand Down
14 changes: 8 additions & 6 deletions apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,14 @@ Changes to activation policy take effect after stopping and starting the stack,
later invocation omits an earlier `--eager` flag. `--preparation` selects on-demand or background
artifact preparation.

When Functions is selected, the CLI reads and validates `supabase/functions/.env`, ignoring reserved
`SUPABASE_*` entries. `edge_runtime.secrets` overrides that file, while `functions.<name>.env`
provides per-function values from project environment references. Per-function enabled/JWT policies,
entrypoints, import maps and static files are forwarded to the worker bootstrap. Without a
configured `import_map`, a function uses its own `deno.json` or `deno.jsonc`; a per-function
`import_map.json` is not read. Configured paths
When Functions is selected, the CLI reads and validates `supabase/functions/.env` like legacy
`start`, skipping reserved `SUPABASE_*` entries with a warning. That file overrides
`edge_runtime.secrets`, per-function `.env` files are not read, and `functions.<name>.env`
provides per-function values from project environment references. Multiline values are supported.
`--debug` runs the Functions runtime verbose; `status` does not report it as drift. Per-function
enabled/JWT policies, entrypoints, import maps and static files are forwarded to the worker
bootstrap. Without a configured `import_map`, a function uses its own `deno.json` or `deno.jsonc`; a
per-function `import_map.json` is not read. Configured paths
are relative to `supabase/` and must remain within the project; Docker mounts that project read-only.
The inspector port is retained as an endpoint intent and does not enable debugging by itself.
Running start calls do not refresh Functions from changed project files; stop the stack and start
Expand Down
24 changes: 16 additions & 8 deletions apps/cli/src/commands/experimental/stack/start/start.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from "../stack-summary.ts";
import { gray } from "../../../../command-internal/colors.ts";
import { currentShellPlatform } from "../../../../command-internal/shell-quote.ts";
import { withProjectFunctionsEnv } from "../../../../command-internal/stack-functions-env.ts";
import { withProjectFunctionsConfig } from "../../../../command-internal/stack-functions-env.ts";
import { statusEnvPointer } from "./start-summary.format.ts";
import {
automaticRuntimeNotice,
Expand All @@ -38,6 +38,7 @@ import {
import { Output } from "../../../../shared/output/output.service.ts";
import { MachineErrorContext } from "../../../../shared/output/machine-error-context.service.ts";
import {
DebugFlag,
OutputFlag,
resolveExperimentalWithProjectEnv,
} from "../../../../command-internal/global-flags.ts";
Expand Down Expand Up @@ -559,6 +560,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
const resolver = yield* StackTargetResolver;
const stackApi = yield* StackApi;
const outputFlag = yield* Effect.serviceOption(OutputFlag);
const debug = yield* DebugFlag;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* rejectStackOutput(outputFlag).pipe(
Expand Down Expand Up @@ -617,6 +619,7 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
const resolveRequested = (
stackId: string,
config: Effect.Success<ReturnType<typeof loadStartConfig>>["config"],
warn = true,
) =>
Effect.gen(function* () {
const creations = yield* config.creations(stackId).pipe(
Expand All @@ -630,9 +633,8 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
}),
),
);
return yield* Effect.forEach(
selectedCreations(creations, exclusions),
withProjectFunctionsEnv,
return yield* Effect.forEach(selectedCreations(creations, exclusions), (creation) =>
withProjectFunctionsConfig(creation, { warn, debug }),
).pipe(
Effect.mapError(
(cause) =>
Expand All @@ -655,7 +657,9 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
const resolveForReplan = (stackId: string) =>
Effect.gen(function* () {
const loaded = yield* startConfig;
return yield* resolveRequested(stackId, loaded.config);
/* A running owner ignores these creations, so this resolve stays silent. A start that
applies the env under that owner resolves it again below and warns there. */
return yield* resolveRequested(stackId, loaded.config, !target.hostRunning);
});
const requestedForReplan =
target.id === undefined
Expand Down Expand Up @@ -855,9 +859,13 @@ export const stackStart = Effect.fn("experimental.stack.start")(function* (flags
suggestion: "Destroy the standalone database before starting this stack.",
});
const { config, keys, toml } = yield* startConfig;
// Reuses the creations already resolved for the re-plan above instead of reading the
// Functions dotenv a second time; only a new or already-running stack has none yet.
const requested = requestedForReplan ?? (yield* resolveRequested(stack.id, config));
// Reuses the creations resolved for a fresh owner's re-plan above instead of reading the
// Functions dotenv a second time. A new stack has none yet, and a live owner's were resolved
// silently, so both resolve here with warnings.
const requested =
requestedForReplan === undefined || target.hostRunning
? yield* resolveRequested(stack.id, config)
: requestedForReplan;
if (studioNeedsRest(requested))
return yield* new StackCommandStartError({
reason: "flags",
Expand Down
Loading
Loading