diff --git a/packages/alchemy/src/AWS/Local/FlociServices.ts b/packages/alchemy/src/AWS/Local/FlociServices.ts index 4f6ca33cee..3ca8d526e5 100644 --- a/packages/alchemy/src/AWS/Local/FlociServices.ts +++ b/packages/alchemy/src/AWS/Local/FlociServices.ts @@ -4,8 +4,10 @@ import * as Floci from "@alchemy.run/floci"; import type { FlociError } from "@alchemy.run/floci"; import { Credentials } from "@distilled.cloud/aws/Credentials"; import type { RegionName } from "@distilled.cloud/aws/Region"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as ProviderLayer from "../../Local/ProviderLayer.ts"; import type { Platform } from "../../Platform.ts"; @@ -26,23 +28,174 @@ export const FLOCI_ACCOUNT_ID = LOCAL_ACCOUNT_ID; /** Region every floci-emulated resource lives in. */ export const FLOCI_REGION = "us-east-1"; +/** + * The provider-owned identity and endpoint for one local Floci context. + * + * This is deliberately separate from process environment variables: callers + * can build multiple {@link flociServices} layers in one process without one + * local account changing another one's credentials or endpoint. `floci` is a + * constrained pass-through for emulator lifecycle settings; it does not carry + * product-specific constants. + */ +export interface FlociProfile { + /** Gateway URL used by all AWS SDK operations in this context. */ + readonly endpoint?: string; + /** Region used for signing and generated resource attributes. */ + readonly region?: string; + /** Account used for generated resource attributes. */ + readonly accountId?: string; + /** Credentials used to sign calls to the emulator. */ + readonly credentials?: { + readonly accessKeyId?: string | Redacted.Redacted; + readonly secretAccessKey?: string | Redacted.Redacted; + readonly sessionToken?: string | Redacted.Redacted; + }; + /** Optional Floci lifecycle settings owned by the provider integration. */ + readonly floci?: Pick< + Floci.FlociConfig, + | "image" + | "port" + | "containerName" + | "storageDir" + | "dockerSocket" + | "env" + | "elbListenerPorts" + | "cloudfrontEdgePorts" + | "readinessTimeout" + >; + /** Whether to start Floci when the configured endpoint is not serving. */ + readonly autoStart?: boolean; +} + +/** JSON-safe profile shape sent to an RPC sidecar for one provider context. */ +export interface FlociProfileTransport extends Omit< + FlociProfile, + "credentials" +> { + readonly credentials?: { + readonly accessKeyId?: string; + readonly secretAccessKey?: string; + readonly sessionToken?: string; + }; +} + +/** Provider context carrying a stack's selected local Floci profile. */ +export class FlociProfileService extends Context.Service< + FlociProfileService, + FlociProfile +>()("AWS::Local::FlociProfile") {} + +const reveal = (value: string | Redacted.Redacted | undefined) => + value === undefined + ? undefined + : Redacted.isRedacted(value) + ? Redacted.value(value) + : value; + +/** Convert a local profile to the JSON-safe form carried by RPC sessions. */ +export const serializeFlociProfile = ( + profile: FlociProfile, +): FlociProfileTransport => ({ + ...profile, + credentials: + profile.credentials === undefined + ? undefined + : { + accessKeyId: reveal(profile.credentials.accessKeyId), + secretAccessKey: reveal(profile.credentials.secretAccessKey), + sessionToken: reveal(profile.credentials.sessionToken), + }, +}); + +const DEFAULT_ACCESS_KEY_ID = "test"; +const DEFAULT_SECRET_ACCESS_KEY = "test"; + +const materialize = ( + value: string | Redacted.Redacted | undefined, + fallback: string, +): Redacted.Redacted => + value === undefined + ? Redacted.make(fallback) + : Redacted.isRedacted(value) + ? value + : Redacted.make(value); + +const portOf = (endpoint: string): number => { + try { + return ( + Number.parseInt(new URL(endpoint).port, 10) || Floci.DEFAULT_FLOCI_PORT + ); + } catch { + return Floci.DEFAULT_FLOCI_PORT; + } +}; + +/** Resolve omitted profile fields while retaining the standalone defaults. */ +export const resolveFlociProfile = ( + profile: FlociProfile = {}, +): { + readonly endpoint: string; + readonly region: string; + readonly accountId: string; + readonly credentials: { + readonly accessKeyId: Redacted.Redacted; + readonly secretAccessKey: Redacted.Redacted; + readonly sessionToken: Redacted.Redacted | undefined; + }; + readonly floci: FlociProfile["floci"]; + readonly autoStart: FlociProfile["autoStart"]; +} => { + const endpoint = profile.endpoint ?? DEFAULT_LOCAL_ENDPOINT; + const region = profile.region ?? FLOCI_REGION; + const accountId = profile.accountId ?? FLOCI_ACCOUNT_ID; + const customAccount = accountId !== LOCAL_ACCOUNT_ID; + return { + endpoint, + region, + accountId, + credentials: { + accessKeyId: materialize( + profile.credentials?.accessKeyId, + customAccount ? accountId : DEFAULT_ACCESS_KEY_ID, + ), + secretAccessKey: materialize( + profile.credentials?.secretAccessKey, + DEFAULT_SECRET_ACCESS_KEY, + ), + sessionToken: + profile.credentials?.sessionToken === undefined + ? undefined + : materialize(profile.credentials.sessionToken, ""), + }, + floci: profile.floci, + autoStart: profile.autoStart, + }; +}; + // Annotated (not inferred): the inferred union names distilled's Endpoint // through a non-portable relative path (TS2883), and consumers only ever // hand this to `provideProviderContext`, which takes `Layer`. -const makeFlociServices = (): Layer.Layer => { - const region = FLOCI_REGION as RegionName; +const makeFlociServices = ( + input: FlociProfile, +): Layer.Layer => { + const profile = resolveFlociProfile(input); + const region = profile.region as RegionName; const resolved = { - accessKeyId: Redacted.make("test"), - secretAccessKey: Redacted.make("test"), - sessionToken: undefined, + accessKeyId: profile.credentials.accessKeyId, + secretAccessKey: profile.credentials.secretAccessKey, + sessionToken: profile.credentials.sessionToken, region, }; const credentials = Effect.succeed(resolved); + const flociConfig = { + ...profile.floci, + port: profile.floci?.port ?? portOf(profile.endpoint), + }; return Layer.mergeAll( // Pin every distilled SDK call made by a wrapped lifecycle method to the // emulator gateway with dummy credentials in the emulator's region. - Endpoint.of(DEFAULT_LOCAL_ENDPOINT), - Region.of(FLOCI_REGION), + Endpoint.of(profile.endpoint), + Region.of(region), Layer.succeed(Credentials, credentials), // Providers read `AWSEnvironment.current` inside lifecycle operations to // compute ARNs/attrs (accountId, region) — override it so computed @@ -50,30 +203,43 @@ const makeFlociServices = (): Layer.Layer => { Layer.succeed( AWSEnvironment, Effect.succeed({ - accountId: FLOCI_ACCOUNT_ID, - region: FLOCI_REGION, + accountId: profile.accountId, + region: profile.region, credentials, - endpoint: DEFAULT_LOCAL_ENDPOINT, + endpoint: profile.endpoint, }), ), // Building the services guarantees the emulator is serving: reuses // anything already listening on the endpoint, otherwise starts (or // revives) the managed `alchemy-floci` container and waits for health. - Layer.effectDiscard(Floci.ensureFloci({ port: Floci.DEFAULT_FLOCI_PORT })), + Layer.effectDiscard( + (profile.autoStart ?? profile.endpoint === DEFAULT_LOCAL_ENDPOINT) + ? Floci.ensureFloci(flociConfig) + : Effect.void, + ), ); }; -let flociServicesLayer: ReturnType | undefined; - /** * The floci-scoped override context for local-mode AWS providers, as a - * **module-memoized layer reference** (see the note on - * [Local/ProviderLayer.ts](../../Local/ProviderLayer.ts)): every - * {@link flociDual} registration shares this one reference, so the stack - * build's MemoMap constructs it — and runs `ensureFloci()` — exactly once - * per stack build, and only when a local-mode provider is actually demanded. + * provider-owned layer reference (see the note on + * [Local/ProviderLayer.ts](../../Local/ProviderLayer.ts)). A caller that + * shares one returned layer reference across registrations gets one Floci + * lifecycle per stack build; separate profiles produce separate references + * and cannot leak identity or endpoint state into one another. */ -export const flociServices = () => (flociServicesLayer ??= makeFlociServices()); +export const flociServices = ( + profile?: FlociProfile, +): Layer.Layer => + Layer.unwrap( + Effect.gen(function* () { + if (profile !== undefined) return makeFlociServices(profile); + const selected = yield* Effect.serviceOption(FlociProfileService); + return makeFlociServices( + Option.getOrElse(selected, () => ({}) as FlociProfile), + ); + }), + ) as Layer.Layer; /** * Registers an AWS resource provider with both a **live** and a **local** @@ -98,12 +264,17 @@ export const flociDual = < | Platform | { Type: R["Type"] }, live: () => L, -) => - ProviderLayer.dual(cls, { +) => { + // Keep one layer reference for both the local lifecycle and its data plane. + // ProviderLayer's MemoMap then builds the selected profile once per stack, + // while separate `AWS.providers({ local: ... })` layers retain isolation. + const services = flociServices(); + return ProviderLayer.dual(cls, { live, - local: () => provideProviderContext(live(), flociServices()), + local: () => provideProviderContext(live(), services), // Registered as the resource's local data plane so deploy-time binding // clients (Action bodies, plan-time `execute`) route their API calls to // the emulator whenever the bound resource resolves to local mode. - dataPlane: flociServices, + dataPlane: () => services, }); +}; diff --git a/packages/alchemy/src/AWS/Providers.ts b/packages/alchemy/src/AWS/Providers.ts index 49965ad7f1..4020c9eff0 100644 --- a/packages/alchemy/src/AWS/Providers.ts +++ b/packages/alchemy/src/AWS/Providers.ts @@ -27,7 +27,12 @@ import { captureAwsEnvironment, pinCollectionEnvironment, } from "./Local/ProviderContext.ts"; -import { flociDual, flociServices } from "./Local/FlociServices.ts"; +import { + FlociProfileService, + flociDual, + flociServices, + type FlociProfile, +} from "./Local/FlociServices.ts"; import * as Provider from "../Provider.ts"; import { Random, RandomProvider } from "../Random.ts"; import { @@ -239,7 +244,13 @@ export class Providers extends Provider.ProviderCollection()( "AWS", ) {} -export const providers = () => +/** Provider-wide configuration for local AWS emulation. */ +export interface ProvidersOptions { + /** Identity, endpoint, and lifecycle settings for the Floci local mode. */ + readonly local?: FlociProfile; +} + +export const providers = (options: ProvidersOptions = {}) => Layer.effect( Providers, // Providers are PINNED to the environment they are registered with (the @@ -1956,6 +1967,7 @@ export const providers = () => Layer.provideMerge(Credentials.fromEnvironment), Layer.provideMerge(Endpoint.fromEnvironment), Layer.provideMerge(DefaultEnvironment), + Layer.provideMerge(Layer.succeed(FlociProfileService, options.local ?? {})), Layer.provideMerge(AwsAuth), Layer.provideMerge(CredentialsStoreLive), // Apply a blanket retry policy to every AWS SDK call. Like distilled's diff --git a/packages/alchemy/src/Local/RpcProviderProxy.ts b/packages/alchemy/src/Local/RpcProviderProxy.ts index 49dca45473..b0f15d8f2a 100644 --- a/packages/alchemy/src/Local/RpcProviderProxy.ts +++ b/packages/alchemy/src/Local/RpcProviderProxy.ts @@ -4,9 +4,14 @@ import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { AlchemyContext } from "../AlchemyContext.ts"; +import { + FlociProfileService, + serializeFlociProfile, +} from "../AWS/Local/FlociServices.ts"; import type { ProviderService } from "../Provider.ts"; import type { ResourceLike } from "../Resource.ts"; import { Stack } from "../Stack.ts"; @@ -114,9 +119,14 @@ const make = Effect.fn(function* (spawnerUrl: string) { get: Effect.fn(function* (mainUrl, providerName) { const alchemyContext = yield* AlchemyContext; const stack = yield* Stack; + const context = yield* Effect.context(); + const flociProfile = Context.getOption(context, FlociProfileService); const sessionEnv = encodeSessionEnvironment({ alchemyContext, stack: { name: stack.name, stage: stack.stage }, + ...(Option.isSome(flociProfile) + ? { flociProfile: serializeFlociProfile(flociProfile.value) } + : {}), }); const key = mainUrl + SESSION_KEY_SEPARATOR + sessionEnv; const fetchProvider = Effect.gen(function* () { diff --git a/packages/alchemy/src/Local/RpcServerEnvironment.ts b/packages/alchemy/src/Local/RpcServerEnvironment.ts index 04d1117350..1017bf34b4 100644 --- a/packages/alchemy/src/Local/RpcServerEnvironment.ts +++ b/packages/alchemy/src/Local/RpcServerEnvironment.ts @@ -4,6 +4,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { AlchemyContext } from "../AlchemyContext.ts"; +import { + FlociProfileService, + type FlociProfileTransport, +} from "../AWS/Local/FlociServices.ts"; import { AuthProviders } from "../Auth/AuthProvider.ts"; import { CredentialsStoreLive } from "../Auth/Credentials.ts"; import { ProfileLive, withProfileOverride } from "../Auth/Profile.ts"; @@ -24,6 +28,8 @@ export interface SessionEnvironment { name: string; stage: string; }; + /** Profile selected by the parent for AWS local providers in this session. */ + flociProfile?: FlociProfileTransport; } export interface RpcServerEnvironment { @@ -74,6 +80,7 @@ export const layer = ( actions: {}, }), Layer.succeed(Stage, environment.stack.stage), + Layer.succeed(FlociProfileService, environment.flociProfile ?? {}), ); export const RPC_SERVER_ENVIRONMENT_KEY = diff --git a/packages/alchemy/test/AWS/Local/FlociServices.test.ts b/packages/alchemy/test/AWS/Local/FlociServices.test.ts new file mode 100644 index 0000000000..ded6f39886 --- /dev/null +++ b/packages/alchemy/test/AWS/Local/FlociServices.test.ts @@ -0,0 +1,251 @@ +import { AWSEnvironment } from "@/AWS/Environment.ts"; +import { + FLOCI_ACCOUNT_ID, + FLOCI_REGION, + FlociProfileService, + flociServices, + resolveFlociProfile, + serializeFlociProfile, +} from "@/AWS/Local/FlociServices.ts"; +import * as RpcServerEnvironment from "@/Local/RpcServerEnvironment.ts"; +import { Endpoint } from "@distilled.cloud/aws"; +import { Credentials } from "@distilled.cloud/aws/Credentials"; +import { Region } from "@distilled.cloud/aws/Region"; +import { NodeServices } from "@effect/platform-node"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { describe, expect, it } from "alchemy-test"; + +const readProfile = (profile: Parameters[0]) => + Effect.gen(function* () { + const context = yield* Layer.build(flociServices(profile)); + const endpoint = Context.get(context, Endpoint.Endpoint); + const region = Context.get(context, Region); + const credentials = Context.get(context, Credentials); + const environment = Context.get(context, AWSEnvironment); + return { + endpoint: yield* endpoint, + region: yield* region, + credentials: yield* credentials, + environment: yield* environment, + }; + }); + +const readContextualProfile = (profile: Parameters[0]) => + Effect.gen(function* () { + const context = yield* Layer.build( + flociServices().pipe( + Layer.provide(Layer.succeed(FlociProfileService, profile ?? {})), + ), + ); + const environment = Context.get(context, AWSEnvironment); + return yield* environment; + }); + +describe("Floci local provider profiles", () => { + it.effect("keeps the standalone Floci identity defaults", () => + Effect.gen(function* () { + const profile = resolveFlociProfile(); + expect(profile.accountId).toBe(FLOCI_ACCOUNT_ID); + expect(profile.region).toBe(FLOCI_REGION); + expect(profile.endpoint).toBe("http://localhost:4566"); + expect(Redacted.value(profile.credentials.accessKeyId)).toBe("test"); + expect(Redacted.value(profile.credentials.secretAccessKey)).toBe("test"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "projects two profiles without process-global environment mutation", + () => + Effect.gen(function* () { + const a = yield* readProfile({ + endpoint: "http://localhost:4567", + region: "eu-west-1", + accountId: "123456789012", + credentials: { + accessKeyId: "123456789012", + secretAccessKey: "profile-a-secret", + }, + autoStart: false, + }); + const b = yield* readProfile({ + endpoint: "http://localhost:4568", + region: "ap-southeast-1", + accountId: "210987654321", + credentials: { + accessKeyId: "210987654321", + secretAccessKey: "profile-b-secret", + }, + autoStart: false, + }); + + expect(a.endpoint).toBe("http://localhost:4567"); + expect(a.region).toBe("eu-west-1"); + expect(a.environment.accountId).toBe("123456789012"); + expect(a.environment.endpoint).toBe("http://localhost:4567"); + expect(Redacted.value(a.credentials.accessKeyId)).toBe("123456789012"); + expect(Redacted.value(a.credentials.secretAccessKey)).toBe( + "profile-a-secret", + ); + + expect(b.endpoint).toBe("http://localhost:4568"); + expect(b.region).toBe("ap-southeast-1"); + expect(b.environment.accountId).toBe("210987654321"); + expect(b.environment.endpoint).toBe("http://localhost:4568"); + expect(Redacted.value(b.credentials.accessKeyId)).toBe("210987654321"); + expect(Redacted.value(b.credentials.secretAccessKey)).toBe( + "profile-b-secret", + ); + }), + ); + + it.effect("reads the selected profile from provider context", () => + Effect.gen(function* () { + const environment = yield* readContextualProfile({ + endpoint: "http://localhost:4571", + region: "eu-central-1", + accountId: "333333333333", + autoStart: false, + }); + expect(environment.endpoint).toBe("http://localhost:4571"); + expect(environment.region).toBe("eu-central-1"); + expect(environment.accountId).toBe("333333333333"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("carries profiles through the sidecar session context", () => + Effect.gen(function* () { + const readSidecar = ( + profile: Parameters[0], + ) => { + const session = RpcServerEnvironment.decodeSessionEnvironment( + RpcServerEnvironment.encodeSessionEnvironment({ + alchemyContext: { + dotAlchemy: ".alchemy", + dev: true, + adopt: false, + }, + stack: { name: profile.accountId ?? "sidecar", stage: "test" }, + flociProfile: serializeFlociProfile(profile), + }), + ); + return Effect.gen(function* () { + const context = yield* Layer.build( + flociServices().pipe( + Layer.provide( + RpcServerEnvironment.layer({ + profile: undefined, + envFile: undefined, + ...session, + }), + ), + ), + ); + const environment = Context.get(context, AWSEnvironment); + return yield* environment; + }); + }; + + const a = yield* readSidecar({ + endpoint: "http://localhost:4581", + region: "eu-central-1", + accountId: "444444444444", + credentials: { + accessKeyId: "444444444444", + secretAccessKey: "sidecar-a-secret", + }, + autoStart: false, + }); + const b = yield* readSidecar({ + endpoint: "http://localhost:4582", + region: "ap-southeast-1", + accountId: "555555555555", + credentials: { + accessKeyId: "555555555555", + secretAccessKey: "sidecar-b-secret", + }, + autoStart: false, + }); + + expect(a.endpoint).toBe("http://localhost:4581"); + expect(a.region).toBe("eu-central-1"); + expect(a.accountId).toBe("444444444444"); + expect(Redacted.value((yield* a.credentials).accessKeyId)).toBe( + "444444444444", + ); + expect(b.endpoint).toBe("http://localhost:4582"); + expect(b.region).toBe("ap-southeast-1"); + expect(b.accountId).toBe("555555555555"); + expect(Redacted.value((yield* b.credentials).accessKeyId)).toBe( + "555555555555", + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("serializes redacted credentials without losing their values", () => + Effect.gen(function* () { + const session = RpcServerEnvironment.decodeSessionEnvironment( + RpcServerEnvironment.encodeSessionEnvironment({ + alchemyContext: { + dotAlchemy: ".alchemy", + dev: true, + adopt: false, + }, + stack: { name: "sidecar-redacted", stage: "test" }, + flociProfile: serializeFlociProfile({ + endpoint: "http://localhost:4583", + region: "eu-central-1", + accountId: "666666666666", + credentials: { + accessKeyId: Redacted.make("666666666666"), + secretAccessKey: Redacted.make("sidecar-redacted-secret"), + }, + autoStart: false, + }), + }), + ); + const context = yield* Layer.build( + flociServices().pipe( + Layer.provide( + RpcServerEnvironment.layer({ + profile: undefined, + envFile: undefined, + ...session, + }), + ), + ), + ); + const environment = Context.get(context, AWSEnvironment); + const resolved = yield* environment; + expect(resolved.endpoint).toBe("http://localhost:4583"); + expect(resolved.accountId).toBe("666666666666"); + const credentials = yield* resolved.credentials; + expect(Redacted.value(credentials.accessKeyId)).toBe("666666666666"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "accepts an owned Floci lifecycle profile without requiring credentials", + () => + Effect.gen(function* () { + const profile = resolveFlociProfile({ + endpoint: "http://localhost:4570", + accountId: "111111111111", + floci: { + image: "floci:test", + containerName: "alchemy-floci-test", + env: { FLOCI_TEST_PROFILE: "one" }, + }, + autoStart: false, + }); + expect(profile.floci?.image).toBe("floci:test"); + expect(profile.floci?.containerName).toBe("alchemy-floci-test"); + expect(profile.floci?.env).toEqual({ FLOCI_TEST_PROFILE: "one" }); + expect(Redacted.value(profile.credentials.accessKeyId)).toBe( + "111111111111", + ); + }), + ); +});