Skip to content
Closed
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
217 changes: 194 additions & 23 deletions packages/alchemy/src/AWS/Local/FlociServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,54 +28,218 @@ 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<string>;
readonly secretAccessKey?: string | Redacted.Redacted<string>;
readonly sessionToken?: string | Redacted.Redacted<string>;
};
/** 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<string> | 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<string> | undefined,
fallback: string,
): Redacted.Redacted<string> =>
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<string>;
readonly secretAccessKey: Redacted.Redacted<string>;
readonly sessionToken: Redacted.Redacted<string> | 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<any, any, never>`.
const makeFlociServices = (): Layer.Layer<any, FlociError, never> => {
const region = FLOCI_REGION as RegionName;
const makeFlociServices = (
input: FlociProfile,
): Layer.Layer<any, FlociError, never> => {
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
// identities carry the dummy account and the emulator endpoint.
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<typeof makeFlociServices> | 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<any, FlociError, never> =>
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<any, FlociError, never>;

/**
* Registers an AWS resource provider with both a **live** and a **local**
Expand All @@ -98,12 +264,17 @@ export const flociDual = <
| Platform<R, any, any, any, any>
| { 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,
});
};
16 changes: 14 additions & 2 deletions packages/alchemy/src/AWS/Providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -239,7 +244,13 @@ export class Providers extends Provider.ProviderCollection<Providers>()(
"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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/alchemy/src/Local/RpcProviderProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<never>();
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* () {
Expand Down
7 changes: 7 additions & 0 deletions packages/alchemy/src/Local/RpcServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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 =
Expand Down
Loading