diff --git a/AGENTS.md b/AGENTS.md index b3c56e9..5877b9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,4 +19,22 @@ Conduct each engagement with the user as follows: Restrictions: 1. Never use `Effect.catchAll`, always use `Effect.catchTag` or `Effect.catchTags` -1. Always use `bun` (never npm, pnpm, yarn, etc.) \ No newline at end of file +1. Always use `bun` (never npm, pnpm, yarn, etc.) + +:::caution +Never (ever!) delete .alchemy/ + +Tests are designed ot be idempotent. +When making changes to providers, you should keep running the tests and fixing providers until the tests pass. +If you think the state is corrupted stop and let me know. +You can always add a `yield* destroy()` at the beginning of each test to clean up state. Do not delete .alchemy files or folders. + +Never manually delete resources with the aws cli or api calls. Tests must be designed to be idempotent and self-healing. +::: + +# Testing + +To test, use the following command: +``` +bun vitest run ./alchemy-effect/test///.test.ts +``` diff --git a/alchemy-effect/src/apply.ts b/alchemy-effect/src/apply.ts index ee10fb1..3f83393 100644 --- a/alchemy-effect/src/apply.ts +++ b/alchemy-effect/src/apply.ts @@ -1,33 +1,40 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import type { Simplify } from "effect/Types"; +import { App } from "./app.ts"; import type { AnyBinding, BindingService } from "./binding.ts"; +import { type PlanStatusSession, CLI, type ScopedPlanStatusSession } from "./cli/service.ts"; import type { ApplyStatus } from "./event.ts"; +import { generateInstanceId } from "./instance-id.ts"; import * as Output from "./output.ts"; import { + type Apply, plan, type BindNode, - type Create, - type CRUD, type Delete, type DerivePlan, type IPlan, type Providers, - type Update, } from "./plan.ts"; import type { Instance } from "./policy.ts"; import type { AnyResource, Resource } from "./resource.ts"; import type { AnyService } from "./service.ts"; -import { State } from "./state.ts"; -import { App } from "./app.ts"; +import { + type CreatedResourceState, + type CreatingResourceState, + type DeletingResourceState, + type ReplacedResourceState, + type ReplacingResourceState, + type ResourceState, + type UpdatedResourceState, + type UpdatingReourceState, + State, + StateStoreError, +} from "./state.ts"; import { asEffect } from "./util.ts"; -import { type ScopedPlanStatusSession, CLI } from "./cli/service.ts"; +import { getProviderByType } from "./provider.ts"; -export type ApplyEffect< - P extends IPlan, - Err = never, - Req = never, -> = Effect.Effect< +export type ApplyEffect

= Effect.Effect< { [k in keyof AppliedPlan

]: AppliedPlan

[k]; }, @@ -36,429 +43,746 @@ export type ApplyEffect< >; export type AppliedPlan

= { - [id in keyof P["resources"]]: P["resources"][id] extends - | Delete - | undefined - | never + [id in keyof P["resources"]]: P["resources"][id] extends Delete | undefined | never ? never : Simplify; }; -export const apply = < - const Resources extends (AnyService | AnyResource)[] = never, ->( +export const apply = ( ...resources: Resources ): ApplyEffect< DerivePlan>, never, State | Providers> // TODO(sam): don't cast to any -> => plan(...resources).pipe(Effect.flatMap(applyPlan)) as any; +> => plan(...resources).pipe(Effect.flatMap((p) => applyPlan(p as any as IPlan))) as any; export const applyPlan =

(plan: P) => Effect.gen(function* () { - const state = yield* State; - // TODO(sam): rename terminology to Stack - const app = yield* App; - const outputs = {} as Record>; - const cli = yield* CLI; - const session = yield* cli.startApplySession(plan); - const { emit, done } = session; - - const resolveUpstream = Effect.fn(function* (resourceId: string) { - const upstreamNode = plan.resources[resourceId]; - const upstreamAttr = upstreamNode - ? yield* apply(upstreamNode) - : yield* Effect.dieMessage(`Resource ${resourceId} not found`); - return { - resourceId, - upstreamAttr, - upstreamNode, - }; - }); - - const resolveBindingUpstream = Effect.fn(function* ({ - node, - }: { - node: BindNode; - resource: Resource; - }) { - const binding = node.binding as AnyBinding & { - // smuggled property (because it interacts poorly with inference) - Tag: Context.Tag; - }; - const provider = yield* binding.Tag; - - const resourceId: string = node.binding.capability.resource.id; - const { upstreamAttr, upstreamNode } = yield* resolveUpstream(resourceId); - - return { - resourceId, - upstreamAttr, - upstreamNode, - provider, - }; - }); - - const attachBindings = ({ - resource, - bindings, - target, - }: { - resource: Resource; - bindings: BindNode[]; - target: { - id: string; - props: any; - attr: any; - }; - }) => - Effect.all( - bindings.map( - Effect.fn(function* (node) { - const { resourceId, upstreamAttr, upstreamNode, provider } = - yield* resolveBindingUpstream({ node, resource }); - - const input = { - source: { - id: resourceId, - attr: upstreamAttr, - props: upstreamNode.resource.props, - }, - props: node.binding.props, - attr: node.attr, - target, - } as const; - if (node.action === "attach") { - return yield* asEffect(provider.attach(input)); - } else if (node.action === "reattach") { - // reattach is optional, we fall back to attach if it's not available - return yield* asEffect( - (provider.reattach ? provider.reattach : provider.attach)( - input, + + // 1. expand the graph (create new resources, update existing and create replacements) + const resources = yield* expandAndPivot(plan, session); + // TODO(sam): support roll back to previous state if errors occur during expansion + // -> RISK: some UPDATEs may not be reverisble (i.e. trigger replacements) + // TODO(sam): should pivot be done separately? E.g shift traffic? + + // 2. delete orphans and replaced resources + yield* collectGarbage(plan, session); + + yield* session.done(); + + if (Object.keys(plan.resources).length === 0) { + // all resources are deleted, return undefined + return undefined; + } + return resources as { + [k in keyof AppliedPlan

]: AppliedPlan

[k]; + }; + }); + +const expandAndPivot = Effect.fnUntraced(function* (plan: IPlan, session: PlanStatusSession) { + const state = yield* State; + const app = yield* App; + + const outputs = {} as Record>; + const resolveUpstream = Effect.fn(function* (resourceId: string) { + const upstreamNode = plan.resources[resourceId]; + const upstreamAttr = upstreamNode + ? yield* apply(upstreamNode) + : yield* Effect.dieMessage(`Resource ${resourceId} not found`); + return { + resourceId, + upstreamAttr, + upstreamNode, + }; + }); + + const resolveBindingUpstream = Effect.fn(function* ({ + node, + }: { + node: BindNode; + resource: Resource; + }) { + const binding = node.binding as AnyBinding & { + // smuggled property (because it interacts poorly with inference) + Tag: Context.Tag; + }; + const provider = yield* binding.Tag; + const resourceId: string = node.binding.capability.resource.id; + const { upstreamAttr, upstreamNode } = yield* resolveUpstream(resourceId); + + return { + resourceId, + upstreamAttr, + upstreamNode, + provider, + }; + }); + + const attachBindings = ({ + resource, + bindings, + target, + }: { + resource: Resource; + bindings: BindNode[]; + target: { + id: string; + props: any; + attr: any; + }; + }) => + Effect.all( + bindings.map( + Effect.fn(function* (node) { + const { resourceId, upstreamAttr, upstreamNode, provider } = + yield* resolveBindingUpstream({ node, resource }); + + const input = { + source: { + id: resourceId, + attr: upstreamAttr, + props: upstreamNode.resource.props, + }, + props: node.binding.props, + attr: node.attr, + target, + } as const; + if (node.action === "attach") { + return yield* asEffect(provider.attach(input)); + } else if (node.action === "reattach") { + // reattach is optional, we fall back to attach if it's not available + return yield* asEffect( + (provider.reattach ? provider.reattach : provider.attach)(input), + ); + } else if (node.action === "detach" && provider.detach) { + return yield* asEffect( + provider.detach({ + ...input, + target, + }), + ); + } + return node.attr; + }), + ), + ); + + const postAttachBindings = ({ + bindings, + bindingOutputs, + resource, + target, + }: { + bindings: BindNode[]; + bindingOutputs: any[]; + resource: Resource; + target: { + id: string; + props: any; + attr: any; + }; + }) => + Effect.all( + bindings.map( + Effect.fn(function* (node, i) { + const { resourceId, upstreamAttr, upstreamNode, provider } = + yield* resolveBindingUpstream({ node, resource }); + + const oldBindingOutput = bindingOutputs[i]; + + if (provider.postattach && (node.action === "attach" || node.action === "reattach")) { + const bindingOutput = yield* asEffect( + provider.postattach({ + source: { + id: resourceId, + attr: upstreamAttr, + props: upstreamNode.resource.props, + }, + props: node.binding.props, + attr: oldBindingOutput, + target, + } as const), + ); + return { + ...oldBindingOutput, + ...bindingOutput, + }; + } + return oldBindingOutput; + }), + ), + ); + + const apply: (node: Apply) => Effect.Effect = (node) => + Effect.gen(function* () { + const commit = (value: State) => + state.set({ + stack: app.name, + stage: app.stage, + resourceId: node.resource.id, + value, + }); + + const id = node.resource.id; + const resource = node.resource; + + const scopedSession = { + ...session, + note: (note: string) => + session.emit({ + id, + kind: "annotate", + message: note, + }), + } satisfies ScopedPlanStatusSession; + + return yield* (outputs[id] ??= yield* Effect.cached( + Effect.gen(function* () { + const report = (status: ApplyStatus) => + session.emit({ + kind: "status-change", + id, + type: node.resource.type, + status, + }); + + const instanceId = yield* Effect.gen(function* () { + if (node.action === "create" && !node.state?.instanceId) { + const instanceId = yield* generateInstanceId(); + yield* commit({ + status: "creating", + instanceId, + logicalId: id, + downstream: node.downstream, + props: node.props, + providerVersion: node.provider.version ?? 0, + resourceType: node.resource.type, + bindings: node.bindings, + }); + return instanceId; + } else if (node.action === "replace") { + if (node.state.status === "replaced" || node.state.status === "replacing") { + // replace has already begun and we have the new instanceId, do not re-create it + return node.state.instanceId; + } + const instanceId = yield* generateInstanceId(); + yield* commit({ + status: "replacing", + instanceId, + logicalId: id, + downstream: node.downstream, + props: node.props, + providerVersion: node.provider.version ?? 0, + resourceType: node.resource.type, + bindings: node.bindings, + old: node.state, + deleteFirst: node.deleteFirst, + }); + return instanceId; + } else if (node.state?.instanceId) { + // we're in a create, update or delete state with a stable instanceId, use it + return node.state.instanceId; + } + // this should never happen + return yield* Effect.dieMessage( + `Instance ID not found for resource '${id}' and action is '${node.action}'`, + ); + }); + + if (node.action === "noop") { + return node.state.attr; + } else if (node.action === "create") { + const upstream = Object.fromEntries( + yield* Effect.all( + Object.entries(Output.resolveUpstream(node.props)).map(([id]) => + resolveUpstream(id).pipe(Effect.map(({ upstreamAttr }) => [id, upstreamAttr])), ), - ); - } else if (node.action === "detach" && provider.detach) { - return yield* asEffect( - provider.detach({ - ...input, - target, - }), - ); + ), + ); + const news = (yield* Output.evaluate(node.props, upstream)) as Record; + + const checkpoint = (attr: any) => + commit({ + status: "creating", + logicalId: id, + instanceId, + resourceType: node.resource.type, + props: news, + attr, + providerVersion: node.provider.version ?? 0, + bindings: node.bindings, + downstream: node.downstream, + }); + + if (!node.state) { + yield* checkpoint(undefined); } - return node.attr; - }), - ), - ); - - const postAttachBindings = ({ - bindings, - bindingOutputs, - resource, - target, - }: { - bindings: BindNode[]; - bindingOutputs: any[]; - resource: Resource; - target: { - id: string; - props: any; - attr: any; - }; - }) => - Effect.all( - bindings.map( - Effect.fn(function* (node, i) { - const { resourceId, upstreamAttr, upstreamNode, provider } = - yield* resolveBindingUpstream({ node, resource }); - - const oldBindingOutput = bindingOutputs[i]; + let attr: any; if ( - provider.postattach && - (node.action === "attach" || node.action === "reattach") + node.action === "create" && + node.provider.precreate && + // pre-create is only designed to ensure the resource exists, if we have state.attr, then it already exists and should be skipped + node.state?.attr === undefined ) { - const bindingOutput = yield* asEffect( - provider.postattach({ - source: { - id: resourceId, - attr: upstreamAttr, - props: upstreamNode.resource.props, - }, - props: node.binding.props, - attr: oldBindingOutput, - target, - } as const), - ); - return { - ...oldBindingOutput, - ...bindingOutput, - }; + yield* report("pre-creating"); + + // stub the resource prior to resolving upstream resources or bindings if a stub is available + attr = yield* node.provider.precreate({ + id, + news: node.props, + session: scopedSession, + instanceId, + }); + + yield* checkpoint(attr); } - return oldBindingOutput; - }), - ), - ); - const apply: (node: CRUD) => Effect.Effect = (node) => - Effect.gen(function* () { - const saveState = ({ - output, - bindings = node.bindings, - news, - }: { - output: Output; - bindings?: BindNode[]; - news: any; - }) => - state - .set({ - stack: app.name, - stage: app.stage, - resourceId: node.resource.id, - value: { - id: node.resource.id, - type: node.resource.type, - status: node.action === "create" ? "created" : "updated", + yield* report("attaching"); + + let bindingOutputs = yield* attachBindings({ + resource, + bindings: node.bindings, + target: { + id, props: news, - output, - bindings, + attr, }, - }) - .pipe(Effect.map(() => output)); + }); - const id = node.resource.id; - const resource = node.resource; + yield* report("creating"); - const scopedSession = { - ...session, - note: (note: string) => - session.emit({ + attr = yield* node.provider.create({ id, - kind: "annotate", - message: note, - }), - } satisfies ScopedPlanStatusSession; - - return yield* (outputs[id] ??= yield* Effect.cached( - Effect.gen(function* () { - const report = (status: ApplyStatus) => - emit({ - kind: "status-change", + news, + instanceId, + bindings: bindingOutputs, + session: scopedSession, + }); + + yield* checkpoint(attr); + + yield* report("post-attach"); + bindingOutputs = yield* postAttachBindings({ + resource, + bindings: node.bindings, + bindingOutputs, + target: { id, - type: node.resource.type, - status, - }); + props: news, + attr, + }, + }); - const createOrUpdate = Effect.fn(function* ({ - node, + yield* commit({ + status: "created", + logicalId: id, + instanceId, + resourceType: node.resource.type, + props: news, attr, - phase, - }: { - node: Create | Update; - attr: any; - phase: "create" | "update"; - }) { - const upstream = Object.fromEntries( - yield* Effect.all( - Object.entries(Output.resolveUpstream(node.news)).map( - ([id]) => - resolveUpstream(id).pipe( - Effect.map(({ upstreamAttr }) => [id, upstreamAttr]), - ), - ), - ), - ); - const news = yield* Output.evaluate(node.news, upstream); + bindings: node.bindings.map((binding, i) => ({ + ...binding, + attr: bindingOutputs[i], + })), + providerVersion: node.provider.version ?? 0, + downstream: node.downstream, + }); - yield* report(phase === "create" ? "creating" : "updating"); + yield* report("created"); - let bindingOutputs = yield* attachBindings({ - resource, - bindings: node.bindings, - target: { - id, + return attr; + } else if (node.action === "update") { + const upstream = Object.fromEntries( + yield* Effect.all( + Object.entries(Output.resolveUpstream(node.props)).map(([id]) => + resolveUpstream(id).pipe(Effect.map(({ upstreamAttr }) => [id, upstreamAttr])), + ), + ), + ); + const news = (yield* Output.evaluate(node.props, upstream)) as Record; + + const checkpoint = (attr: any) => { + if (node.state.status === "replaced") { + return commit({ + ...node.state, + attr, + props: news, + }); + } else { + return commit({ + status: "updating", + logicalId: id, + instanceId, + resourceType: node.resource.type, props: news, attr, - }, - }); + providerVersion: node.provider.version ?? 0, + bindings: node.bindings, + downstream: node.downstream, + old: node.state.status === "updating" ? node.state.old : node.state, + }); + } + }; + + yield* checkpoint(node.state.attr); + + yield* report("attaching"); - const output: any = yield* ( - phase === "create" ? node.provider.create : node.provider.update - )({ + let bindingOutputs = yield* attachBindings({ + resource, + bindings: node.bindings, + target: { id, - news, - bindings: bindingOutputs, - session: scopedSession, - ...(node.action === "update" - ? { - output: node.output, - olds: node.olds, - } - : {}), - }).pipe( - // TODO(sam): partial checkpoints - // checkpoint, - Effect.tap(() => - report(phase === "create" ? "created" : "updated"), - ), - ); + props: news, + attr: node.state.attr, + }, + }); - bindingOutputs = yield* postAttachBindings({ - resource, - bindings: node.bindings, - bindingOutputs, - target: { - id, - props: news, - attr, - }, - }); + yield* report("updating"); + + const attr = yield* node.provider.update({ + id, + news, + instanceId, + bindings: bindingOutputs, + session: scopedSession, + olds: + node.state.status === "created" || + node.state.status === "updated" || + node.state.status === "replaced" + ? node.state.props + : node.state.old.props, + output: node.state.attr, + }); + + yield* checkpoint(attr); + + yield* report("post-attach"); + + bindingOutputs = yield* postAttachBindings({ + resource, + bindings: node.bindings, + bindingOutputs, + target: { + id, + props: news, + attr, + }, + }); - yield* saveState({ - news, - output, + if (node.state.status === "replaced") { + yield* commit({ + ...node.state, + attr, + props: news, + }); + } else { + yield* commit({ + status: "updated", + logicalId: id, + instanceId, + resourceType: node.resource.type, + props: news, + attr, bindings: node.bindings.map((binding, i) => ({ ...binding, attr: bindingOutputs[i], })), + providerVersion: node.provider.version ?? 0, + downstream: node.downstream, }); + } - return output; - }); - - if (node.action === "noop") { - return (yield* state.get({ - stack: app.name, - stage: app.stage, - resourceId: id, - }))?.output; - } else if (node.action === "create") { - let attr: any; - if (node.provider.precreate) { - yield* Effect.logDebug("precreate", id); - // stub the resource prior to resolving upstream resources or bindings if a stub is available - attr = yield* node.provider.precreate({ - id, - news: node.news, - session: scopedSession, - }); - } + yield* report("updated"); - yield* Effect.logDebug("create", id); - return yield* createOrUpdate({ - node, - attr, - phase: "create", - }); - } else if (node.action === "update") { - yield* Effect.logDebug("update", id); - return yield* createOrUpdate({ - node, - attr: node.attributes, - phase: "update", - }); - } else if (node.action === "delete") { - yield* Effect.logDebug("delete", id); + return attr; + } else if (node.action === "replace") { + if (node.state.status === "replaced") { + // we've already created the replacement resource, return the output + return node.state.attr; + } + let state: ReplacingResourceState; + if (node.state.status !== "replacing") { + yield* commit( + (state = { + status: "replacing", + logicalId: id, + instanceId, + resourceType: node.resource.type, + props: node.props, + attr: node.state.attr, + providerVersion: node.provider.version ?? 0, + deleteFirst: node.deleteFirst, + old: node.state, + downstream: node.downstream, + }), + ); + } else { + state = node.state; + } + const upstream = Object.fromEntries( yield* Effect.all( - node.downstream.map((dep) => - dep in plan.resources - ? apply(plan.resources[dep] as any) - : Effect.void, + Object.entries(Output.resolveUpstream(node.props)).map(([id]) => + resolveUpstream(id).pipe(Effect.map(({ upstreamAttr }) => [id, upstreamAttr])), ), - ); - yield* report("deleting"); - - return yield* node.provider - .delete({ - id, - olds: node.olds, - output: node.output, - session: scopedSession, - bindings: [], - }) - .pipe( - Effect.flatMap(() => - state.delete({ - stack: app.name, - stage: app.stage, - resourceId: id, - }), - ), - Effect.tap(() => report("deleted")), - ); - } else if (node.action === "replace") { - const destroy = Effect.gen(function* () { - yield* report("deleting"); - return yield* node.provider.delete({ - id, - olds: node.olds, - output: node.output, - session: scopedSession, - bindings: [], - }); + ), + ); + const news = (yield* Output.evaluate(node.props, upstream)) as Record; + + const checkpoint = ({ + status, + attr, + bindings, + }: Pick) => + commit({ + status, + logicalId: id, + instanceId, + resourceType: node.resource.type, + props: news, + attr, + providerVersion: node.provider.version ?? 0, + bindings: bindings ?? node.bindings, + downstream: node.downstream, + old: state.old, + deleteFirst: node.deleteFirst, + } as S); + + let attr: any; + if ( + node.provider.precreate && + // pre-create is only designed to ensure the resource exists, if we have state.attr, then it already exists and should be skipped + node.state?.attr === undefined + ) { + yield* report("pre-creating"); + + // stub the resource prior to resolving upstream resources or bindings if a stub is available + attr = yield* node.provider.precreate({ + id, + news: node.props, + session: scopedSession, + instanceId, }); - const create = Effect.gen(function* () { - yield* report("creating"); - - // TODO(sam): delete and create will conflict here, we need to extend the state store for replace - return yield* node.provider - .create({ - id, - news: node.news, - // TODO(sam): these need to only include attach actions - bindings: yield* attachBindings({ - resource, - bindings: node.bindings, - target: { - id, - // TODO(sam): resolve the news - props: node.news, - attr: node.attributes, - }, - }), - session: scopedSession, - }) - .pipe( - Effect.tap((output) => - saveState({ news: node.news, output }), - ), - ); + + yield* checkpoint({ + status: "replacing", + attr, }); - if (!node.deleteFirst) { - yield* destroy; - return outputs; - } else { - yield* destroy; - return yield* create; - } } - }), - )); - }) as Effect.Effect; - - const nodes = [ - ...Object.entries(plan.resources), - ...Object.entries(plan.deletions), - ]; - - const resources: any = Object.fromEntries( - yield* Effect.all( - nodes.map( - Effect.fn(function* ([id, node]) { - return [id, yield* apply(node as CRUD)]; - }), - ), + + yield* report("attaching"); + + let bindingOutputs = yield* attachBindings({ + resource, + bindings: node.bindings, + target: { + id, + props: news, + attr, + }, + }); + + yield* report("creating replacement"); + + attr = yield* node.provider.create({ + id, + news, + instanceId, + bindings: bindingOutputs, + session: scopedSession, + }); + + yield* checkpoint({ + status: "replacing", + attr, + }); + + yield* report("post-attach"); + + bindingOutputs = yield* postAttachBindings({ + resource, + bindings: node.bindings, + bindingOutputs, + target: { + id, + props: news, + attr, + }, + }); + + yield* checkpoint({ + status: "replaced", + attr, + bindings: node.bindings.map((binding, i) => ({ + ...binding, + attr: bindingOutputs[i], + })), + }); + + yield* report("created"); + return attr; + } + // @ts-expect-error + return yield* Effect.dieMessage(`Unknown action: ${node.action}`); + }), + )); + }) as Effect.Effect; + + return Object.fromEntries( + yield* Effect.all( + Object.entries(plan.resources).map( + Effect.fn(function* ([id, node]) { + return [id, yield* apply(node)]; + }), ), - ); - yield* done(); - if (Object.keys(plan.resources).length === 0) { - // all resources are deleted, return undefined - return undefined; - } - return resources as { - [k in keyof AppliedPlan

]: AppliedPlan

[k]; - }; + ), + ); +}); + +const collectGarbage = Effect.fnUntraced(function* (plan: IPlan, session: PlanStatusSession) { + const state = yield* State; + const app = yield* App; + + const deletions: { + [logicalId in string]: Effect.Effect; + } = {}; + + // delete all replaced resources + const replacedResources = yield* state.getReplacedResources({ + stack: app.name, + stage: app.stage, + }); + + const deletionGraph = { + ...plan.deletions, + ...Object.fromEntries(replacedResources.map((replaced) => [replaced.logicalId, replaced])), + }; + + const deleteResource: ( + node: Delete | ReplacedResourceState, + ) => Effect.Effect = Effect.fnUntraced(function* ( + node: Delete | ReplacedResourceState, + ) { + const isDeleteNode = ( + node: Delete | ReplacedResourceState, + ): node is Delete => "action" in node; + + const { logicalId, resourceType, instanceId, downstream, props, attr, provider } = isDeleteNode( + node, + ) + ? { + logicalId: node.resource.id, + resourceType: node.resource.type, + instanceId: node.state.instanceId, + downstream: node.downstream, + props: node.state.props, + attr: node.state.attr, + provider: node.provider, + } + : { + logicalId: node.logicalId, + resourceType: node.old.resourceType, + instanceId: node.old.instanceId, + downstream: node.old.downstream, + props: node.old.props, + attr: node.old.attr, + provider: yield* getProviderByType(node.old.resourceType), + }; + + const commit = (value: State) => + state.set({ + stack: app.name, + stage: app.stage, + resourceId: logicalId, + value, + }); + + const report = (status: ApplyStatus) => + session.emit({ + kind: "status-change", + id: logicalId, + type: resourceType, + status, + }); + + const scopedSession = { + ...session, + note: (note: string) => + session.emit({ + id: logicalId, + kind: "annotate", + message: note, + }), + } satisfies ScopedPlanStatusSession; + + return yield* (deletions[logicalId] ??= yield* Effect.cached( + Effect.gen(function* () { + yield* Effect.all( + downstream.map((dep) => + dep in deletionGraph + ? deleteResource(deletionGraph[dep] as Delete) + : Effect.void, + ), + ); + + yield* report("deleting"); + + if (isDeleteNode(node)) { + yield* commit({ + status: "deleting", + logicalId, + instanceId, + resourceType, + props, + attr, + downstream, + providerVersion: provider.version ?? 0, + bindings: node.bindings, + }); + } + + yield* provider.delete({ + id: logicalId, + instanceId, + olds: props as never, + output: attr, + session: scopedSession, + bindings: [], + }); + + if (isDeleteNode(node)) { + // TODO(sam): should we commit a tombstone instead? and then clean up tombstones after all deletions are complete? + yield* state.delete({ + stack: app.name, + stage: app.stage, + resourceId: logicalId, + }); + yield* report("deleted"); + } else { + yield* commit({ + status: "created", + logicalId, + instanceId, + resourceType, + props: node.props, + attr: node.attr, + providerVersion: provider.version ?? 0, + downstream: node.downstream, + bindings: node.bindings, + }); + yield* report("replaced"); + } + }), + )); }); + + yield* Effect.all( + Object.values(deletionGraph) + .filter((node) => node !== undefined) + .map(deleteResource), + ); +}); diff --git a/alchemy-effect/src/aws/dynamodb/table.provider.ts b/alchemy-effect/src/aws/dynamodb/table.provider.ts index 5158d0f..190e504 100644 --- a/alchemy-effect/src/aws/dynamodb/table.provider.ts +++ b/alchemy-effect/src/aws/dynamodb/table.provider.ts @@ -120,6 +120,7 @@ export const tableProvider = (): Layer.Layer< ); return { + stables: ["tableName", "tableId", "tableArn"], diff: Effect.fn(function* ({ news, olds }) { if ( // TODO(sam): if the name is hard-coded, REPLACE is impossible - we need a suffix diff --git a/alchemy-effect/src/aws/ec2/index.ts b/alchemy-effect/src/aws/ec2/index.ts index 7190f2d..52e2723 100644 --- a/alchemy-effect/src/aws/ec2/index.ts +++ b/alchemy-effect/src/aws/ec2/index.ts @@ -1,4 +1,12 @@ export * from "./client.ts"; +export * from "./internet-gateway.provider.ts"; +export * from "./internet-gateway.ts"; +export * from "./route-table-association.provider.ts"; +export * from "./route-table-association.ts"; +export * from "./route-table.provider.ts"; +export * from "./route-table.ts"; +export * from "./route.provider.ts"; +export * from "./route.ts"; export * from "./subnet.provider.ts"; export * from "./subnet.ts"; export * from "./vpc.provider.ts"; diff --git a/alchemy-effect/src/aws/ec2/internet-gateway.provider.ts b/alchemy-effect/src/aws/ec2/internet-gateway.provider.ts new file mode 100644 index 0000000..ba5af99 --- /dev/null +++ b/alchemy-effect/src/aws/ec2/internet-gateway.provider.ts @@ -0,0 +1,316 @@ +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +import type { EC2 } from "itty-aws/ec2"; + +import type { ScopedPlanStatusSession } from "../../cli/service.ts"; +import type { ProviderService } from "../../provider.ts"; +import { createTagger, createTagsList } from "../../tags.ts"; +import { Account } from "../account.ts"; +import { Region } from "../region.ts"; +import { EC2Client } from "./client.ts"; +import { + InternetGateway, + type InternetGatewayAttrs, + type InternetGatewayId, + type InternetGatewayProps, +} from "./internet-gateway.ts"; + +export const internetGatewayProvider = () => + InternetGateway.provider.effect( + Effect.gen(function* () { + const ec2 = yield* EC2Client; + const region = yield* Region; + const accountId = yield* Account; + const tagged = yield* createTagger(); + + return { + stables: ["internetGatewayId", "internetGatewayArn", "ownerId"], + diff: Effect.fn(function* ({ news, olds }) { + // VPC attachment change can be handled via attach/detach (update) + // No properties require replacement + }), + + create: Effect.fn(function* ({ id, news, session }) { + // 1. Prepare tags + const alchemyTags = tagged(id); + const userTags = news.tags ?? {}; + const allTags = { ...alchemyTags, ...userTags }; + + // 2. Call CreateInternetGateway + const createResult = yield* ec2.createInternetGateway({ + TagSpecifications: [ + { + ResourceType: "internet-gateway", + Tags: createTagsList(allTags), + }, + ], + DryRun: false, + }); + + const internetGatewayId = createResult.InternetGateway! + .InternetGatewayId! as InternetGatewayId; + yield* session.note(`Internet gateway created: ${internetGatewayId}`); + + // 3. Attach to VPC if specified + if (news.vpcId) { + yield* ec2 + .attachInternetGateway({ + InternetGatewayId: internetGatewayId, + VpcId: news.vpcId, + }) + .pipe( + Effect.retry({ + // Retry if VPC is not yet available + while: (e) => e._tag === "InvalidVpcID.NotFound", + schedule: Schedule.exponential(100), + }), + ); + yield* session.note(`Attached to VPC: ${news.vpcId}`); + } + + // 4. Describe to get full details + const igw = yield* describeInternetGateway( + ec2, + internetGatewayId, + session, + ); + + // 5. Return attributes + return { + internetGatewayId, + internetGatewayArn: + `arn:aws:ec2:${region}:${accountId}:internet-gateway/${internetGatewayId}` as InternetGatewayAttrs["internetGatewayArn"], + vpcId: news.vpcId, + ownerId: igw.OwnerId, + attachments: igw.Attachments?.map((a) => ({ + state: a.State! as + | "attaching" + | "available" + | "detaching" + | "detached", + vpcId: a.VpcId!, + })), + } satisfies InternetGatewayAttrs; + }), + + update: Effect.fn(function* ({ news, olds, output, session }) { + const internetGatewayId = output.internetGatewayId; + + // Handle VPC attachment changes + if (news.vpcId !== olds.vpcId) { + // Detach from old VPC if was attached + if (olds.vpcId) { + yield* ec2 + .detachInternetGateway({ + InternetGatewayId: internetGatewayId, + VpcId: olds.vpcId, + }) + .pipe( + Effect.catchTag("Gateway.NotAttached", () => Effect.void), + ); + yield* session.note(`Detached from VPC: ${olds.vpcId}`); + } + + // Attach to new VPC if specified + if (news.vpcId) { + yield* ec2 + .attachInternetGateway({ + InternetGatewayId: internetGatewayId, + VpcId: news.vpcId, + }) + .pipe( + Effect.retry({ + while: (e) => e._tag === "InvalidVpcID.NotFound", + schedule: Schedule.exponential(100), + }), + ); + yield* session.note(`Attached to VPC: ${news.vpcId}`); + } + } + + // Handle tag updates + if ( + JSON.stringify(news.tags ?? {}) !== JSON.stringify(olds.tags ?? {}) + ) { + const alchemyTags = tagged(output.internetGatewayId); + const userTags = news.tags ?? {}; + const allTags = { ...alchemyTags, ...userTags }; + + // Delete old tags that are no longer present + const oldTagKeys = Object.keys(olds.tags ?? {}); + const newTagKeys = Object.keys(news.tags ?? {}); + const tagsToDelete = oldTagKeys.filter( + (key) => !newTagKeys.includes(key), + ); + + if (tagsToDelete.length > 0) { + yield* ec2.deleteTags({ + Resources: [internetGatewayId], + Tags: tagsToDelete.map((key) => ({ Key: key })), + }); + } + + // Create/update tags + yield* ec2.createTags({ + Resources: [internetGatewayId], + Tags: createTagsList(allTags), + }); + + yield* session.note("Updated tags"); + } + + // Re-describe to get current state + const igw = yield* describeInternetGateway( + ec2, + internetGatewayId, + session, + ); + + return { + ...output, + vpcId: news.vpcId, + attachments: igw.Attachments?.map((a) => ({ + state: a.State! as + | "attaching" + | "available" + | "detaching" + | "detached", + vpcId: a.VpcId!, + })), + }; + }), + + delete: Effect.fn(function* ({ output, session }) { + const internetGatewayId = output.internetGatewayId; + + yield* session.note( + `Deleting internet gateway: ${internetGatewayId}`, + ); + + // 1. Detach from all VPCs first + if (output.attachments && output.attachments.length > 0) { + for (const attachment of output.attachments) { + yield* ec2 + .detachInternetGateway({ + InternetGatewayId: internetGatewayId, + VpcId: attachment.vpcId, + }) + .pipe( + Effect.tapError(Effect.logDebug), + Effect.catchTag("Gateway.NotAttached", () => Effect.void), + Effect.catchTag( + "InvalidInternetGatewayID.NotFound", + () => Effect.void, + ), + ); + yield* session.note(`Detached from VPC: ${attachment.vpcId}`); + } + } + + // 2. Delete the internet gateway + yield* ec2 + .deleteInternetGateway({ + InternetGatewayId: internetGatewayId, + DryRun: false, + }) + .pipe( + Effect.tapError(Effect.logDebug), + Effect.catchTag( + "InvalidInternetGatewayID.NotFound", + () => Effect.void, + ), + // Retry on dependency violations + Effect.retry({ + while: (e) => { + return ( + e._tag === "DependencyViolation" || + (e._tag === "ValidationError" && + e.message?.includes("DependencyViolation")) + ); + }, + schedule: Schedule.exponential(1000, 1.5).pipe( + Schedule.intersect(Schedule.recurs(10)), + Schedule.tapOutput(([, attempt]) => + session.note( + `Waiting for dependencies to clear... (attempt ${attempt + 1})`, + ), + ), + ), + }), + ); + + // 3. Wait for internet gateway to be fully deleted + yield* waitForInternetGatewayDeleted(ec2, internetGatewayId, session); + + yield* session.note( + `Internet gateway ${internetGatewayId} deleted successfully`, + ); + }), + } satisfies ProviderService; + }), + ); + +/** + * Describe an internet gateway by ID + */ +const describeInternetGateway = ( + ec2: EC2, + internetGatewayId: string, + _session?: ScopedPlanStatusSession, +) => + Effect.gen(function* () { + const result = yield* ec2 + .describeInternetGateways({ InternetGatewayIds: [internetGatewayId] }) + .pipe( + Effect.catchTag("InvalidInternetGatewayID.NotFound", () => + Effect.succeed({ InternetGateways: [] }), + ), + ); + + const igw = result.InternetGateways?.[0]; + if (!igw) { + return yield* Effect.fail(new Error("Internet gateway not found")); + } + return igw; + }); + +/** + * Wait for internet gateway to be deleted + */ +const waitForInternetGatewayDeleted = ( + ec2: EC2, + internetGatewayId: string, + session: ScopedPlanStatusSession, +) => + Effect.gen(function* () { + yield* Effect.retry( + Effect.gen(function* () { + const result = yield* ec2 + .describeInternetGateways({ InternetGatewayIds: [internetGatewayId] }) + .pipe( + Effect.tapError(Effect.logDebug), + Effect.catchTag("InvalidInternetGatewayID.NotFound", () => + Effect.succeed({ InternetGateways: [] }), + ), + ); + + if (!result.InternetGateways || result.InternetGateways.length === 0) { + return; // Successfully deleted + } + + // Still exists, fail to trigger retry + return yield* Effect.fail(new Error("Internet gateway still exists")); + }), + { + schedule: Schedule.fixed(2000).pipe( + Schedule.intersect(Schedule.recurs(15)), + Schedule.tapOutput(([, attempt]) => + session.note( + `Waiting for internet gateway deletion... (${(attempt + 1) * 2}s)`, + ), + ), + ), + }, + ); + }); diff --git a/alchemy-effect/src/aws/ec2/internet-gateway.ts b/alchemy-effect/src/aws/ec2/internet-gateway.ts new file mode 100644 index 0000000..464d6cf --- /dev/null +++ b/alchemy-effect/src/aws/ec2/internet-gateway.ts @@ -0,0 +1,79 @@ +import type { Input } from "../../input.ts"; +import { Resource } from "../../resource.ts"; +import type { AccountID } from "../account.ts"; +import type { RegionID } from "../region.ts"; +import type { VpcId } from "./vpc.ts"; + +export const InternetGateway = Resource<{ + ( + id: ID, + props: Props, + ): InternetGateway; +}>("AWS.EC2.InternetGateway"); + +export interface InternetGateway< + ID extends string = string, + Props extends InternetGatewayProps = InternetGatewayProps, +> extends Resource< + "AWS.EC2.InternetGateway", + ID, + Props, + InternetGatewayAttrs>, + InternetGateway + > {} + +export type InternetGatewayId = `igw-${ID}`; +export const InternetGatewayId = ( + id: ID, +): ID & InternetGatewayId => `igw-${id}` as ID & InternetGatewayId; + +export interface InternetGatewayProps { + /** + * The VPC to attach the internet gateway to. + * If provided, the internet gateway will be automatically attached to the VPC. + * Optional - you can create an unattached internet gateway and attach it later. + */ + vpcId?: Input; + + /** + * Tags to assign to the internet gateway. + * These will be merged with alchemy auto-tags (alchemy::app, alchemy::stage, alchemy::id). + */ + tags?: Record>; +} + +export interface InternetGatewayAttrs { + /** + * The ID of the internet gateway. + */ + internetGatewayId: InternetGatewayId; + + /** + * The Amazon Resource Name (ARN) of the internet gateway. + */ + internetGatewayArn: `arn:aws:ec2:${RegionID}:${AccountID}:internet-gateway/${this["internetGatewayId"]}`; + + /** + * The ID of the VPC the internet gateway is attached to (if any). + */ + vpcId?: Props["vpcId"]; + + /** + * The ID of the AWS account that owns the internet gateway. + */ + ownerId?: string; + + /** + * The attachments for the internet gateway. + */ + attachments?: Array<{ + /** + * The current state of the attachment. + */ + state: "attaching" | "available" | "detaching" | "detached"; + /** + * The ID of the VPC. + */ + vpcId: string; + }>; +} diff --git a/alchemy-effect/src/aws/ec2/route-table-association.provider.ts b/alchemy-effect/src/aws/ec2/route-table-association.provider.ts new file mode 100644 index 0000000..6f0f616 --- /dev/null +++ b/alchemy-effect/src/aws/ec2/route-table-association.provider.ts @@ -0,0 +1,214 @@ +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +import type { ScopedPlanStatusSession } from "../../cli/service.ts"; +import type { ProviderService } from "../../provider.ts"; +import { EC2Client } from "./client.ts"; +import { + RouteTableAssociation, + type RouteTableAssociationAttrs, + type RouteTableAssociationId, + type RouteTableAssociationProps, +} from "./route-table-association.ts"; + +export const routeTableAssociationProvider = () => + RouteTableAssociation.provider.effect( + Effect.gen(function* () { + const ec2 = yield* EC2Client; + + return { + stables: ["associationId", "subnetId", "gatewayId"], + diff: Effect.fn(function* ({ news, olds }) { + // Subnet/Gateway change requires replacement (use ReplaceRouteTableAssociation internally) + if (olds.subnetId !== news.subnetId) { + return { action: "replace" }; + } + if (olds.gatewayId !== news.gatewayId) { + return { action: "replace" }; + } + // Route table change can be done via ReplaceRouteTableAssociation + }), + + create: Effect.fn(function* ({ news, session }) { + // Call AssociateRouteTable + const result = yield* ec2 + .associateRouteTable({ + RouteTableId: news.routeTableId, + SubnetId: news.subnetId, + GatewayId: news.gatewayId, + DryRun: false, + }) + .pipe( + Effect.retry({ + // Retry if route table or subnet/gateway is not yet available + while: (e) => + e._tag === "InvalidRouteTableID.NotFound" || + e._tag === "InvalidSubnetID.NotFound", + schedule: Schedule.exponential(100), + }), + ); + + const associationId = + result.AssociationId! as RouteTableAssociationId; + yield* session.note( + `Route table association created: ${associationId}`, + ); + + // Wait for association to be associated + yield* waitForAssociationState( + ec2, + news.routeTableId, + associationId, + "associated", + session, + ); + + // Return attributes + return { + associationId, + routeTableId: news.routeTableId, + subnetId: news.subnetId, + gatewayId: news.gatewayId, + associationState: { + state: result.AssociationState?.State ?? "associated", + statusMessage: result.AssociationState?.StatusMessage, + }, + } satisfies RouteTableAssociationAttrs; + }), + + update: Effect.fn(function* ({ news, olds, output, session }) { + // If route table changed, use ReplaceRouteTableAssociation + if (news.routeTableId !== olds.routeTableId) { + const result = yield* ec2.replaceRouteTableAssociation({ + AssociationId: output.associationId, + RouteTableId: news.routeTableId, + DryRun: false, + }); + + const newAssociationId = + result.NewAssociationId! as RouteTableAssociationId; + yield* session.note( + `Route table association replaced: ${newAssociationId}`, + ); + + // Wait for new association to be associated + yield* waitForAssociationState( + ec2, + news.routeTableId, + newAssociationId, + "associated", + session, + ); + + return { + associationId: newAssociationId, + routeTableId: news.routeTableId, + subnetId: news.subnetId, + gatewayId: news.gatewayId, + associationState: { + state: result.AssociationState?.State ?? "associated", + statusMessage: result.AssociationState?.StatusMessage, + }, + }; + } + + // No changes needed + return output; + }), + + delete: Effect.fn(function* ({ output, session }) { + yield* session.note( + `Deleting route table association: ${output.associationId}`, + ); + + // Disassociate the route table + yield* ec2 + .disassociateRouteTable({ + AssociationId: output.associationId, + DryRun: false, + }) + .pipe( + Effect.tapError(Effect.log), + Effect.catchTag( + "InvalidAssociationID.NotFound", + () => Effect.void, + ), + ); + + yield* session.note( + `Route table association ${output.associationId} deleted successfully`, + ); + }), + } satisfies ProviderService; + }), + ); + +/** + * Wait for association to reach a specific state + */ +const waitForAssociationState = ( + ec2: import("itty-aws/ec2").EC2, + routeTableId: string, + associationId: string, + targetState: + | "associating" + | "associated" + | "disassociating" + | "disassociated", + session?: ScopedPlanStatusSession, +) => + Effect.retry( + Effect.gen(function* () { + const result = yield* ec2 + .describeRouteTables({ RouteTableIds: [routeTableId] }) + .pipe( + Effect.catchTag("InvalidRouteTableID.NotFound", () => + Effect.succeed({ RouteTables: [] }), + ), + ); + + const routeTable = result.RouteTables?.[0]; + if (!routeTable) { + return yield* Effect.fail(new Error("Route table not found")); + } + + const association = routeTable.Associations?.find( + (a) => a.RouteTableAssociationId === associationId, + ); + + if (!association) { + // Association might not exist yet, retry + return yield* Effect.fail(new Error("Association not found")); + } + + if (association.AssociationState?.State === targetState) { + return; + } + + if (association.AssociationState?.State === "failed") { + return yield* Effect.fail( + new Error( + `Association failed: ${association.AssociationState.StatusMessage}`, + ), + ); + } + + // Still in progress, fail to trigger retry + return yield* Effect.fail( + new Error(`Association state: ${association.AssociationState?.State}`), + ); + }), + { + schedule: Schedule.fixed(1000).pipe( + // Check every second + Schedule.intersect(Schedule.recurs(30)), // Max 30 seconds + Schedule.tapOutput(([, attempt]) => + session + ? session.note( + `Waiting for association to be ${targetState}... (${attempt + 1}s)`, + ) + : Effect.void, + ), + ), + }, + ); diff --git a/alchemy-effect/src/aws/ec2/route-table-association.ts b/alchemy-effect/src/aws/ec2/route-table-association.ts new file mode 100644 index 0000000..131644a --- /dev/null +++ b/alchemy-effect/src/aws/ec2/route-table-association.ts @@ -0,0 +1,82 @@ +import type * as EC2 from "itty-aws/ec2"; +import type { Input } from "../../input.ts"; +import { Resource } from "../../resource.ts"; +import type { RouteTableId } from "./route-table.ts"; +import type { SubnetId } from "./subnet.ts"; + +export const RouteTableAssociation = Resource<{ + ( + id: ID, + props: Props, + ): RouteTableAssociation; +}>("AWS.EC2.RouteTableAssociation"); + +export interface RouteTableAssociation< + ID extends string = string, + Props extends RouteTableAssociationProps = RouteTableAssociationProps, +> extends Resource< + "AWS.EC2.RouteTableAssociation", + ID, + Props, + RouteTableAssociationAttrs>, + RouteTableAssociation + > {} + +export type RouteTableAssociationId = + `rtbassoc-${ID}`; +export const RouteTableAssociationId = ( + id: ID, +): ID & RouteTableAssociationId => + `rtbassoc-${id}` as ID & RouteTableAssociationId; + +export interface RouteTableAssociationProps { + /** + * The ID of the route table. + * Required. + */ + routeTableId: Input; + + /** + * The ID of the subnet to associate with the route table. + * Either subnetId or gatewayId is required, but not both. + */ + subnetId?: Input; + + /** + * The ID of the gateway (internet gateway or virtual private gateway) to associate with the route table. + * Either subnetId or gatewayId is required, but not both. + */ + gatewayId?: Input; +} + +export interface RouteTableAssociationAttrs< + Props extends RouteTableAssociationProps, +> { + /** + * The ID of the association. + */ + associationId: RouteTableAssociationId; + + /** + * The ID of the route table. + */ + routeTableId: Props["routeTableId"]; + + /** + * The ID of the subnet (if the association is with a subnet). + */ + subnetId?: Props["subnetId"]; + + /** + * The ID of the gateway (if the association is with a gateway). + */ + gatewayId?: Props["gatewayId"]; + + /** + * The state of the association. + */ + associationState: { + state: EC2.RouteTableAssociationStateCode; + statusMessage?: string; + }; +} diff --git a/alchemy-effect/src/aws/ec2/route-table.provider.ts b/alchemy-effect/src/aws/ec2/route-table.provider.ts new file mode 100644 index 0000000..3a035f0 --- /dev/null +++ b/alchemy-effect/src/aws/ec2/route-table.provider.ts @@ -0,0 +1,306 @@ +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +import type { EC2 } from "itty-aws/ec2"; + +import type { ScopedPlanStatusSession } from "../../cli/service.ts"; +import type { ProviderService } from "../../provider.ts"; +import { createTagger, createTagsList } from "../../tags.ts"; +import { Account } from "../account.ts"; +import { Region } from "../region.ts"; +import { EC2Client } from "./client.ts"; +import { + RouteTable, + type RouteTableAttrs, + type RouteTableId, + type RouteTableProps, +} from "./route-table.ts"; + +export const routeTableProvider = () => + RouteTable.provider.effect( + Effect.gen(function* () { + const ec2 = yield* EC2Client; + const region = yield* Region; + const accountId = yield* Account; + const tagged = yield* createTagger(); + + return { + stables: ["routeTableId", "ownerId", "routeTableArn", "vpcId"], + diff: Effect.fn(function* ({ news, olds }) { + // VpcId change requires replacement + if (olds.vpcId !== news.vpcId) { + return { action: "replace" }; + } + // Tags can be updated in-place + }), + + create: Effect.fn(function* ({ id, news, session }) { + // 1. Prepare tags + const alchemyTags = tagged(id); + const userTags = news.tags ?? {}; + const allTags = { ...alchemyTags, ...userTags }; + + // 2. Call CreateRouteTable + const createResult = yield* ec2 + .createRouteTable({ + VpcId: news.vpcId, + TagSpecifications: [ + { + ResourceType: "route-table", + Tags: createTagsList(allTags), + }, + ], + DryRun: false, + }) + .pipe( + Effect.retry({ + // Retry if VPC is not yet available + while: (e) => e._tag === "InvalidVpcID.NotFound", + schedule: Schedule.exponential(100), + }), + ); + + const routeTableId = createResult.RouteTable! + .RouteTableId! as RouteTableId; + yield* session.note(`Route table created: ${routeTableId}`); + + // 3. Describe to get full details + const routeTable = yield* describeRouteTable( + ec2, + routeTableId, + session, + ); + + // 4. Return attributes + return { + routeTableId, + routeTableArn: + `arn:aws:ec2:${region}:${accountId}:route-table/${routeTableId}` as RouteTableAttrs["routeTableArn"], + vpcId: news.vpcId, + ownerId: routeTable.OwnerId, + associations: routeTable.Associations?.map((assoc) => ({ + main: assoc.Main ?? false, + routeTableAssociationId: assoc.RouteTableAssociationId, + routeTableId: assoc.RouteTableId, + subnetId: assoc.SubnetId, + gatewayId: assoc.GatewayId, + associationState: assoc.AssociationState + ? { + state: assoc.AssociationState.State!, + statusMessage: assoc.AssociationState.StatusMessage, + } + : undefined, + })), + routes: routeTable.Routes?.map((route) => ({ + destinationCidrBlock: route.DestinationCidrBlock, + destinationIpv6CidrBlock: route.DestinationIpv6CidrBlock, + destinationPrefixListId: route.DestinationPrefixListId, + egressOnlyInternetGatewayId: route.EgressOnlyInternetGatewayId, + gatewayId: route.GatewayId, + instanceId: route.InstanceId, + instanceOwnerId: route.InstanceOwnerId, + natGatewayId: route.NatGatewayId, + transitGatewayId: route.TransitGatewayId, + localGatewayId: route.LocalGatewayId, + carrierGatewayId: route.CarrierGatewayId, + networkInterfaceId: route.NetworkInterfaceId, + origin: route.Origin!, + state: route.State!, + vpcPeeringConnectionId: route.VpcPeeringConnectionId, + coreNetworkArn: route.CoreNetworkArn, + })), + propagatingVgws: routeTable.PropagatingVgws?.map((vgw) => ({ + gatewayId: vgw.GatewayId!, + })), + } satisfies RouteTableAttrs; + }), + + update: Effect.fn(function* ({ news, olds, output, session }) { + const routeTableId = output.routeTableId; + + // Handle tag updates + if ( + JSON.stringify(news.tags ?? {}) !== JSON.stringify(olds.tags ?? {}) + ) { + const alchemyTags = tagged(output.routeTableId); + const userTags = news.tags ?? {}; + const allTags = { ...alchemyTags, ...userTags }; + + // Delete old tags that are no longer present + const oldTagKeys = Object.keys(olds.tags ?? {}); + const newTagKeys = Object.keys(news.tags ?? {}); + const tagsToDelete = oldTagKeys.filter( + (key) => !newTagKeys.includes(key), + ); + + if (tagsToDelete.length > 0) { + yield* ec2.deleteTags({ + Resources: [routeTableId], + Tags: tagsToDelete.map((key) => ({ Key: key })), + }); + } + + // Create/update tags + yield* ec2.createTags({ + Resources: [routeTableId], + Tags: createTagsList(allTags), + }); + + yield* session.note("Updated tags"); + } + + // Re-describe to get current state + const routeTable = yield* describeRouteTable( + ec2, + routeTableId, + session, + ); + + return { + ...output, + associations: routeTable.Associations?.map((assoc) => ({ + main: assoc.Main ?? false, + routeTableAssociationId: assoc.RouteTableAssociationId, + routeTableId: assoc.RouteTableId, + subnetId: assoc.SubnetId, + gatewayId: assoc.GatewayId, + associationState: assoc.AssociationState + ? { + state: assoc.AssociationState.State!, + statusMessage: assoc.AssociationState.StatusMessage, + } + : undefined, + })), + routes: routeTable.Routes?.map((route) => ({ + destinationCidrBlock: route.DestinationCidrBlock, + destinationIpv6CidrBlock: route.DestinationIpv6CidrBlock, + destinationPrefixListId: route.DestinationPrefixListId, + egressOnlyInternetGatewayId: route.EgressOnlyInternetGatewayId, + gatewayId: route.GatewayId, + instanceId: route.InstanceId, + instanceOwnerId: route.InstanceOwnerId, + natGatewayId: route.NatGatewayId, + transitGatewayId: route.TransitGatewayId, + localGatewayId: route.LocalGatewayId, + carrierGatewayId: route.CarrierGatewayId, + networkInterfaceId: route.NetworkInterfaceId, + origin: route.Origin!, + state: route.State!, + vpcPeeringConnectionId: route.VpcPeeringConnectionId, + coreNetworkArn: route.CoreNetworkArn, + })), + propagatingVgws: routeTable.PropagatingVgws?.map((vgw) => ({ + gatewayId: vgw.GatewayId!, + })), + }; + }), + + delete: Effect.fn(function* ({ output, session }) { + const routeTableId = output.routeTableId; + + yield* session.note(`Deleting route table: ${routeTableId}`); + + // 1. Attempt to delete route table + yield* ec2 + .deleteRouteTable({ + RouteTableId: routeTableId, + DryRun: false, + }) + .pipe( + Effect.tapError(Effect.logDebug), + Effect.catchTag( + "InvalidRouteTableID.NotFound", + () => Effect.void, + ), + // Retry on dependency violations (associations still being deleted) + Effect.retry({ + // DependencyViolation means there are still dependent resources + while: (e) => { + return e._tag === "DependencyViolation"; + }, + schedule: Schedule.exponential(1000, 1.5).pipe( + Schedule.intersect(Schedule.recurs(10)), // Try up to 10 times + Schedule.tapOutput(([, attempt]) => + session.note( + `Waiting for dependencies to clear... (attempt ${attempt + 1})`, + ), + ), + ), + }), + ); + + // 2. Wait for route table to be fully deleted + yield* waitForRouteTableDeleted(ec2, routeTableId, session); + + yield* session.note( + `Route table ${routeTableId} deleted successfully`, + ); + }), + } satisfies ProviderService; + }), + ); + +/** + * Describe a route table by ID + */ +const describeRouteTable = ( + ec2: EC2, + routeTableId: string, + _session?: ScopedPlanStatusSession, +) => + Effect.gen(function* () { + const result = yield* ec2 + .describeRouteTables({ RouteTableIds: [routeTableId] }) + .pipe( + Effect.catchTag("InvalidRouteTableID.NotFound", () => + Effect.succeed({ RouteTables: [] }), + ), + ); + + const routeTable = result.RouteTables?.[0]; + if (!routeTable) { + return yield* Effect.fail(new Error("Route table not found")); + } + return routeTable; + }); + +/** + * Wait for route table to be deleted + */ +const waitForRouteTableDeleted = ( + ec2: EC2, + routeTableId: string, + session: ScopedPlanStatusSession, +) => + Effect.gen(function* () { + yield* Effect.retry( + Effect.gen(function* () { + const result = yield* ec2 + .describeRouteTables({ RouteTableIds: [routeTableId] }) + .pipe( + Effect.tapError(Effect.logDebug), + Effect.catchTag("InvalidRouteTableID.NotFound", () => + Effect.succeed({ RouteTables: [] }), + ), + ); + + if (!result.RouteTables || result.RouteTables.length === 0) { + return; // Successfully deleted + } + + // Still exists, fail to trigger retry + return yield* Effect.fail(new Error("Route table still exists")); + }), + { + schedule: Schedule.fixed(2000).pipe( + // Check every 2 seconds + Schedule.intersect(Schedule.recurs(15)), // Max 30 seconds + Schedule.tapOutput(([, attempt]) => + session.note( + `Waiting for route table deletion... (${(attempt + 1) * 2}s)`, + ), + ), + ), + }, + ); + }); diff --git a/alchemy-effect/src/aws/ec2/route-table.ts b/alchemy-effect/src/aws/ec2/route-table.ts new file mode 100644 index 0000000..8409ef3 --- /dev/null +++ b/alchemy-effect/src/aws/ec2/route-table.ts @@ -0,0 +1,175 @@ +import type * as EC2 from "itty-aws/ec2"; +import type { Input } from "../../input.ts"; +import { Resource } from "../../resource.ts"; +import type { AccountID } from "../account.ts"; +import type { RegionID } from "../region.ts"; +import type { VpcId } from "./vpc.ts"; + +export const RouteTable = Resource<{ + ( + id: ID, + props: Props, + ): RouteTable; +}>("AWS.EC2.RouteTable"); + +export interface RouteTable< + ID extends string = string, + Props extends RouteTableProps = RouteTableProps, +> extends Resource< + "AWS.EC2.RouteTable", + ID, + Props, + RouteTableAttrs>, + RouteTable + > {} + +export type RouteTableId = `rtb-${ID}`; +export const RouteTableId = ( + id: ID, +): ID & RouteTableId => `rtb-${id}` as ID & RouteTableId; + +export interface RouteTableProps { + /** + * The VPC to create the route table in. + * Required. + */ + vpcId: Input; + + /** + * Tags to assign to the route table. + * These will be merged with alchemy auto-tags (alchemy::app, alchemy::stage, alchemy::id). + */ + tags?: Record>; +} + +export interface RouteTableAttrs { + /** + * The ID of the VPC the route table is in. + */ + vpcId: Props["vpcId"]; + + /** + * The ID of the route table. + */ + routeTableId: RouteTableId; + + /** + * The Amazon Resource Name (ARN) of the route table. + */ + routeTableArn: `arn:aws:ec2:${RegionID}:${AccountID}:route-table/${this["routeTableId"]}`; + + /** + * The ID of the AWS account that owns the route table. + */ + ownerId?: string; + + /** + * The associations between the route table and subnets or gateways. + */ + associations?: Array<{ + /** + * Whether this is the main route table for the VPC. + */ + main: boolean; + /** + * The ID of the association. + */ + routeTableAssociationId?: string; + /** + * The ID of the route table. + */ + routeTableId?: string; + /** + * The ID of the subnet (if the association is with a subnet). + */ + subnetId?: string; + /** + * The ID of the gateway (if the association is with a gateway). + */ + gatewayId?: string; + /** + * The state of the association. + */ + associationState?: { + state: EC2.RouteTableAssociationStateCode; + statusMessage?: string; + }; + }>; + + /** + * The routes in the route table. + */ + routes?: Array<{ + /** + * The IPv4 CIDR block used for the destination match. + */ + destinationCidrBlock?: string; + /** + * The IPv6 CIDR block used for the destination match. + */ + destinationIpv6CidrBlock?: string; + /** + * The prefix of the AWS service. + */ + destinationPrefixListId?: string; + /** + * The ID of the egress-only internet gateway. + */ + egressOnlyInternetGatewayId?: string; + /** + * The ID of the gateway (internet gateway or virtual private gateway). + */ + gatewayId?: string; + /** + * The ID of the NAT instance. + */ + instanceId?: string; + /** + * The ID of AWS account that owns the NAT instance. + */ + instanceOwnerId?: string; + /** + * The ID of the NAT gateway. + */ + natGatewayId?: string; + /** + * The ID of the transit gateway. + */ + transitGatewayId?: string; + /** + * The ID of the local gateway. + */ + localGatewayId?: string; + /** + * The ID of the carrier gateway. + */ + carrierGatewayId?: string; + /** + * The ID of the network interface. + */ + networkInterfaceId?: string; + /** + * Describes how the route was created. + */ + origin: EC2.RouteOrigin; + /** + * The state of the route. + */ + state: EC2.RouteState; + /** + * The ID of the VPC peering connection. + */ + vpcPeeringConnectionId?: string; + /** + * The Amazon Resource Name (ARN) of the core network. + */ + coreNetworkArn?: string; + }>; + + /** + * Any virtual private gateway (VGW) propagating routes. + */ + propagatingVgws?: Array<{ + gatewayId: string; + }>; +} diff --git a/alchemy-effect/src/aws/ec2/route.provider.ts b/alchemy-effect/src/aws/ec2/route.provider.ts new file mode 100644 index 0000000..97e988b --- /dev/null +++ b/alchemy-effect/src/aws/ec2/route.provider.ts @@ -0,0 +1,213 @@ +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +import type { EC2 } from "itty-aws/ec2"; + +import { somePropsAreDifferent } from "../../diff.ts"; +import type { ProviderService } from "../../provider.ts"; +import { EC2Client } from "./client.ts"; +import { Route, type RouteAttrs, type RouteProps } from "./route.ts"; + +export const routeProvider = () => + Route.provider.effect( + Effect.gen(function* () { + const ec2 = yield* EC2Client; + + return { + diff: Effect.fn(function* ({ news, olds }) { + // Route table change requires replacement + if (olds.routeTableId !== news.routeTableId) { + return { action: "replace" }; + } + + // Destination change requires replacement + if ( + somePropsAreDifferent(olds, news, [ + "destinationCidrBlock", + "destinationIpv6CidrBlock", + "destinationPrefixListId", + ]) + ) { + return { action: "replace" }; + } + + // Target change can be done via ReplaceRoute (update) + }), + + create: Effect.fn(function* ({ news, session }) { + // Call CreateRoute + yield* ec2 + .createRoute({ + RouteTableId: news.routeTableId, + DestinationCidrBlock: news.destinationCidrBlock, + DestinationIpv6CidrBlock: news.destinationIpv6CidrBlock, + DestinationPrefixListId: news.destinationPrefixListId, + GatewayId: news.gatewayId, + NatGatewayId: news.natGatewayId, + InstanceId: news.instanceId, + NetworkInterfaceId: news.networkInterfaceId, + VpcPeeringConnectionId: news.vpcPeeringConnectionId, + TransitGatewayId: news.transitGatewayId, + LocalGatewayId: news.localGatewayId, + CarrierGatewayId: news.carrierGatewayId, + EgressOnlyInternetGatewayId: news.egressOnlyInternetGatewayId, + CoreNetworkArn: news.coreNetworkArn, + VpcEndpointId: news.vpcEndpointId, + DryRun: false, + }) + .pipe( + Effect.retry({ + // Retry if route table is not yet available + while: (e) => e._tag === "InvalidRouteTableID.NotFound", + schedule: Schedule.exponential(100), + }), + ); + + const dest = + news.destinationCidrBlock || + news.destinationIpv6CidrBlock || + news.destinationPrefixListId || + "unknown"; + yield* session.note(`Route created: ${dest}`); + + // Describe to get route details + const route = yield* describeRoute(ec2, news.routeTableId, news); + + // Return attributes + return { + routeTableId: news.routeTableId, + destinationCidrBlock: news.destinationCidrBlock, + destinationIpv6CidrBlock: news.destinationIpv6CidrBlock, + destinationPrefixListId: news.destinationPrefixListId, + origin: route?.Origin ?? "CreateRoute", + state: route?.State ?? "active", + gatewayId: route?.GatewayId, + natGatewayId: route?.NatGatewayId, + instanceId: route?.InstanceId, + networkInterfaceId: route?.NetworkInterfaceId, + vpcPeeringConnectionId: route?.VpcPeeringConnectionId, + transitGatewayId: route?.TransitGatewayId, + localGatewayId: route?.LocalGatewayId, + carrierGatewayId: route?.CarrierGatewayId, + egressOnlyInternetGatewayId: route?.EgressOnlyInternetGatewayId, + coreNetworkArn: route?.CoreNetworkArn, + } satisfies RouteAttrs; + }), + + update: Effect.fn(function* ({ news, output, session }) { + // Use ReplaceRoute to update the target + yield* ec2 + .replaceRoute({ + RouteTableId: news.routeTableId, + DestinationCidrBlock: news.destinationCidrBlock, + DestinationIpv6CidrBlock: news.destinationIpv6CidrBlock, + DestinationPrefixListId: news.destinationPrefixListId, + GatewayId: news.gatewayId, + NatGatewayId: news.natGatewayId, + InstanceId: news.instanceId, + NetworkInterfaceId: news.networkInterfaceId, + VpcPeeringConnectionId: news.vpcPeeringConnectionId, + TransitGatewayId: news.transitGatewayId, + LocalGatewayId: news.localGatewayId, + CarrierGatewayId: news.carrierGatewayId, + EgressOnlyInternetGatewayId: news.egressOnlyInternetGatewayId, + CoreNetworkArn: news.coreNetworkArn, + DryRun: false, + }) + .pipe( + Effect.tapError(Effect.log), + Effect.retry({ + while: (e) => e._tag === "InvalidRouteTableID.NotFound", + schedule: Schedule.exponential(100), + }), + ); + + yield* session.note("Route target updated"); + + // Describe to get updated route details + const route = yield* describeRoute(ec2, news.routeTableId, news); + + return { + ...output, + origin: route?.Origin ?? output.origin, + state: route?.State ?? output.state, + gatewayId: route?.GatewayId, + natGatewayId: route?.NatGatewayId, + instanceId: route?.InstanceId, + networkInterfaceId: route?.NetworkInterfaceId, + vpcPeeringConnectionId: route?.VpcPeeringConnectionId, + transitGatewayId: route?.TransitGatewayId, + localGatewayId: route?.LocalGatewayId, + carrierGatewayId: route?.CarrierGatewayId, + egressOnlyInternetGatewayId: route?.EgressOnlyInternetGatewayId, + coreNetworkArn: route?.CoreNetworkArn, + }; + }), + + delete: Effect.fn(function* ({ output, session }) { + const dest = + output.destinationCidrBlock || + output.destinationIpv6CidrBlock || + output.destinationPrefixListId || + "unknown"; + + yield* session.note(`Deleting route: ${dest}`); + + // Delete the route + yield* ec2 + .deleteRoute({ + RouteTableId: output.routeTableId, + DestinationCidrBlock: output.destinationCidrBlock, + DestinationIpv6CidrBlock: output.destinationIpv6CidrBlock, + DestinationPrefixListId: output.destinationPrefixListId, + DryRun: false, + }) + .pipe( + Effect.tapError(Effect.logDebug), + Effect.catchTag("InvalidRoute.NotFound", () => Effect.void), + Effect.catchTag( + "InvalidRouteTableID.NotFound", + () => Effect.void, + ), + ); + + yield* session.note(`Route ${dest} deleted successfully`); + }), + } satisfies ProviderService; + }), + ); + +/** + * Find a specific route in a route table + */ +const describeRoute = (ec2: EC2, routeTableId: string, props: RouteProps) => + Effect.gen(function* () { + const result = yield* ec2 + .describeRouteTables({ RouteTableIds: [routeTableId] }) + .pipe( + Effect.catchTag("InvalidRouteTableID.NotFound", () => + Effect.succeed({ RouteTables: [] }), + ), + ); + + const routeTable = result.RouteTables?.[0]; + if (!routeTable) { + return undefined; + } + + // Find the matching route + const route = routeTable.Routes?.find((r) => { + if (props.destinationCidrBlock) { + return r.DestinationCidrBlock === props.destinationCidrBlock; + } + if (props.destinationIpv6CidrBlock) { + return r.DestinationIpv6CidrBlock === props.destinationIpv6CidrBlock; + } + if (props.destinationPrefixListId) { + return r.DestinationPrefixListId === props.destinationPrefixListId; + } + return false; + }); + + return route; + }); diff --git a/alchemy-effect/src/aws/ec2/route.ts b/alchemy-effect/src/aws/ec2/route.ts new file mode 100644 index 0000000..e79a03b --- /dev/null +++ b/alchemy-effect/src/aws/ec2/route.ts @@ -0,0 +1,192 @@ +import type * as EC2 from "itty-aws/ec2"; +import type { Input } from "../../input.ts"; +import { Resource } from "../../resource.ts"; +import type { RouteTableId } from "./route-table.ts"; + +export const Route = Resource<{ + ( + id: ID, + props: Props, + ): Route; +}>("AWS.EC2.Route"); + +export interface Route< + ID extends string = string, + Props extends RouteProps = RouteProps, +> extends Resource< + "AWS.EC2.Route", + ID, + Props, + RouteAttrs>, + Route + > {} + +export interface RouteProps { + /** + * The ID of the route table where the route will be added. + * Required. + */ + routeTableId: Input; + + /** + * The IPv4 CIDR block used for the destination match. + * Either destinationCidrBlock, destinationIpv6CidrBlock, or destinationPrefixListId is required. + * @example "0.0.0.0/0" + */ + destinationCidrBlock?: string; + + /** + * The IPv6 CIDR block used for the destination match. + * Either destinationCidrBlock, destinationIpv6CidrBlock, or destinationPrefixListId is required. + * @example "::/0" + */ + destinationIpv6CidrBlock?: string; + + /** + * The ID of a prefix list used for the destination match. + * Either destinationCidrBlock, destinationIpv6CidrBlock, or destinationPrefixListId is required. + */ + destinationPrefixListId?: string; + + // ---- Target properties (exactly one required) ---- + + /** + * The ID of an internet gateway or virtual private gateway. + */ + gatewayId?: Input; + + /** + * The ID of a NAT gateway. + */ + natGatewayId?: Input; + + /** + * The ID of a NAT instance in your VPC. + * This operation fails unless exactly one network interface is attached. + */ + instanceId?: Input; + + /** + * The ID of a network interface. + */ + networkInterfaceId?: Input; + + /** + * The ID of a VPC peering connection. + */ + vpcPeeringConnectionId?: Input; + + /** + * The ID of a transit gateway. + */ + transitGatewayId?: Input; + + /** + * The ID of a local gateway. + */ + localGatewayId?: Input; + + /** + * The ID of a carrier gateway. + * Use for Wavelength Zones only. + */ + carrierGatewayId?: Input; + + /** + * The ID of an egress-only internet gateway. + * IPv6 traffic only. + */ + egressOnlyInternetGatewayId?: Input; + + /** + * The Amazon Resource Name (ARN) of the core network. + */ + coreNetworkArn?: Input; + + /** + * The ID of a VPC endpoint for Gateway Load Balancer. + */ + vpcEndpointId?: Input; +} + +export interface RouteAttrs { + /** + * The ID of the route table. + */ + routeTableId: Props["routeTableId"]; + + /** + * The IPv4 CIDR block used for the destination match. + */ + destinationCidrBlock?: Props["destinationCidrBlock"]; + + /** + * The IPv6 CIDR block used for the destination match. + */ + destinationIpv6CidrBlock?: Props["destinationIpv6CidrBlock"]; + + /** + * The ID of a prefix list used for the destination match. + */ + destinationPrefixListId?: Props["destinationPrefixListId"]; + + /** + * Describes how the route was created. + */ + origin: EC2.RouteOrigin; + + /** + * The state of the route. + */ + state: EC2.RouteState; + + /** + * The ID of the gateway (if applicable). + */ + gatewayId?: string; + + /** + * The ID of the NAT gateway (if applicable). + */ + natGatewayId?: string; + + /** + * The ID of the NAT instance (if applicable). + */ + instanceId?: string; + + /** + * The ID of the network interface (if applicable). + */ + networkInterfaceId?: string; + + /** + * The ID of the VPC peering connection (if applicable). + */ + vpcPeeringConnectionId?: string; + + /** + * The ID of the transit gateway (if applicable). + */ + transitGatewayId?: string; + + /** + * The ID of the local gateway (if applicable). + */ + localGatewayId?: string; + + /** + * The ID of the carrier gateway (if applicable). + */ + carrierGatewayId?: string; + + /** + * The ID of the egress-only internet gateway (if applicable). + */ + egressOnlyInternetGatewayId?: string; + + /** + * The Amazon Resource Name (ARN) of the core network (if applicable). + */ + coreNetworkArn?: string; +} diff --git a/alchemy-effect/src/aws/ec2/subnet.provider.ts b/alchemy-effect/src/aws/ec2/subnet.provider.ts index c858abc..8ef466e 100644 --- a/alchemy-effect/src/aws/ec2/subnet.provider.ts +++ b/alchemy-effect/src/aws/ec2/subnet.provider.ts @@ -11,8 +11,8 @@ import { EC2Client } from "./client.ts"; import { Subnet, type SubnetAttrs, - type SubnetProps, type SubnetId, + type SubnetProps, } from "./subnet.ts"; export const subnetProvider = () => @@ -22,6 +22,7 @@ export const subnetProvider = () => const tagged = yield* createTagger(); return { + stables: ["subnetId", "subnetArn", "ownerId", "vpcId"], diff: Effect.fn(function* ({ news, olds }) { if ( somePropsAreDifferent(olds, news, [ @@ -71,7 +72,6 @@ export const subnetProvider = () => }) .pipe( Effect.retry({ - // @ts-expect-error - this is unknown to itty-aws while: (e) => e._tag === "InvalidVpcID.NotFound", schedule: Schedule.exponential(100), }), diff --git a/alchemy-effect/src/aws/ec2/vpc.provider.ts b/alchemy-effect/src/aws/ec2/vpc.provider.ts index cb84b32..a353511 100644 --- a/alchemy-effect/src/aws/ec2/vpc.provider.ts +++ b/alchemy-effect/src/aws/ec2/vpc.provider.ts @@ -3,15 +3,15 @@ import * as Schedule from "effect/Schedule"; import type { EC2 } from "itty-aws/ec2"; -import type { VpcId } from "./vpc.ts"; import type { ScopedPlanStatusSession } from "../../cli/service.ts"; import { somePropsAreDifferent } from "../../diff.ts"; import type { ProviderService } from "../../provider.ts"; import { createTagger, createTagsList } from "../../tags.ts"; +import { Account } from "../account.ts"; +import { Region } from "../region.ts"; import { EC2Client } from "./client.ts"; +import type { VpcId } from "./vpc.ts"; import { Vpc, type VpcAttrs, type VpcProps } from "./vpc.ts"; -import { Region } from "../region.ts"; -import { Account } from "../account.ts"; export const vpcProvider = () => Vpc.provider.effect( @@ -21,7 +21,17 @@ export const vpcProvider = () => const accountId = yield* Account; const tagged = yield* createTagger(); + const createTags = ( + id: string, + tags?: Record, + ): Record => ({ + Name: id, + ...tagged(id), + ...tags, + }); + return { + stables: ["vpcId", "vpcArn", "ownerId", "isDefault"], diff: Effect.fn(function* ({ news, olds }) { if ( somePropsAreDifferent(olds, news, [ @@ -35,14 +45,10 @@ export const vpcProvider = () => return { action: "replace" }; } }), - create: Effect.fn(function* ({ id, news, session }) { - // 1. Prepare tags - const alchemyTags = tagged(id); - const userTags = news.tags ?? {}; - const allTags = { ...alchemyTags, ...userTags }; + const tags = createTags(id, news.tags); - // 2. Call CreateVpc + // 1. Call CreateVpc const createResult = yield* ec2.createVpc({ // TODO(sam): add all properties AmazonProvidedIpv6CidrBlock: news.amazonProvidedIpv6CidrBlock, @@ -59,7 +65,7 @@ export const vpcProvider = () => TagSpecifications: [ { ResourceType: "vpc", - Tags: createTagsList(allTags), + Tags: createTagsList(tags), }, ], DryRun: false, @@ -68,7 +74,7 @@ export const vpcProvider = () => const vpcId = createResult.Vpc!.VpcId! as VpcId; yield* session.note(`VPC created: ${vpcId}`); - // 3. Modify DNS attributes if specified (separate API calls) + // 2. Modify DNS attributes if specified (separate API calls) yield* ec2.modifyVpcAttribute({ VpcId: vpcId, EnableDnsSupport: { Value: news.enableDnsSupport ?? true }, @@ -116,10 +122,12 @@ export const vpcProvider = () => ipv6Pool: assoc.Ipv6Pool, }), ), + tags, } satisfies VpcAttrs; }), - update: Effect.fn(function* ({ news, olds, output, session }) { + update: Effect.fn(function* ({ id, news, olds, output, session }) { + const tags = createTags(id, news.tags); const vpcId = output.vpcId; // Only DNS and metrics settings can be updated @@ -141,9 +149,36 @@ export const vpcProvider = () => yield* session.note("Updated DNS hostnames"); } - // Note: Tag updates would go here if we support user tag changes + // Handle user tag updates + const oldTags = output.tags; + const removed: string[] = []; + const updated: { Key: string; Value: string }[] = []; + for (const key in oldTags) { + if (!(key in tags)) { + removed.push(key); + } else if (oldTags[key] !== tags[key]) { + updated.push({ Key: key, Value: tags[key] }); + } + } + if (removed.length > 0) { + yield* ec2.deleteTags({ + Resources: [vpcId], + Tags: removed.map((key) => ({ Key: key })), + DryRun: false, + }); + } + if (updated.length > 0) { + yield* ec2.createTags({ + Resources: [vpcId], + Tags: updated, + DryRun: false, + }); + } - return output; // VPC attributes don't change from these updates + return { + ...output, + tags, + }; // VPC attributes don't change from these updates }), delete: Effect.fn(function* ({ output, session }) { @@ -165,10 +200,7 @@ export const vpcProvider = () => while: (e) => { // DependencyViolation means there are still dependent resources // This can happen if subnets/IGW are being deleted concurrently - return ( - e._tag === "ValidationError" && - e.message?.includes("DependencyViolation") - ); + return e._tag === "DependencyViolation"; }, schedule: Schedule.exponential(1000, 1.5).pipe( Schedule.intersect(Schedule.recurs(10)), // Try up to 10 times diff --git a/alchemy-effect/src/aws/ec2/vpc.ts b/alchemy-effect/src/aws/ec2/vpc.ts index 72db912..d8f2a37 100644 --- a/alchemy-effect/src/aws/ec2/vpc.ts +++ b/alchemy-effect/src/aws/ec2/vpc.ts @@ -160,4 +160,6 @@ export interface VpcAttrs<_Props extends VpcProps = VpcProps> { networkBorderGroup?: string; ipv6Pool?: string; }>; + + tags?: Record; } diff --git a/alchemy-effect/src/aws/index.ts b/alchemy-effect/src/aws/index.ts index bb87ffa..dd31145 100644 --- a/alchemy-effect/src/aws/index.ts +++ b/alchemy-effect/src/aws/index.ts @@ -1,7 +1,6 @@ import * as Layer from "effect/Layer"; // oxlint-disable-next-line no-unused-vars - needed or else provider types are transitively resolved through DynamoDB.Provider<..> lol -import type { Provider } from "../provider.ts"; import * as ESBuild from "../esbuild.ts"; import * as Account from "./account.ts"; @@ -22,6 +21,10 @@ import "./config.ts"; export const resources = () => Layer.mergeAll( DynamoDB.tableProvider(), + EC2.internetGatewayProvider(), + EC2.routeProvider(), + EC2.routeTableAssociationProvider(), + EC2.routeTableProvider(), EC2.subnetProvider(), EC2.vpcProvider(), Lambda.functionProvider(), diff --git a/alchemy-effect/src/aws/lambda/function.provider.ts b/alchemy-effect/src/aws/lambda/function.provider.ts index 4a654e3..495d1e7 100644 --- a/alchemy-effect/src/aws/lambda/function.provider.ts +++ b/alchemy-effect/src/aws/lambda/function.provider.ts @@ -14,12 +14,12 @@ import { App } from "../../app.ts"; import { DotAlchemy } from "../../dot-alchemy.ts"; import type { ProviderService } from "../../provider.ts"; import { createTagger, createTagsList, hasTags } from "../../tags.ts"; +import { Account } from "../account.ts"; import * as IAM from "../iam.ts"; +import { Region } from "../region.ts"; import { zipCode } from "../zip.ts"; import { LambdaClient } from "./client.ts"; import { Function, type FunctionAttr, type FunctionProps } from "./function.ts"; -import { Account } from "../account.ts"; -import { Region } from "../region.ts"; export const functionProvider = () => Function.provider.effect( @@ -426,6 +426,28 @@ export const functionProvider = () => }`; return { + stables: ["functionArn", "functionName", "roleName"], + diff: Effect.fn(function* ({ id, olds, news, output }) { + if ( + // function name changed + output.functionName !== + (news.functionName ?? createFunctionName(id)) || + // url changed + olds.url !== news.url + ) { + return { action: "replace" }; + } + if ( + output.code.hash !== + (yield* bundleCode(id, { + main: news.main, + handler: news.handler, + })).hash + ) { + // code changed + return { action: "update" }; + } + }), read: Effect.fn(function* ({ id, output }) { if (output) { yield* Effect.logDebug(`reading function ${id}`); @@ -452,27 +474,7 @@ export const functionProvider = () => } return output; }), - diff: Effect.fn(function* ({ id, olds, news, output }) { - if ( - // function name changed - output.functionName !== - (news.functionName ?? createFunctionName(id)) || - // url changed - olds.url !== news.url - ) { - return { action: "replace" }; - } - if ( - output.code.hash !== - (yield* bundleCode(id, { - main: news.main, - handler: news.handler, - })).hash - ) { - // code changed - return { action: "update" }; - } - }), + precreate: Effect.fn(function* ({ id, news }) { const { roleName, functionName, roleArn } = createPhysicalNames(id); diff --git a/alchemy-effect/src/aws/sqs/queue.provider.ts b/alchemy-effect/src/aws/sqs/queue.provider.ts index ed92dfc..79456b9 100644 --- a/alchemy-effect/src/aws/sqs/queue.provider.ts +++ b/alchemy-effect/src/aws/sqs/queue.provider.ts @@ -3,10 +3,10 @@ import * as Schedule from "effect/Schedule"; import { App } from "../../app.ts"; import type { ProviderService } from "../../provider.ts"; +import { Account } from "../account.ts"; +import { Region } from "../region.ts"; import { SQSClient } from "./client.ts"; import { Queue, type QueueProps } from "./queue.ts"; -import { Region } from "../region.ts"; -import { Account } from "../account.ts"; export const queueProvider = () => Queue.provider.effect( @@ -39,6 +39,7 @@ export const queueProvider = () => VisibilityTimeout: props.visibilityTimeout?.toString(), }); return { + stables: ["queueName", "queueUrl", "queueArn"], diff: Effect.fn(function* ({ id, news, olds }) { const oldFifo = olds.fifo ?? false; const newFifo = news.fifo ?? false; diff --git a/alchemy-effect/src/cloudflare/kv/namespace.provider.ts b/alchemy-effect/src/cloudflare/kv/namespace.provider.ts index 4bf20ad..cee07b7 100644 --- a/alchemy-effect/src/cloudflare/kv/namespace.provider.ts +++ b/alchemy-effect/src/cloudflare/kv/namespace.provider.ts @@ -29,6 +29,7 @@ export const namespaceProvider = () => }); return { + stables: ["namespaceId", "accountId"], diff: ({ id, news, output }) => Effect.sync(() => { if (output.accountId !== accountId) { diff --git a/alchemy-effect/src/cloudflare/r2/bucket.provider.ts b/alchemy-effect/src/cloudflare/r2/bucket.provider.ts index 324ccbd..f12db4a 100644 --- a/alchemy-effect/src/cloudflare/r2/bucket.provider.ts +++ b/alchemy-effect/src/cloudflare/r2/bucket.provider.ts @@ -38,7 +38,13 @@ export const bucketProvider = () => return { action: "replace" }; } if (output.storageClass !== (news.storageClass ?? "Standard")) { - return { action: "update" }; + return { + action: "update", + stables: + output.name === createName(id, news.name) + ? ["name"] + : undefined, + }; } return { action: "noop" }; }), diff --git a/alchemy-effect/src/cloudflare/worker/worker.provider.ts b/alchemy-effect/src/cloudflare/worker/worker.provider.ts index c6ce0d4..597b6b3 100644 --- a/alchemy-effect/src/cloudflare/worker/worker.provider.ts +++ b/alchemy-effect/src/cloudflare/worker/worker.provider.ts @@ -7,8 +7,8 @@ import type { ScopedPlanStatusSession } from "../../cli/service.ts"; import { DotAlchemy } from "../../dot-alchemy.ts"; import { ESBuild } from "../../esbuild.ts"; import { sha256 } from "../../sha256.ts"; -import { CloudflareApi } from "../api.ts"; import { Account } from "../account.ts"; +import { CloudflareApi } from "../api.ts"; import { Assets } from "./assets.provider.ts"; import { Worker, type WorkerAttr, type WorkerProps } from "./worker.ts"; @@ -187,6 +187,7 @@ export const workerProvider = () => }); return { + stables: ["id"], diff: Effect.fnUntraced(function* ({ id, olds, news, output }) { if (output.accountId !== accountId) { return { action: "replace" }; @@ -203,7 +204,10 @@ export const workerProvider = () => assets?.hash !== output.hash.assets || bundle.hash !== output.hash.bundle ) { - return { action: "update" }; + return { + action: "update", + stables: output.name === workerName ? ["name"] : undefined, + }; } }), create: Effect.fnUntraced(function* ({ id, news, bindings, session }) { diff --git a/alchemy-effect/src/diff.ts b/alchemy-effect/src/diff.ts index b8cd547..cd0448d 100644 --- a/alchemy-effect/src/diff.ts +++ b/alchemy-effect/src/diff.ts @@ -1,20 +1,21 @@ -export type Diff = - | { - action: "noop"; - deleteFirst?: undefined; - stables?: undefined; - } - | { - action: "update"; - deleteFirst?: undefined; - /** properties that won't change as part of this update */ - stables?: string[]; - } - | { - action: "replace"; - deleteFirst?: boolean; - stables?: undefined; - }; +export type Diff = NoopDiff | UpdateDiff | ReplaceDiff; + +export interface NoopDiff { + action: "noop"; + stables?: undefined; +} + +export interface UpdateDiff { + action: "update"; + /** properties that won't change as part of this update */ + stables?: string[]; +} + +export interface ReplaceDiff { + action: "replace"; + deleteFirst?: boolean; + stables?: undefined; +} export const somePropsAreDifferent = >( olds: Props, @@ -28,3 +29,20 @@ export const somePropsAreDifferent = >( } return false; }; + +export const anyPropsAreDifferent = >( + olds: Props, + news: Props, +) => { + for (const prop in olds) { + if (olds[prop] !== news[prop]) { + return true; + } + } + for (const prop in news) { + if (!(prop in olds)) { + return true; + } + } + return false; +}; diff --git a/alchemy-effect/src/event.ts b/alchemy-effect/src/event.ts index cf80c1e..86ddc9c 100644 --- a/alchemy-effect/src/event.ts +++ b/alchemy-effect/src/event.ts @@ -1,11 +1,17 @@ export type ApplyStatus = + | "attaching" + | "post-attach" | "pending" + | "pre-creating" | "creating" + | "creating replacement" | "created" | "updating" | "updated" | "deleting" | "deleted" + | "replacing" + | "replaced" | "success" | "fail"; diff --git a/alchemy-effect/src/instance-id.ts b/alchemy-effect/src/instance-id.ts new file mode 100644 index 0000000..698d6d8 --- /dev/null +++ b/alchemy-effect/src/instance-id.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; + +/** A 16-byte (128-bit) random hex-encoded string representing an physical instance of a logical resource */ +export type InstanceId = string; + +/** + * @returns Hex-encoded instance ID (16 random bytes) + */ +export const generateInstanceId = () => + Effect.sync(() => { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + }); diff --git a/alchemy-effect/src/output.ts b/alchemy-effect/src/output.ts index fd983cf..7402291 100644 --- a/alchemy-effect/src/output.ts +++ b/alchemy-effect/src/output.ts @@ -1,13 +1,13 @@ import { pipe } from "effect"; -import * as App from "./app.ts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as App from "./app.ts"; import { isPrimitive } from "./data.ts"; import type { From } from "./policy.ts"; +import { getRefMetadata, isRef, ref as stageRef, type Ref } from "./ref.ts"; import type { AnyResource, Resource } from "./resource.ts"; -import type { IsAny, UnionToIntersection } from "./util.ts"; import * as State from "./state.ts"; -import { isRef, type Ref, getRefMetadata, ref as stageRef } from "./ref.ts"; +import type { IsAny, UnionToIntersection } from "./util.ts"; // a special symbol only used at runtime to probe the Output proxy const ExprSymbol = Symbol.for("alchemy/Expr"); @@ -125,7 +125,7 @@ const proxy = (self: any): any => { return new EffectExpr(self.expr, args[0]); } } - throw new Error("Not callable"); + return undefined; }, }, ); @@ -367,7 +367,7 @@ export const evaluate: ( }), ); } - return resource.output; + return resource.attr; } else if (Array.isArray(expr)) { return yield* Effect.all(expr.map((item) => evaluate(item, upstream))); } else if (typeof expr === "object" && expr !== null) { @@ -389,6 +389,30 @@ export type Upstream> = } : never; +export const hasOutputs = (value: any): value is Output => + Object.keys(upstreamAny(value)).length > 0; + +export const upstreamAny = ( + value: any, +): { + [ID in string]: Resource; +} => { + if (isExpr(value)) { + return upstream(value); + } else if (Array.isArray(value)) { + return Object.assign({}, ...value.map(resolveUpstream)); + } else if ( + value && + (typeof value === "object" || typeof value === "function") + ) { + return Object.assign( + {}, + ...Object.values(value).map((value) => resolveUpstream(value)), + ); + } + return {}; +}; + export const upstream = >( expr: E, ): { diff --git a/alchemy-effect/src/physical-name.ts b/alchemy-effect/src/physical-name.ts index c8c6a56..1938e0b 100644 --- a/alchemy-effect/src/physical-name.ts +++ b/alchemy-effect/src/physical-name.ts @@ -1,7 +1,62 @@ import * as Effect from "effect/Effect"; import { App } from "./app.ts"; -export const physicalName = Effect.fn(function* (id: string) { +export const physicalName = Effect.fn(function* ({ + id, + instanceId, + // 16 base32 characters = 80 bits of entropy = 4 × 10⁻⁷ + suffixLength = 16, +}: { + id: string; + /** Hex-encoded instance ID (16 random bytes) */ + instanceId: string; + suffixLength?: number; +}) { const app = yield* App; - return `${app.name}-${id}-${app.stage}`; + return `${app.name}-${id}-${app.stage}-${base32(Buffer.from(instanceId, "hex")).slice(0, suffixLength)}`; }); + +// Base32 is ideal for physical names because it's denser than hex (5 bits per char vs 4) +// and compatible with DNS/S3 (as opposed to base64 which contains uppercase letters and symbols). + +// base32.ts +// Concise, fast RFC 4648 Base32 encoder (no padding), lowercase output. +// Charset: a-z2-7 (DNS/S3 friendly) +const ALPH = "abcdefghijklmnopqrstuvwxyz234567"; + +/** + * Encode bytes into RFC4648 Base32 (no padding), lowercase. + * + * Performance notes: + * - O(n) single pass, no big-int + * - Avoids per-byte string concatenation by using a char array + */ +export function base32(bytes: Uint8Array): string { + const n = bytes.length; + if (n === 0) return ""; + + // Base32 length without padding: ceil(n*8/5) + const outLen = ((n * 8 + 4) / 5) | 0; + const out = Array.from({ length: outLen }); + + let buffer = 0; + let bits = 0; + let o = 0; + + for (let i = 0; i < n; i++) { + buffer = (buffer << 8) | bytes[i]; + bits += 8; + + while (bits >= 5) { + out[o++] = ALPH[(buffer >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + out[o++] = ALPH[(buffer << (5 - bits)) & 31]; + } + + // outLen computed as exact ceiling; o should match, but slice defensively. + return o === outLen ? out.join("") : out.slice(0, o).join(""); +} diff --git a/alchemy-effect/src/plan.ts b/alchemy-effect/src/plan.ts index b72d3ae..66ad619 100644 --- a/alchemy-effect/src/plan.ts +++ b/alchemy-effect/src/plan.ts @@ -1,22 +1,29 @@ import * as Context from "effect/Context"; -import { App } from "./app.ts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import { omit } from "effect/Struct"; -import type { - AnyBinding, - BindingDiffProps, - BindingService, -} from "./binding.ts"; +import { App } from "./app.ts"; +import type { AnyBinding, BindingDiffProps, BindingService } from "./binding.ts"; import type { Capability } from "./capability.ts"; -import type { Diff } from "./diff.ts"; +import type { Diff, NoopDiff, UpdateDiff } from "./diff.ts"; import * as Output from "./output.ts"; import type { Instance } from "./policy.ts"; import type { Provider } from "./provider.ts"; -import { type ProviderService } from "./provider.ts"; +import { type ProviderService, getProviderByType } from "./provider.ts"; import type { AnyResource, Resource, ResourceTags } from "./resource.ts"; import { isService, type IService, type Service } from "./service.ts"; -import { State, StateStoreError, type ResourceState } from "./state.ts"; +import { + type CreatedResourceState, + type CreatingResourceState, + type ReplacedResourceState, + type ReplacingResourceState, + type UpdatedResourceState, + type UpdatingReourceState, + State, + StateStoreError, + type ResourceState, +} from "./state.ts"; +import { asEffect } from "./util.ts"; export type PlanError = never; @@ -24,9 +31,7 @@ export const isBindNode = (node: any): node is BindNode => { return ( node && typeof node === "object" && - (node.action === "attach" || - node.action === "detach" || - node.action === "noop") + (node.action === "attach" || node.action === "detach" || node.action === "noop") ); }; @@ -92,65 +97,54 @@ export type Apply = | Replace | NoopUpdate; -const Node = (node: T) => ({ - ...node, - toString(): string { - return `${this.action.charAt(0).toUpperCase()}${this.action.slice(1)}(${this.resource})`; - }, - [Symbol.toStringTag]() { - return this.toString(); - }, -}); - -export type Create = { - action: "create"; +export interface BaseNode { resource: R; - news: any; provider: ProviderService; - attributes: R["attr"]; bindings: BindNode[]; -}; + downstream: string[]; +} -export type Update = { - action: "update"; - resource: R; - olds: any; - news: any; - output: any; - provider: ProviderService; - attributes: R["attr"]; - bindings: BindNode[]; -}; +export interface Create extends BaseNode { + action: "create"; + props: any; + state: CreatingResourceState | undefined; +} -export type Delete = { +export interface Update extends BaseNode { + action: "update"; + props: any; + state: + | CreatedResourceState + | UpdatedResourceState + | UpdatingReourceState + // the props can change after creating the replacement resource, + // so Apply needs to handle updates and then continue with cleaning up the replaced graph + | ReplacedResourceState; +} + +export interface Delete extends BaseNode { action: "delete"; - resource: R; - olds: any; - output: any; - provider: ProviderService; - bindings: BindNode[]; - attributes: R["attr"]; - downstream: string[]; -}; + // a resource can be deleted no matter what state it's in + state: ResourceState; +} -export type NoopUpdate = { +export interface NoopUpdate extends BaseNode { action: "noop"; - resource: R; - attributes: R["attr"]; - bindings: BindNode[]; -}; + state: CreatedResourceState | UpdatedResourceState; +} -export type Replace = { +export interface Replace extends BaseNode { action: "replace"; - resource: R; - olds: any; - news: any; - output: any; - provider: ProviderService; - bindings: BindNode[]; - attributes: R["attr"]; - deleteFirst?: boolean; -}; + props: any; + deleteFirst: boolean; + state: + | CreatingResourceState + | CreatedResourceState + | UpdatingReourceState + | UpdatedResourceState + | ReplacingResourceState + | ReplacedResourceState; +} export type ResourceGraph = ToGraph< TraverseResources @@ -166,10 +160,7 @@ type ToGraph = { }; export type BoundResources = NeverUnknown< - Extract< - Resources, - IService - >["props"]["bindings"]["capabilities"][number]["resource"] + Extract["props"]["bindings"]["capabilities"][number]["resource"] >; // finds transitive dependencies at most two levels deep @@ -180,9 +171,7 @@ export type TransitiveResources< > = Extract< | Found | { - [prop in keyof Resources["props"]]: IsAny< - Resources["props"][prop] - > extends true + [prop in keyof Resources["props"]]: IsAny extends true ? Found : Resources["props"][prop] extends { kind: "alchemy/Policy" } ? Found @@ -195,11 +184,7 @@ export type TransitiveResources< Resources["props"][prop][p] > extends true ? Found - : Resources["props"][prop][p] extends Output.Output< - any, - infer Src, - any - > + : Resources["props"][prop][p] extends Output.Output ? Src extends Found ? Found : TransitiveResources @@ -236,7 +221,7 @@ export type DerivePlan = { export type IPlan = { resources: { - [id in string]: CRUD; + [id in string]: Apply; }; deletions: { [id in string]?: Delete; @@ -245,16 +230,31 @@ export type IPlan = { export type Plan = Effect.Effect< DerivePlan, - never, + CannotReplacePartiallyReplacedResource | DeleteResourceHasDownstreamDependencies, Providers | State >; export const plan = ( - ...resources: Resources + ..._resources: Resources ): Plan> => Effect.gen(function* () { const state = yield* State; + const findResources = ( + resource: Service | Resource, + visited: Set, + ): (Service | Resource)[] => { + if (visited.has(resource.id)) { + return []; + } + visited.add(resource.id); + const upstream = Object.values(Output.upstreamAny(resource.props)) as (Service | Resource)[]; + return [resource, ...upstream, ...upstream.flatMap((r) => findResources(r, visited))]; + }; + const resources = _resources + .flatMap((r) => findResources(r, new Set())) + .filter((r, i, arr) => arr.findIndex((r2) => r2.id === r.id) === i); + // TODO(sam): rename terminology to Stack const app = yield* App; @@ -262,32 +262,9 @@ export const plan = ( stack: app.name, stage: app.stage, }); - const resourcesState = yield* Effect.all( - resourceIds.map((id) => - state.get({ stack: app.name, stage: app.stage, resourceId: id }), - ), + const oldResources = yield* Effect.all( + resourceIds.map((id) => state.get({ stack: app.name, stage: app.stage, resourceId: id })), ); - // map of resource ID -> its downstream dependencies (resources that depend on it) - const downstream = resourcesState - .filter( - ( - resource, - ): resource is ResourceState & { - bindings: BindNode[]; - } => !!resource?.bindings, - ) - .flatMap((resource) => - resource.bindings.flatMap(({ binding }) => [ - [binding.capability.resource.id, binding.capability.resource], - ]), - ) - .reduce( - (acc, [id, resourceId]) => ({ - ...acc, - [id]: [...(acc[id] ?? []), resourceId], - }), - {} as Record, - ); type ResolveEffect = Effect.Effect; type ResolveErr = StateStoreError; @@ -301,66 +278,88 @@ export const plan = ( const resolvedResources: Record< string, - ResolveEffect<{ - [attr in string]: any; - }> + ResolveEffect< + | { + [attr in string]: any; + } + | undefined + > > = {}; - const resolveResource = ( - resourceExpr: Output.ResourceExpr, - ) => + const resolveResource = (resourceExpr: Output.ResourceExpr) => Effect.gen(function* () { - return yield* (resolvedResources[resourceExpr.src.id] ??= - yield* Effect.cached( - Effect.gen(function* () { - const resource = resourceExpr.src as Resource & { - provider: ResourceTags>; - }; - const provider = yield* resource.provider.tag; - const props = yield* resolveInput(resource.props); - const oldState = yield* state.get({ - stack: app.name, - stage: app.stage, - resourceId: resource.id, - }); + return yield* (resolvedResources[resourceExpr.src.id] ??= yield* Effect.cached( + Effect.gen(function* () { + const resource = resourceExpr.src as Resource & { + provider: ResourceTags>; + }; + const provider = yield* resource.provider.tag; + const props = yield* resolveInput(resource.props); + const oldState = yield* state.get({ + stack: app.name, + stage: app.stage, + resourceId: resource.id, + }); - if (!oldState) { + if (!oldState || oldState.status === "creating") { + return resourceExpr; + } + + const diff = yield* provider.diff + ? provider.diff({ + id: resource.id, + olds: + oldState.status === "created" || + oldState.status === "updated" || + oldState.status === "replaced" + ? // if we're in a stable state, then just use the props + oldState.props + : // if we failed to update or replace, compare with the last known stable props + oldState.status === "updating" || oldState.status === "replacing" + ? oldState.old.props + : // TODO(sam): it kinda doesn't make sense to diff with a "deleting" state + oldState.props, + instanceId: oldState.instanceId, + news: props, + output: oldState.attr, + }) + : Effect.succeed(undefined); + + const stables: string[] = [...(provider.stables ?? []), ...(diff?.stables ?? [])]; + + if (diff == null) { + if (arePropsChanged(oldState, props)) { + // the props have changed but the provider did not provide any hints as to what is stable + // so we must assume everything has changed return resourceExpr; } - - const diff = yield* provider.diff - ? provider.diff({ - id: resource.id, - olds: oldState.props, - news: props, - output: oldState.output, - }) - : Effect.succeed(undefined); - - if (diff == null) { - if (arePropsChanged(oldState, props)) { - // the props have changed but the provider did not provide any hints as to what is stable - // so we must assume everything has changed - return resourceExpr; - } - } else if (diff.action === "update") { - const output = oldState?.output; - if (diff.stables) { - return new Output.ResourceExpr( - resourceExpr.src, - Object.fromEntries( - diff.stables.map((stable) => [stable, output?.[stable]]), - ), - ); - } else { - // if there are no stable properties, treat every property as changed - return resourceExpr; - } - } else if (diff.action === "replace") { + } else if (diff.action === "update") { + const output = oldState?.attr; + if (stables.length > 0) { + return new Output.ResourceExpr( + resourceExpr.src, + Object.fromEntries(stables.map((stable) => [stable, output?.[stable]])), + ); + } else { + // if there are no stable properties, treat every property as changed + return resourceExpr; } - return oldState?.output; - }), - )); + } else if (diff.action === "replace") { + return resourceExpr; + } + if ( + oldState.status === "created" || + oldState.status === "updated" || + oldState.status === "replaced" + ) { + // we can safely return the attributes if we know they have stabilized + return oldState?.attr; + } else { + // we must assume the resource doesn't exist if it hasn't stabilized + return resourceExpr; + } + }), + )); }); const resolveInput = (input: any): ResolveEffect => @@ -402,26 +401,39 @@ export const plan = ( return yield* Effect.die(new Error("Not implemented yet")); }); - const resolveUpstream = ( - value: any, - ): { - [ID in string]: Resource; - } => { - if (Output.isExpr(value)) { - return Output.upstream(value); - } else if (Array.isArray(value)) { - return Object.assign({}, ...value.map(resolveUpstream)); - } else if ( - value && - (typeof value === "object" || typeof value === "function") - ) { - return Object.assign( - {}, - ...Object.values(value).map((value) => resolveUpstream(value)), - ); - } - return {}; - }; + // map of resource ID -> its downstream dependencies (resources that depend on it) + const oldDownstreamDependencies: { + [resourceId: string]: string[]; + } = Object.fromEntries( + oldResources + .filter((resource) => !!resource) + .map((resource) => [resource.logicalId, resource.downstream]), + ); + + const newUpstreamDependencies: { + [resourceId: string]: string[]; + } = Object.fromEntries( + resources.map((resource) => [ + resource.id, + [ + ...Object.values(Output.upstreamAny(resource.props)).map((r) => r.id), + ...(isService(resource) + ? resource.props.bindings.capabilities.map((cap) => cap.resource.id) + : []), + ], + ]), + ); + + const newDownstreamDependencies: { + [resourceId: string]: string[]; + } = Object.fromEntries( + resources.map((resource) => [ + resource.id, + Object.entries(newUpstreamDependencies) + .filter(([_, downstream]) => downstream.includes(resource.id)) + .map(([id]) => id), + ]), + ); const resourceGraph = Object.fromEntries( (yield* Effect.all( @@ -432,12 +444,10 @@ export const plan = ( (cap: Capability) => cap.resource as Resource, ) : []), - ...Object.values(resolveUpstream(resource.props)), + ...Object.values(Output.upstreamAny(resource.props)), resource, ]) - .filter( - (node, i, arr) => arr.findIndex((n) => n.id === node.id) === i, - ) + .filter((node, i, arr) => arr.findIndex((n) => n.id === node.id) === i) .map( Effect.fn(function* (node) { const id = node.id; @@ -453,6 +463,8 @@ export const plan = ( }); const provider = yield* resource.provider.tag; + const downstream = newDownstreamDependencies[id] ?? []; + const bindings = isService(node) ? yield* diffBindings({ oldState, @@ -464,79 +476,193 @@ export const plan = ( target: { id: node.id, props: node.props, - oldAttr: oldState?.output, + // TODO(sam): pick the right ones based on old status + oldAttr: oldState?.attr, oldProps: oldState?.props, }, }) : []; // TODO(sam): return undefined instead of empty array - if (oldState === undefined || oldState.status === "creating") { - return Node>({ - action: "create", - news, + const Node = ( + node: Omit, + ) => + ({ + ...node, provider, resource, bindings, - // phantom - attributes: undefined!, + downstream, + }) as any as T; + + // handle empty and intermediate (non-final) states: + if (oldState === undefined) { + return Node>({ + action: "create", + props: news, + state: oldState, }); } - const diff = provider.diff - ? yield* (() => { - const diff = provider.diff({ + const diff = yield* asEffect( + provider.diff + ? provider.diff({ id, olds: oldState.props, + instanceId: oldState.instanceId, + output: oldState.attr, news, - output: oldState.output, - }); - return Effect.isEffect(diff) ? diff : Effect.succeed(diff); - })() - : undefined; + }) + : undefined, + ).pipe( + Effect.map( + (diff) => + diff ?? + ({ + action: arePropsChanged(oldState, news) ? "update" : "noop", + } as UpdateDiff | NoopDiff), + ), + ); - if (!diff && arePropsChanged(oldState, news)) { + if (oldState.status === "creating") { + if (diff.action === "noop") { + // we're in the creating state and props are un-changed + // let's just continue where we left off + return Node>({ + action: "create", + props: news, + state: oldState, + }); + } else if (diff.action === "update") { + // props have changed in a way that is updatable + // again, just continue with the create + // TODO(sam): should we maybe try an update instead? + return Node>({ + action: "create", + props: news, + state: oldState, + }); + } else { + // props have changed in an incompatible way + // because it's possible that an un-updatable resource has already been created + // we must use a replace step to create a new one and delete the potential old one + return Node>({ + action: "replace", + props: news, + deleteFirst: diff.deleteFirst ?? false, + state: oldState, + }); + } + } else if (oldState.status === "updating") { + // we started to update a resource but did not complete + if (diff.action === "update" || diff.action === "noop") { + return Node>({ + action: "update", + props: news, + state: oldState, + }); + } else { + // we started to update a resource but now believe we should replace it + return Node>({ + action: "replace", + deleteFirst: diff.deleteFirst ?? false, + props: news, + // TODO(sam): can Apply handle replacements when the oldState is UpdatingResourceState? + // -> or is there we do a provider.read to try and reconcile back to UpdatedResourceState? + state: oldState, + }); + } + } else if (oldState.status === "replacing") { + // resource replacement started, but the replacement may or may not have been created + if (diff.action === "noop") { + // this is the stable case - noop means just continue with the replacement + return Node>({ + action: "replace", + deleteFirst: oldState.deleteFirst, + props: news, + state: oldState, + }); + } else if (diff.action === "update") { + // potential problem here - the props have changed since we tried to replace, + // but not enough to trigger another replacement. the resource provider should + // be designed as idempotent to converge to the right state when creating the new resource + // the newly generated instanceId is intended to assist with this + return Node>({ + action: "replace", + deleteFirst: oldState.deleteFirst, + props: news, + state: oldState, + }); + } else { + // ah shit, so we tried to replace the resource and then crashed + // now the props have changed again in such a way that the (maybe, maybe not) + // created resource should also be replaced + + // TODO(sam): what should we do? + // 1. trigger a deletion of the potentially created resource + // 2. expect the resource provider to handle it idempotently? + // -> i don't think this case is fair to put on the resource provider + // because if the resource was created, it's in a state that can't be updated + return yield* Effect.fail(new CannotReplacePartiallyReplacedResource(id)); + } + } else if (oldState.status === "replaced") { + // replacement has been created but we're not done cleaning up the old state + if (diff.action === "noop") { + // this is the stable case - noop means just continue cleaning up the replacement + return Node>({ + action: "replace", + deleteFirst: oldState.deleteFirst, + props: news, + state: oldState, + }); + } else if (diff.action === "update") { + // the replacement has been created but now also needs to be updated + // the resource provider should: + // 1. Update the newly created replacement resource + // 2. Then proceed as normal to delete the replaced resources (after all downstream references are updated) + return Node>({ + action: "update", + props: news, + state: oldState, + }); + } else { + // the replacement has been created but now it needs to be replaced + // this is the worst-case scenario because downstream resources + // could have been been updated to point to the replaced resources + return yield* Effect.fail(new CannotReplacePartiallyReplacedResource(id)); + } + } else if (oldState.status === "deleting") { + if (diff.action === "noop" || diff.action === "update") { + // we're in a partially deleted state, it is unclear whether it was or was not deleted + // it should be safe to re-create it with the same instanceId? + return Node>({ + action: "create", + props: news, + state: { + ...oldState, + status: "creating", + props: news, + }, + }); + } else { + return yield* Effect.fail(new CannotReplacePartiallyReplacedResource(id)); + } + } else if (diff.action === "update") { return Node>({ action: "update", - olds: oldState.props, - news, - output: oldState.output, - provider, - resource, - bindings, - // phantom - attributes: undefined!, + props: news, + state: oldState, }); - } else if (diff?.action === "replace") { + } else if (diff.action === "replace") { return Node>({ action: "replace", - olds: oldState.props, - news, - output: oldState.output, - provider, - resource, - bindings, - // phantom - attributes: undefined!, - }); - } else if (diff?.action === "update") { - return Node>({ - action: "update", - olds: oldState.props, - news, - output: oldState.output, - provider, - resource, - bindings, - // phantom - attributes: undefined!, + props: news, + state: oldState, + deleteFirst: diff?.deleteFirst ?? false, }); } else { return Node>({ action: "noop", - resource, - bindings, - // phantom - attributes: undefined!, + state: oldState, }); } }), @@ -556,33 +682,24 @@ export const plan = ( stage: app.stage, resourceId: id, }); - const context = yield* Effect.context(); if (oldState) { - const provider: ProviderService = context.unsafeMap.get( - oldState?.type, - ); - if (!provider) { - yield* Effect.die( - new Error(`Provider not found for ${oldState?.type}`), - ); - } + const provider = yield* getProviderByType(oldState.resourceType); return [ id, { action: "delete", - olds: oldState.props, - output: oldState.output, - provider, - attributes: oldState?.output, - // TODO(sam): Support Detach Bindings + state: oldState, + // // TODO(sam): Support Detach Bindings bindings: [], + provider, resource: { id: id, - type: oldState.type, - attr: oldState.output, + type: oldState.resourceType, + attr: oldState.attr, props: oldState.props, } as Resource, - downstream: downstream[id] ?? [], + // TODO(sam): is it enough to just pass through oldState? + downstream: oldDownstreamDependencies[id] ?? [], } satisfies Delete, ] as const; } @@ -592,9 +709,7 @@ export const plan = ( ); for (const [resourceId, deletion] of Object.entries(deletions)) { - const dependencies = deletion.downstream.filter( - (d) => d in resourceGraph, - ); + const dependencies = deletion.state.downstream.filter((d) => d in resourceGraph); if (dependencies.length > 0) { return yield* Effect.fail( new DeleteResourceHasDownstreamDependencies({ @@ -612,7 +727,24 @@ export const plan = ( } satisfies IPlan as IPlan; }) as any; -class DeleteResourceHasDownstreamDependencies extends Data.TaggedError( +export class CannotReplacePartiallyReplacedResource extends Data.TaggedError( + "CannotReplacePartiallyReplacedResource", +)<{ + message: string; + logicalId: string; +}> { + constructor(logicalId: string) { + super({ + message: + `Resource '${logicalId}' did not finish being replaced in a previous deployment ` + + `and is expected to be replaced again in this deployment. ` + + `You should revert its properties and try again after a successful deployment.`, + logicalId, + }); + } +} + +export class DeleteResourceHasDownstreamDependencies extends Data.TaggedError( "DeleteResourceHasDownstreamDependencies", )<{ message: string; @@ -625,8 +757,9 @@ const arePropsChanged = ( newProps: R["props"], ) => { return ( + Output.hasOutputs(newProps) || JSON.stringify(omit(oldState?.props ?? {}, "bindings")) !== - JSON.stringify(omit((newProps ?? {}) as any, "bindings")) + JSON.stringify(omit((newProps ?? {}) as any, "bindings")) ); }; @@ -641,20 +774,16 @@ const diffBindings = Effect.fn(function* ({ }) { // const actions: BindNode[] = []; const oldBindings = oldState?.bindings; - const oldSids = new Set( - oldBindings?.map(({ binding }) => binding.capability.sid), - ); + // const oldSids = new Set( + // oldBindings?.map(({ binding }) => binding.capability.sid), + // ); - const diffBinding: ( - binding: AnyBinding, - ) => Effect.Effect = Effect.fn( - function* (binding) { + const diffBinding: (binding: AnyBinding) => Effect.Effect = + Effect.fn(function* (binding) { const cap = binding.capability; const sid = cap.sid ?? `${cap.action}:${cap.resource.ID}`; // Find potential oldBinding for this sid - const oldBinding = oldBindings?.find( - ({ binding }) => binding.capability.sid === sid, - ); + const oldBinding = oldBindings?.find(({ binding }) => binding.capability.sid === sid); if (!oldBinding) { return { action: "attach", @@ -678,9 +807,7 @@ const diffBindings = Effect.fn(function* ({ // } satisfies Attach; // } if (diff.action === "replace") { - return yield* Effect.die( - new Error("Replace binding not yet supported"), - ); + return yield* Effect.die(new Error("Replace binding not yet supported")); // TODO(sam): implement support for replacing bindings // return { // action: "replace", @@ -700,8 +827,7 @@ const diffBindings = Effect.fn(function* ({ binding, attr: oldBinding.attr, } satisfies NoopBind; - }, - ); + }); return (yield* Effect.all(bindings.map(diffBinding))).filter( (action): action is BindNode => action !== null, @@ -747,7 +873,7 @@ const isBindingDiff = Effect.fn(function* ({ id: oldCap.resource.id, props: newCap.resource.props, oldProps: oldState?.props, - oldAttr: oldState?.output, + oldAttr: oldState?.attr, }, props: newBinding.props, attr: oldBinding.attr, @@ -761,11 +887,95 @@ const isBindingDiff = Effect.fn(function* ({ return { action: oldBinding.capability.action !== newBinding.capability.action || - oldBinding.capability?.resource?.id !== - newBinding.capability?.resource?.id + oldBinding.capability?.resource?.id !== newBinding.capability?.resource?.id ? "update" : "noop", } as const; }); // TODO(sam): compare props // oldBinding.props !== newBinding.props; + +/** + * Print a plan in a human-readable format that shows the graph topology. + */ +export const printPlan = (plan: IPlan): string => { + const lines: string[] = []; + const allNodes = { ...plan.resources, ...plan.deletions }; + + // Build reverse mapping: upstream -> downstream + const upstreamMap: Record = {}; + for (const [id] of Object.entries(allNodes)) { + upstreamMap[id] = []; + } + for (const [id, node] of Object.entries(allNodes)) { + if (!node) continue; + for (const downstreamId of node.state?.downstream ?? []) { + if (upstreamMap[downstreamId]) { + upstreamMap[downstreamId].push(id); + } + } + } + + // Action symbols + const actionSymbol = (action: string) => { + switch (action) { + case "create": + return "+"; + case "update": + return "~"; + case "delete": + return "-"; + case "replace": + return "±"; + case "noop": + return "="; + default: + return "?"; + } + }; + + // Print header + lines.push("╔════════════════════════════════════════════════════════════════╗"); + lines.push("║ PLAN ║"); + lines.push("╠════════════════════════════════════════════════════════════════╣"); + lines.push("║ Legend: + create, ~ update, - delete, ± replace, = noop ║"); + lines.push("╚════════════════════════════════════════════════════════════════╝"); + lines.push(""); + + // Print resources section + lines.push("┌─ Resources ────────────────────────────────────────────────────┐"); + const resourceIds = Object.keys(plan.resources).sort(); + for (const id of resourceIds) { + const node = plan.resources[id]; + const symbol = actionSymbol(node.action); + const type = node.resource?.type ?? "unknown"; + const downstream = node.state?.downstream?.length + ? ` → [${node.state?.downstream.join(", ")}]` + : ""; + lines.push(`│ [${symbol}] ${id} (${type})${downstream}`); + } + if (resourceIds.length === 0) { + lines.push("│ (none)"); + } + lines.push("└────────────────────────────────────────────────────────────────┘"); + lines.push(""); + + // Print deletions section + lines.push("┌─ Deletions ────────────────────────────────────────────────────┐"); + const deletionIds = Object.keys(plan.deletions).sort(); + for (const id of deletionIds) { + const node = plan.deletions[id]!; + const type = node.resource?.type ?? "unknown"; + const downstream = node.state.downstream?.length + ? ` → [${node.state.downstream.join(", ")}]` + : ""; + lines.push(`│ [-] ${id} (${type})${downstream}`); + } + if (deletionIds.length === 0) { + lines.push("│ (none)"); + } + lines.push("└────────────────────────────────────────────────────────────────┘"); + lines.push(""); + + return lines.join("\n"); +}; diff --git a/alchemy-effect/src/provider.ts b/alchemy-effect/src/provider.ts index 8e8e941..ae6b408 100644 --- a/alchemy-effect/src/provider.ts +++ b/alchemy-effect/src/provider.ts @@ -1,11 +1,11 @@ import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; +import * as Effect from "effect/Effect"; +import type { ScopedPlanStatusSession } from "./cli/service.ts"; import type { Diff } from "./diff.ts"; import type { Input } from "./input.ts"; import type { Resource } from "./resource.ts"; import type { Runtime } from "./runtime.ts"; import type { Service } from "./service.ts"; -import type { ScopedPlanStatusSession } from "./cli/service.ts"; export interface Provider extends Context.TagClass< @@ -25,7 +25,21 @@ type BindingData = [Res] extends [Runtime] type Props = Input.ResolveOpaque; -export interface ProviderService { +export interface ProviderService< + Res extends Resource = Resource, + ReadReq = never, + DiffReq = never, + PrecreateReq = never, + CreateReq = never, + UpdateReq = never, + DeleteReq = never, +> { + /** + * The version of the provider. + * + * @default 0 + */ + version?: number; // tail(); // watch(); // replace(): Effect.Effect; @@ -33,44 +47,67 @@ export interface ProviderService { // run?() {} read?(input: { id: string; + instanceId: string; olds: Props | undefined; // what is the ARN? output: Res["attr"] | undefined; // current state -> synced state session: ScopedPlanStatusSession; bindings: BindingData; - }): Effect.Effect; + }): Effect.Effect; + /** + * Properties that are always stable across any update. + */ + stables?: Extract[]; diff?(input: { id: string; olds: Props; + instanceId: string; // Note: we do not resolve (Props) here because diff runs during plan // -> we need a way for the diff handlers to work with Outputs news: Res["props"]; output: Res["attr"]; - }): Effect.Effect; + }): Effect.Effect; precreate?(input: { id: string; news: Props; + instanceId: string; session: ScopedPlanStatusSession; - }): Effect.Effect; + }): Effect.Effect; create(input: { id: string; + instanceId: string; news: Props; session: ScopedPlanStatusSession; bindings: BindingData; - }): Effect.Effect; + }): Effect.Effect; update(input: { id: string; + instanceId: string; news: Props; olds: Props; output: Res["attr"]; session: ScopedPlanStatusSession; bindings: BindingData; - }): Effect.Effect; + }): Effect.Effect; delete(input: { id: string; + instanceId: string; olds: Props; output: Res["attr"]; session: ScopedPlanStatusSession; bindings: BindingData; - }): Effect.Effect; + }): Effect.Effect; } + +export const getProviderByType = Effect.fnUntraced(function* ( + resourceType: string, +) { + const context = yield* Effect.context(); + const provider: ProviderService = context.unsafeMap.get(resourceType); + if (!provider) { + return yield* Effect.die( + new Error(`Provider not found for ${resourceType}`), + ); + } + return provider; +}); diff --git a/alchemy-effect/src/resource.ts b/alchemy-effect/src/resource.ts index a8d271f..c42e4c2 100644 --- a/alchemy-effect/src/resource.ts +++ b/alchemy-effect/src/resource.ts @@ -40,10 +40,56 @@ export interface Resource< export interface ResourceTags> { of>(service: S): S; tag: Provider; - effect( - eff: Effect, Err, Req>, - ): Layer.Layer, Err, Req>; - succeed(service: ProviderService): Layer.Layer>; + effect< + Err, + Req, + ReadReq = never, + DiffReq = never, + PrecreateReq = never, + CreateReq = never, + UpdateReq = never, + DeleteReq = never, + >( + eff: Effect< + ProviderService< + R, + ReadReq, + DiffReq, + PrecreateReq, + CreateReq, + UpdateReq, + DeleteReq + >, + Err, + Req + >, + ): Layer.Layer< + Provider, + Err, + Req | ReadReq | DiffReq | PrecreateReq | CreateReq | UpdateReq | DeleteReq + >; + succeed< + ReadReq = never, + DiffReq = never, + PrecreateReq = never, + CreateReq = never, + UpdateReq = never, + DeleteReq = never, + >( + service: ProviderService< + R, + ReadReq, + DiffReq, + PrecreateReq, + CreateReq, + UpdateReq, + DeleteReq + >, + ): Layer.Layer< + Provider, + never, + ReadReq | DiffReq | PrecreateReq | CreateReq | UpdateReq | DeleteReq + >; } export const Resource = Resource>( diff --git a/alchemy-effect/src/state.ts b/alchemy-effect/src/state.ts index 577b637..0eadc23 100644 --- a/alchemy-effect/src/state.ts +++ b/alchemy-effect/src/state.ts @@ -52,22 +52,114 @@ import { isResource } from "./resource.ts"; // Scrap the "key-value" store on State/Scope -export type ResourceStatus = - | "creating" - | "created" - | "updating" - | "updated" - | "deleting" - | "deleted"; - -export type ResourceState = { - type: string; - id: string; +export type Props = Record; +export type Attr = Record; + +export type ResourceStatus = ResourceState["status"]; + +interface BaseResourceState { + resourceType: string; + /** Logical ID of the Resource (stable across creates, updates, deletes and replaces) */ + logicalId: string; + /** A unique randomly generated token used to seed ID generation (only changes when replaced) */ + instanceId: string; + /** The version of the provider that was used to create/update the resource. */ + providerVersion: number; + /** Current status of the logical Resource */ status: ResourceStatus; - props: any; - output: any; + /** List of logical IDs of resources that depend on this resource */ + downstream: string[]; + /** List of Bindings attached to this Resource */ bindings?: BindNode[]; -}; + /** Desired state (input props) of this Resource */ + props?: Props; + /** The output attributes of this Resource (if it has been created) */ + attr?: Attr; +} + +export interface CreatingResourceState extends BaseResourceState { + status: "creating"; + /** The new resource properties that are being (or have been) applied. */ + props: Props; +} + +export interface CreatedResourceState extends BaseResourceState { + status: "created"; + /** The new resource properties that have been applied. */ + props: Props; + /** The output attributes of the created resource */ + attr: Attr; +} + +export interface UpdatingReourceState extends BaseResourceState { + status: "updating"; + /** The new resource properties that are being (or have been) applied. */ + props: Props; + old: { + /** The old resource properties that have been successfully applied. */ + props: Props; + /** The old output properties that have been successfully applied. */ + attr: Attr; + // TODO(sam): do I need to track the old downstream edges? + // downstream: string[]; + }; +} + +export interface UpdatedResourceState extends BaseResourceState { + status: "updated"; + /** The new resource properties that are being (or have been) applied. */ + props: Props; + /** The output attributes of the created resource */ + attr: Attr; +} + +export interface DeletingResourceState extends BaseResourceState { + status: "deleting"; + /** Attributes of the resource being deleted */ + attr: Attr | undefined; +} + +export interface ReplacingResourceState extends BaseResourceState { + status: "replacing"; + /** Desired properties of the new resource (the replacement) */ + props: Props; + /** Reference to the state of the old resource (the one being replaced) */ + old: + | CreatedResourceState + | UpdatedResourceState + | CreatingResourceState + | UpdatingReourceState + | DeletingResourceState; + /** Whether the resource should be deleted before or after replacements */ + deleteFirst: boolean; +} + +export interface ReplacedResourceState extends BaseResourceState { + status: "replaced"; + /** Desired properties of the new resource (the replacement) */ + props: Props; + /** Output attributes of the new resource (the replacement) */ + attr: Attr; + /** Reference to the state of the old resource (the one being replaced) */ + old: + | CreatingResourceState + | CreatedResourceState + | UpdatingReourceState + | UpdatedResourceState + | DeletingResourceState; + /** Whether the resource should be deleted before or after replacements */ + deleteFirst: boolean; + // .. will (finally) transition to `CreatedResourceState` after finalizing +} + +export type ResourceState = + | CreatingResourceState + | CreatedResourceState + | UpdatingReourceState + | UpdatedResourceState + | DeletingResourceState + | ReplacingResourceState + | ReplacedResourceState; export class StateStoreError extends Data.TaggedError("StateStoreError")<{ message: string; @@ -82,6 +174,10 @@ export interface StateService { stage: string; resourceId: string; }): Effect.Effect; + getReplacedResources(request: { + stack: string; + stage: string; + }): Effect.Effect; set(request: { stack: string; stage: string; @@ -107,7 +203,6 @@ export class State extends Context.Tag("AWS::Lambda::State")< // TODO(sam): implement with SQLite3 export const localFs = Layer.effect( State, - // @ts-expect-error - Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -146,8 +241,8 @@ export const localFs = Layer.effect( fs.makeDirectory(dir, { recursive: true }), ); - return { - listApps: () => + const state: StateService = { + listStacks: () => fs.readDirectory(stateDir).pipe( recover, Effect.map((files) => files ?? []), @@ -162,6 +257,17 @@ export const localFs = Layer.effect( Effect.map((file) => JSON.parse(file.toString())), recover, ), + getReplacedResources: Effect.fnUntraced(function* (request) { + return (yield* Effect.all( + (yield* state.list(request)).map((resourceId) => + state.get({ + stack: request.stack, + stage: request.stage, + resourceId, + }), + ), + )).filter((r) => r?.status === "replaced"); + }), set: (request) => ensure(stage(request)).pipe( Effect.flatMap(() => @@ -196,6 +302,7 @@ export const localFs = Layer.effect( ), ), }; + return state; }), ); @@ -221,22 +328,14 @@ export const inMemoryService = ( Record> > = {}, ) => { - const state = new Map>>( - Object.entries(initialState).map(([stack, stages]) => [ - stack, - new Map( - Object.entries(stages).map(([stage, resources]) => [ - stage, - new Map(Object.entries(resources)), - ]), - ), - ]), - ); + const state = initialState; return { - listStacks: () => Effect.succeed(Array.from(state.keys())), + listStacks: () => Effect.succeed(Array.from(Object.keys(state))), // oxlint-disable-next-line require-yield listStages: (stack: string) => - Effect.succeed(Array.from(state.get(stack)?.keys() ?? [])), + Effect.succeed( + Array.from(stack in state ? Object.keys(state[stack]) : []), + ), get: ({ stack, stage, @@ -245,7 +344,19 @@ export const inMemoryService = ( stack: string; stage: string; resourceId: string; - }) => Effect.succeed(state.get(stack)?.get(stage)?.get(resourceId)), + }) => Effect.succeed(state[stack]?.[stage]?.[resourceId]), + getReplacedResources: ({ + stack, + stage, + }: { + stack: string; + stage: string; + }) => + Effect.succeed( + Array.from(Object.values(state[stack]?.[stage] ?? {}) ?? []).filter( + (s) => s.status === "replaced", + ), + ), set: ({ stack, stage, @@ -257,7 +368,9 @@ export const inMemoryService = ( resourceId: string; value: V; }) => { - state.get(stack)?.get(stage)?.set(resourceId, value); + const stackState = (state[stack] ??= {}); + const stageState = (stackState[stage] ??= {}); + stageState[resourceId] = value; return Effect.succeed(value); }, delete: ({ @@ -268,8 +381,10 @@ export const inMemoryService = ( stack: string; stage: string; resourceId: string; - }) => Effect.succeed(state.get(stack)?.get(stage)?.delete(resourceId)), + }) => Effect.succeed(delete state[stack]?.[stage]?.[resourceId]), list: ({ stack, stage }: { stack: string; stage: string }) => - Effect.succeed(Array.from(state.get(stack)?.get(stage)?.keys() ?? [])), - }; + Effect.succeed( + Array.from(Object.keys(state[stack]?.[stage] ?? {}) ?? []), + ), + } satisfies StateService; }; diff --git a/alchemy-effect/src/test.ts b/alchemy-effect/src/test.ts index ba55276..92cf5a7 100644 --- a/alchemy-effect/src/test.ts +++ b/alchemy-effect/src/test.ts @@ -1,18 +1,18 @@ import { FetchHttpClient, FileSystem, HttpClient } from "@effect/platform"; -import * as PlatformConfigProvider from "@effect/platform/PlatformConfigProvider"; import { NodeContext } from "@effect/platform-node"; import * as Path from "@effect/platform/Path"; -import { it, expect } from "@effect/vitest"; +import * as PlatformConfigProvider from "@effect/platform/PlatformConfigProvider"; +import { expect, it } from "@effect/vitest"; import { ConfigProvider, LogLevel } from "effect"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Scope from "effect/Scope"; import * as App from "./app.ts"; +import { CLI } from "./cli/service.ts"; import { DotAlchemy, dotAlchemy } from "./dot-alchemy.ts"; -import * as State from "./state.ts"; import type { Resource } from "./resource.ts"; -import { CLI } from "./cli/service.ts"; +import * as State from "./state.ts"; declare module "@effect/vitest" { interface ExpectStatic { @@ -165,7 +165,7 @@ export const testCLI = Layer.succeed( Effect.log( event.kind === "status-change" ? `${event.status} ${event.id}(${event.type})` - : event.message, + : `${event.id}: ${event.message}`, ), }), }), diff --git a/alchemy-effect/src/todo.ts b/alchemy-effect/src/todo.ts new file mode 100644 index 0000000..fcbf30f --- /dev/null +++ b/alchemy-effect/src/todo.ts @@ -0,0 +1,4 @@ +import * as Effect from "effect/Effect"; + +export const todo = (message?: string) => + Effect.dieMessage(message ?? `Not implemented`); diff --git a/alchemy-effect/test/apply.test.ts b/alchemy-effect/test/apply.test.ts index 66b6dbe..14f87ae 100644 --- a/alchemy-effect/test/apply.test.ts +++ b/alchemy-effect/test/apply.test.ts @@ -3,152 +3,1071 @@ import * as Effect from "effect/Effect"; import { apply } from "@/apply"; import { destroy } from "@/destroy"; import * as Output from "@/output"; -import { State } from "@/state"; +import { + type ReplacedResourceState, + type ReplacingResourceState, + type ResourceState, + State, +} from "@/state"; import { test } from "@/test"; -import { expect } from "@effect/vitest"; -import { TestLayers, TestResource } from "./test.resources.ts"; +import { expect, describe } from "@effect/vitest"; +import { + type TestResourceProps, + InMemoryTestLayers, + TestLayers, + TestResource, + TestResourceHooks, +} from "./test.resources.ts"; +import { Data, Layer } from "effect"; +import { App } from "@/app"; +import { CannotReplacePartiallyReplacedResource } from "@/plan"; const testStack = "test"; const testStage = "test"; -const getState = Effect.fn(function* (resourceId: string) { +const getState = Effect.fn(function* (resourceId: string) { const state = yield* State; - return yield* state.get({ stack: testStack, stage: testStage, resourceId }); + return (yield* state.get({ + stack: testStack, + stage: testStage, + resourceId, + })) as S; }); const listState = Effect.fn(function* () { const state = yield* State; return yield* state.list({ stack: testStack, stage: testStage }); }); -test( - "apply should create when non-existent and update when props change", - Effect.gen(function* () { - { +const mockApp = App.of({ name: testStack, stage: testStage, config: {} }); + +export class ResourceFailure extends Data.TaggedError("ResourceFailure")<{ + message: string; +}> { + constructor() { + super({ message: `Failed to create` }); + } +} + +const MockLayers = () => Layer.mergeAll(InMemoryTestLayers(), Layer.succeed(App, mockApp)); + +const fail = ( + test: Effect.Effect, + hooks?: { + create?: (id: string, props: TestResourceProps) => Effect.Effect; + update?: (id: string, props: TestResourceProps) => Effect.Effect; + delete?: (id: string) => Effect.Effect; + }, +): Effect.Effect => + test.pipe( + Effect.provide( + Layer.succeed( + TestResourceHooks, + hooks ?? { + create: () => Effect.fail(new ResourceFailure()), + update: () => Effect.fail(new ResourceFailure()), + delete: () => Effect.fail(new ResourceFailure()), + }, + ), + ), + // @ts-expect-error + Effect.catchTag("ResourceFailure", () => Effect.succeed(true)), + ); + +// Helper to fail on specific resource IDs +const failOn = ( + resourceId: string, + hook: "create" | "update" | "delete", +): { + create?: (id: string, props: TestResourceProps) => Effect.Effect; + update?: (id: string, props: TestResourceProps) => Effect.Effect; + delete?: (id: string) => Effect.Effect; +} => ({ + [hook]: (id: string) => + id === resourceId ? Effect.fail(new ResourceFailure()) : Effect.succeed(undefined), +}); + +describe("basic operations", () => { + test( + "should create, update, and delete resources", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "test-string", + }) {} + + const stack = yield* apply(A); + expect(stack.A.string).toEqual("test-string"); + } + + { + class A extends TestResource("A", { + string: "test-string-new", + }) {} + + const stack = yield* apply(A); + expect(stack.A.string).toEqual("test-string-new"); + } + + yield* destroy(); + + const state = yield* State; + + expect(yield* getState("A")).toBeUndefined(); + expect(yield* listState()).toEqual([]); + }).pipe(Effect.provide(TestLayers)), + ); + + test( + "should resolve output properties", + Effect.gen(function* () { class A extends TestResource("A", { string: "test-string", + stringArray: ["test-string-array"], }) {} + { + class B extends TestResource("B", { + string: Output.of(A).string, + }) {} + + const stack = yield* apply(B); + expect(stack.B.string).toEqual("test-string"); + } + + { + class B extends TestResource("B", { + string: Output.of(A).string.apply((string) => string.toUpperCase()), + }) {} + + const stack = yield* apply(B); + expect(stack.B.string).toEqual("TEST-STRING"); + } + + { + class B extends TestResource("B", { + string: Output.of(A).string.effect((string) => + Effect.succeed(string.toUpperCase() + "-NEW"), + ), + }) {} + + const stack = yield* apply(B); + expect(stack.B.string).toEqual("TEST-STRING-NEW"); + } + + { + class B extends TestResource("B", { + string: Output.of(A) + .string.apply((string) => string.toUpperCase()) + .apply((string) => string + "-CALL-EXPR"), + }) {} + const stack = yield* apply(B); + expect(stack.B.string).toEqual("TEST-STRING-CALL-EXPR"); + } + + { + class B extends TestResource("B", { + stringArray: Output.of(A).stringArray, + }) {} + + const stack = yield* apply(B); + expect(stack.B.stringArray).toEqual(["test-string-array"]); + } + + { + class B extends TestResource("B", { + string: Output.of(A).stringArray[0], + }) {} + + const stack = yield* apply(B); + expect(stack.B.string).toEqual("test-string-array"); + } + + { + class B extends TestResource("B", { + string: Output.of(A).stringArray[0].apply((string) => string.toUpperCase()), + }) {} + + const stack = yield* apply(B); + expect(stack.B.string).toEqual("TEST-STRING-ARRAY"); + } + + { + class B extends TestResource("B", { + stringArray: Output.of(A).stringArray.apply((string) => + string.map((string) => string.toUpperCase()), + ), + }) {} + + const stack = yield* apply(B); + expect(stack.B.stringArray).toEqual(["TEST-STRING-ARRAY"]); + } + + { + class B extends TestResource("B", { + stringArray: Output.of(A).stringArray.apply((stringArray) => + stringArray.flatMap((string) => [string, string]), + ), + }) {} + + const stack = yield* apply(B); + expect(stack.B.stringArray).toEqual(["test-string-array", "test-string-array"]); + } + }).pipe(Effect.provide(TestLayers)), + ); +}); + +describe("from created state", () => { + test( + "noop when props unchanged", + Effect.gen(function* () { + class A extends TestResource("A", { + string: "test-string", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + + // Re-apply with same props - should be noop const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); expect(stack.A.string).toEqual("test-string"); - } + }).pipe(Effect.provide(MockLayers())), + ); - { + test( + "replace when props trigger replacement", + Effect.gen(function* () { + { + class A extends TestResource("A", { + replaceString: "original", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + // Change props that trigger replacement class A extends TestResource("A", { - string: "test-string-new", + replaceString: "new", }) {} - const stack = yield* apply(A); - expect(stack.A.string).toEqual("test-string-new"); - } + expect((yield* getState("A"))?.status).toEqual("created"); + expect(stack.A.replaceString).toEqual("new"); + }).pipe(Effect.provide(MockLayers())), + ); +}); - yield* destroy(); +describe("from updated state", () => { + test( + "noop when props unchanged", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "test-string", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + // Update to get to updated state + class A extends TestResource("A", { + string: "test-string-changed", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("updated"); + } + // Re-apply with same props - should be noop + class A extends TestResource("A", { + string: "test-string-changed", + }) {} + const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("updated"); + expect(stack.A.string).toEqual("test-string-changed"); + }).pipe(Effect.provide(MockLayers())), + ); - const state = yield* State; + test( + "replace when props trigger replacement", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "test-string", + replaceString: "original", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + // Update to get to updated state + class A extends TestResource("A", { + string: "test-string-changed", + replaceString: "original", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("updated"); + } + // Change props that trigger replacement + class A extends TestResource("A", { + string: "test-string-changed", + replaceString: "new", + }) {} + const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + expect(stack.A.replaceString).toEqual("new"); + }).pipe(Effect.provide(MockLayers())), + ); +}); - expect(yield* getState("A")).toBeUndefined(); - expect(yield* listState()).toEqual([]); - }).pipe(Effect.provide(TestLayers)), -); +describe("from creating state", () => { + test( + "continue creating when props unchanged", + Effect.gen(function* () { + class A extends TestResource("A", { + string: "test-string", + }) {} + yield* fail(apply(A)); + expect((yield* getState("A"))?.status).toEqual("creating"); + const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + expect(stack.A.string).toEqual("test-string"); + }).pipe(Effect.provide(MockLayers())), + ); -test( - "apply should resolve output properties", - Effect.gen(function* () { - class A extends TestResource("A", { - string: "test-string", - stringArray: ["test-string-array"], - }) {} - { - class B extends TestResource("B", { - string: Output.of(A).string, + test( + "continue creating when props have updatable changes", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "test-string", + }) {} + yield* fail(apply(A)); + expect((yield* getState("A"))?.status).toEqual("creating"); + } + class A extends TestResource("A", { + string: "test-string-changed", }) {} + const stack = yield* apply(A); + expect(stack.A.string).toEqual("test-string-changed"); + expect((yield* getState("A"))?.status).toEqual("created"); + }).pipe(Effect.provide(MockLayers())), + ); - const stack = yield* apply(B); - expect(stack.B.string).toEqual("test-string"); - } + test( + "replace when props trigger replacement", + Effect.gen(function* () { + { + class A extends TestResource("A", { + replaceString: "test-string", + }) {} + yield* fail(apply(A)); + expect((yield* getState("A"))?.status).toEqual("creating"); + } + class A extends TestResource("A", { + replaceString: "test-string-changed", + }) {} + const stack = yield* apply(A); + expect(stack.A.replaceString).toEqual("test-string-changed"); + expect((yield* getState("A"))?.status).toEqual("created"); + }).pipe(Effect.provide(MockLayers())), + ); +}); - { - class B extends TestResource("B", { - string: Output.of(A).string.apply((string) => string.toUpperCase()), +describe("from updating state", () => { + test( + "continue updating when props unchanged", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "test-string", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + class A extends TestResource("A", { + string: "test-string-changed", + }) {} + yield* fail(apply(A), { + update: () => Effect.fail(new ResourceFailure()), + }); + expect((yield* getState("A"))?.status).toEqual("updating"); + } + class A extends TestResource("A", { + string: "test-string-changed", }) {} + const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("updated"); + expect(stack.A.string).toEqual("test-string-changed"); + }).pipe(Effect.provide(MockLayers())), + ); - const stack = yield* apply(B); - expect(stack.B.string).toEqual("TEST-STRING"); - } + test( + "continue updating when props have updatable changes", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "test-string", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + class A extends TestResource("A", { + string: "test-string-changed", + }) {} + yield* fail(apply(A), { + update: () => Effect.fail(new ResourceFailure()), + }); + expect((yield* getState("A"))?.status).toEqual("updating"); + } + class A extends TestResource("A", { + string: "test-string-changed-again", + }) {} + const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("updated"); + expect(stack.A.string).toEqual("test-string-changed-again"); + }).pipe(Effect.provide(MockLayers())), + ); - { - class B extends TestResource("B", { - string: Output.of(A).string.effect((string) => - Effect.succeed(string.toUpperCase() + "-NEW"), - ), + test( + "replace when props trigger replacement", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "test-string", + replaceString: "original", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + class A extends TestResource("A", { + string: "test-string-changed", + replaceString: "original", + }) {} + yield* fail(apply(A), { + update: () => Effect.fail(new ResourceFailure()), + }); + expect((yield* getState("A"))?.status).toEqual("updating"); + } + class A extends TestResource("A", { + string: "test-string-changed", + replaceString: "changed", }) {} + const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + expect(stack.A.replaceString).toEqual("changed"); + }).pipe(Effect.provide(MockLayers())), + ); +}); - const stack = yield* apply(B); - expect(stack.B.string).toEqual("TEST-STRING-NEW"); - } +describe("from replacing state", () => { + test( + "continue replacement when props unchanged", + Effect.gen(function* () { + { + // 1. Create initial resource + class A extends TestResource("A", { + replaceString: "original", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + // 2. Trigger replacement but fail during create of replacement + class A extends TestResource("A", { + replaceString: "new", + }) {} + yield* fail(apply(A), { + create: () => Effect.fail(new ResourceFailure()), + }); + const state = yield* getState("A"); + expect(state?.status).toEqual("replacing"); + expect(state?.old?.status).toEqual("created"); + } + // 3. Re-apply with same props - should continue replacement + class A extends TestResource("A", { + replaceString: "new", + }) {} + const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + expect(stack.A.replaceString).toEqual("new"); + }).pipe(Effect.provide(MockLayers())), + ); - { - class B extends TestResource("B", { - string: Output.of(A) - .string.apply((string) => string.toUpperCase()) - .apply((string) => string + "-CALL-EXPR"), + test( + "continue replacement when props have updatable changes", + Effect.gen(function* () { + { + // 1. Create initial resource + class A extends TestResource("A", { + replaceString: "original", + string: "initial", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + // 2. Trigger replacement but fail during create + class A extends TestResource("A", { + replaceString: "new", + string: "initial", + }) {} + yield* fail(apply(A), { + create: () => Effect.fail(new ResourceFailure()), + }); + expect((yield* getState("A"))?.status).toEqual("replacing"); + } + // 3. Re-apply with changed props (updatable) - should continue replacement with new props + class A extends TestResource("A", { + replaceString: "new", + string: "changed", }) {} + const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + expect(stack.A.replaceString).toEqual("new"); + expect(stack.A.string).toEqual("changed"); + }).pipe(Effect.provide(MockLayers())), + ); - const stack = yield* apply(B); - expect(stack.B.string).toEqual("TEST-STRING-CALL-EXPR"); - } + test( + "error when props trigger another replacement", + Effect.gen(function* () { + { + // 1. Create initial resource + class A extends TestResource("A", { + replaceString: "original", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + // 2. Trigger replacement but fail during create + class A extends TestResource("A", { + replaceString: "new", + }) {} + yield* fail(apply(A), { + create: () => Effect.fail(new ResourceFailure()), + }); + expect((yield* getState("A"))?.status).toEqual("replacing"); + } + // 3. Try to replace again with another replacement - should fail + class A extends TestResource("A", { + replaceString: "another-replacement", + }) {} + const result = yield* apply(A).pipe(Effect.either); + expect(result._tag).toEqual("Left"); + if (result._tag === "Left") { + expect(result.left).toBeInstanceOf(CannotReplacePartiallyReplacedResource); + } + }).pipe(Effect.provide(MockLayers())), + ); +}); - { - class B extends TestResource("B", { - stringArray: Output.of(A).stringArray, +describe("from replaced state", () => { + test( + "continue cleanup when props unchanged", + Effect.gen(function* () { + { + class A extends TestResource("A", { + replaceString: "test-string", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + class A extends TestResource("A", { + replaceString: "test-string-changed", }) {} + yield* fail(apply(A), { + delete: () => Effect.fail(new ResourceFailure()), + }); + const AState = yield* getState("A"); + expect(AState?.status).toEqual("replaced"); + expect(AState?.old).toMatchObject({ + status: "created", + props: { + replaceString: "test-string", + }, + }); + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + }).pipe(Effect.provide(MockLayers())), + ); - const stack = yield* apply(B); - expect(stack.B.stringArray).toEqual(["test-string-array"]); - } + test( + "update replacement then cleanup when props have updatable changes", + Effect.gen(function* () { + { + // 1. Create initial resource + class A extends TestResource("A", { + replaceString: "original", + string: "initial", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + // 2. Trigger replacement and fail during delete of old resource + class A extends TestResource("A", { + replaceString: "new", + string: "initial", + }) {} + yield* fail(apply(A), { + delete: () => Effect.fail(new ResourceFailure()), + }); + const state = yield* getState("A"); + expect(state?.status).toEqual("replaced"); + expect(state?.old?.status).toEqual("created"); + } + // 3. Change props again (updatable change) - should update the replacement then cleanup + class A extends TestResource("A", { + replaceString: "new", + string: "changed", + }) {} + const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + expect(stack.A.replaceString).toEqual("new"); + expect(stack.A.string).toEqual("changed"); + }).pipe(Effect.provide(MockLayers())), + ); - { - class B extends TestResource("B", { - string: Output.of(A).stringArray[0], + test( + "error when props trigger another replacement", + Effect.gen(function* () { + { + // 1. Create initial resource + class A extends TestResource("A", { + replaceString: "original", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + // 2. Trigger replacement and fail during delete of old resource + class A extends TestResource("A", { + replaceString: "new", + }) {} + yield* fail(apply(A), { + delete: () => Effect.fail(new ResourceFailure()), + }); + expect((yield* getState("A"))?.status).toEqual("replaced"); + } + // 3. Try to replace again - should fail + class A extends TestResource("A", { + replaceString: "another-replacement", }) {} + const result = yield* apply(A).pipe(Effect.either); + expect(result._tag).toEqual("Left"); + if (result._tag === "Left") { + expect(result.left).toBeInstanceOf(CannotReplacePartiallyReplacedResource); + } + }).pipe(Effect.provide(MockLayers())), + ); +}); - const stack = yield* apply(B); - expect(stack.B.string).toEqual("test-string-array"); - } +describe("from deleting state", () => { + test( + "create when props unchanged or have updatable changes", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "test-string", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + class A extends TestResource("A", { + string: "test-string", + }) {} + yield* fail(destroy(), { + delete: () => Effect.fail(new ResourceFailure()), + }); + expect((yield* getState("A"))?.status).toEqual("deleting"); + } + // Now re-apply with the same props - should create the resource again + class A extends TestResource("A", { + string: "test-string", + }) {} + const stack = yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + expect(stack.A.string).toEqual("test-string"); + }).pipe(Effect.provide(MockLayers())), + ); - { - class B extends TestResource("B", { - string: Output.of(A).stringArray[0].apply((string) => - string.toUpperCase(), - ), + test( + "error when props trigger replacement", + Effect.gen(function* () { + { + // 1. Create initial resource + class A extends TestResource("A", { + replaceString: "original", + }) {} + yield* apply(A); + expect((yield* getState("A"))?.status).toEqual("created"); + } + { + // 2. Try to delete but fail + yield* fail(destroy(), { + delete: () => Effect.fail(new ResourceFailure()), + }); + expect((yield* getState("A"))?.status).toEqual("deleting"); + } + // 3. Try to re-apply with props that trigger replacement - should fail + class A extends TestResource("A", { + replaceString: "new", }) {} + const result = yield* apply(A).pipe(Effect.either); + expect(result._tag).toEqual("Left"); + if (result._tag === "Left") { + expect(result.left).toBeInstanceOf(CannotReplacePartiallyReplacedResource); + } + }).pipe(Effect.provide(MockLayers())), + ); +}); - const stack = yield* apply(B); - expect(stack.B.string).toEqual("TEST-STRING-ARRAY"); - } +// ============================================================================= +// DEPENDENT RESOURCES (A -> B where B depends on Output.of(A)) +// ============================================================================= - { - class B extends TestResource("B", { - stringArray: Output.of(A).stringArray.apply((string) => - string.map((string) => string.toUpperCase()), - ), - }) {} +describe("dependent resources (A -> B)", () => { + describe("happy path", () => { + test( + "create A then B where B uses Output.of(A)", + Effect.gen(function* () { + class A extends TestResource("A", { string: "a-value" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} - const stack = yield* apply(B); - expect(stack.B.stringArray).toEqual(["TEST-STRING-ARRAY"]); - } + const stack = yield* apply(B); - { - class B extends TestResource("B", { - stringArray: Output.of(A).stringArray.apply((stringArray) => - stringArray.flatMap((string) => [string, string]), - ), - }) {} + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("created"); + expect(stack.A.string).toEqual("a-value"); + expect(stack.B.string).toEqual("a-value"); + }).pipe(Effect.provide(MockLayers())), + ); + + test( + "update A propagates to B", + Effect.gen(function* () { + { + class A extends TestResource("A", { string: "a-value" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + yield* apply(B); + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("created"); + } + // Update A's string - B should update with the new value + class A extends TestResource("A", { string: "a-value-updated" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + const stack = yield* apply(B); + + expect((yield* getState("A"))?.status).toEqual("updated"); + expect((yield* getState("B"))?.status).toEqual("updated"); + expect(stack.A.string).toEqual("a-value-updated"); + expect(stack.B.string).toEqual("a-value-updated"); + }).pipe(Effect.provide(MockLayers())), + ); + + test( + "replace A, B updates to new A's output", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "a-value", + replaceString: "original", + }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + yield* apply(B); + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("created"); + } + // Replace A - B should update to point to new A's output + class A extends TestResource("A", { + string: "a-value-new", + replaceString: "changed", + }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + const stack = yield* apply(B); + + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("updated"); + expect(stack.A.string).toEqual("a-value-new"); + expect(stack.B.string).toEqual("a-value-new"); + }).pipe(Effect.provide(MockLayers())), + ); + + test( + "delete both resources (B deleted first, then A)", + Effect.gen(function* () { + class A extends TestResource("A", { string: "a-value" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + yield* apply(B); + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("created"); + + yield* destroy(); + + expect(yield* getState("A")).toBeUndefined(); + expect(yield* getState("B")).toBeUndefined(); + expect(yield* listState()).toEqual([]); + }).pipe(Effect.provide(MockLayers())), + ); + }); + + describe("failures during expandAndPivot", () => { + test( + "A create fails, B never starts - recovery creates both", + Effect.gen(function* () { + class A extends TestResource("A", { string: "a-value" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + // A fails to create - B should never start + yield* fail(apply(B), failOn("A", "create")); + + expect((yield* getState("A"))?.status).toEqual("creating"); + expect(yield* getState("B")).toBeUndefined(); + + // Recovery: re-apply should create both + const stack = yield* apply(B); + + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("created"); + expect(stack.A.string).toEqual("a-value"); + expect(stack.B.string).toEqual("a-value"); + }).pipe(Effect.provide(MockLayers())), + ); + + test( + "A creates, B create fails - recovery creates B", + Effect.gen(function* () { + class A extends TestResource("A", { string: "a-value" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + // A succeeds, B fails to create + yield* fail(apply(B), failOn("B", "create")); + + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("creating"); + + // Recovery: re-apply should noop A and create B + const stack = yield* apply(B); + + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("created"); + expect(stack.B.string).toEqual("a-value"); + }).pipe(Effect.provide(MockLayers())), + ); + + test( + "A update fails - recovery updates both", + Effect.gen(function* () { + { + class A extends TestResource("A", { string: "a-value" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + yield* apply(B); + } + + class A extends TestResource("A", { string: "a-value-updated" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + // A fails to update - B should not start updating + yield* fail(apply(B), failOn("A", "update")); + + expect((yield* getState("A"))?.status).toEqual("updating"); + expect((yield* getState("B"))?.status).toEqual("created"); + + // Recovery: re-apply should update both + const stack = yield* apply(B); - const stack = yield* apply(B); - expect(stack.B.stringArray).toEqual([ - "test-string-array", - "test-string-array", - ]); - } - }).pipe(Effect.provide(TestLayers)), -); + expect((yield* getState("A"))?.status).toEqual("updated"); + expect((yield* getState("B"))?.status).toEqual("updated"); + expect(stack.A.string).toEqual("a-value-updated"); + expect(stack.B.string).toEqual("a-value-updated"); + }).pipe(Effect.provide(MockLayers())), + ); + + test( + "A updates, B update fails - recovery updates B", + Effect.gen(function* () { + { + class A extends TestResource("A", { string: "a-value" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + yield* apply(B); + } + + class A extends TestResource("A", { string: "a-value-updated" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + // A succeeds, B fails to update + yield* fail(apply(B), failOn("B", "update")); + + expect((yield* getState("A"))?.status).toEqual("updated"); + expect((yield* getState("B"))?.status).toEqual("updating"); + + // Recovery: re-apply should noop A and update B + const stack = yield* apply(B); + + expect((yield* getState("A"))?.status).toEqual("updated"); + expect((yield* getState("B"))?.status).toEqual("updated"); + expect(stack.B.string).toEqual("a-value-updated"); + }).pipe(Effect.provide(MockLayers())), + ); + + test( + "A replacement fails - recovery replaces A and updates B", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "a-value", + replaceString: "original", + }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + yield* apply(B); + } + + class A extends TestResource("A", { + string: "a-value-new", + replaceString: "changed", + }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + // A replacement fails (during create of new A) - B should not start + yield* fail(apply(B), failOn("A", "create")); + + expect((yield* getState("A"))?.status).toEqual("replacing"); + expect((yield* getState("B"))?.status).toEqual("created"); + + // Recovery: re-apply should complete A replacement and update B + const stack = yield* apply(B); + + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("updated"); + expect(stack.A.string).toEqual("a-value-new"); + expect(stack.B.string).toEqual("a-value-new"); + }).pipe(Effect.provide(MockLayers())), + ); + + test( + "A replaced, B update fails - recovery updates B then cleans up", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "a-value", + replaceString: "original", + }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + yield* apply(B); + } + + class A extends TestResource("A", { + string: "a-value-new", + replaceString: "changed", + }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + // A replacement succeeds, B fails to update + yield* fail(apply(B), failOn("B", "update")); + + // A should be in replaced state (new A created, old A pending cleanup) + // B should be in updating state + const aState = yield* getState("A"); + expect(aState?.status).toEqual("replaced"); + expect((yield* getState("B"))?.status).toEqual("updating"); + + // Recovery: re-apply should update B and clean up old A + const stack = yield* apply(B); + + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("updated"); + expect(stack.B.string).toEqual("a-value-new"); + }).pipe(Effect.provide(MockLayers())), + ); + }); + + describe("failures during collectGarbage", () => { + test( + "A replaced, B updated, old A delete fails - recovery cleans up", + Effect.gen(function* () { + { + class A extends TestResource("A", { + string: "a-value", + replaceString: "original", + }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + yield* apply(B); + } + + class A extends TestResource("A", { + string: "a-value-new", + replaceString: "changed", + }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + // A replacement and B update succeed, but old A delete fails + yield* fail(apply(B), failOn("A", "delete")); + + // A should be in replaced state (delete of old A failed) + // B should have been updated successfully + expect((yield* getState("A"))?.status).toEqual("replaced"); + expect((yield* getState("B"))?.status).toEqual("updated"); + + // Recovery: re-apply should clean up old A + const stack = yield* apply(B); + + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("updated"); + expect(stack.A.string).toEqual("a-value-new"); + }).pipe(Effect.provide(MockLayers())), + ); + + test( + "orphan B delete fails - recovery deletes B then A", + Effect.gen(function* () { + class A extends TestResource("A", { string: "a-value" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + yield* apply(B); + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("created"); + + // Orphan deletion: B delete fails + yield* fail(destroy(), failOn("B", "delete")); + + // B should be in deleting state, A should still be created (waiting for B) + expect((yield* getState("B"))?.status).toEqual("deleting"); + expect((yield* getState("A"))?.status).toEqual("created"); + + // Recovery: re-apply destroy should delete B then A + yield* destroy(); + + expect(yield* getState("A")).toBeUndefined(); + expect(yield* getState("B")).toBeUndefined(); + }).pipe(Effect.provide(MockLayers())), + ); + + test( + "orphan A delete fails after B deleted - recovery deletes A", + Effect.gen(function* () { + class A extends TestResource("A", { string: "a-value" }) {} + class B extends TestResource("B", { string: Output.of(A).string }) {} + + yield* apply(B); + expect((yield* getState("A"))?.status).toEqual("created"); + expect((yield* getState("B"))?.status).toEqual("created"); + + // Orphan deletion: B succeeds, A fails + yield* fail(destroy(), failOn("A", "delete")); + + // B should be deleted, A should be in deleting state + expect(yield* getState("B")).toBeUndefined(); + expect((yield* getState("A"))?.status).toEqual("deleting"); + + // Recovery: re-apply destroy should delete A + yield* destroy(); + + expect(yield* getState("A")).toBeUndefined(); + }).pipe(Effect.provide(MockLayers())), + ); + }); +}); diff --git a/alchemy-effect/test/aws/ec2/vpc.test.ts b/alchemy-effect/test/aws/ec2/vpc.test.ts new file mode 100644 index 0000000..25d140b --- /dev/null +++ b/alchemy-effect/test/aws/ec2/vpc.test.ts @@ -0,0 +1,800 @@ +import * as AWS from "@/aws"; +import * as EC2 from "@/aws/ec2"; +import { + apply as _apply, + applyPlan, + destroy, + plan, + printPlan, + type AnyResource, + type AnyService, +} from "@/index"; +import * as Output from "@/output"; +import { test } from "@/test"; +import { expect } from "@effect/vitest"; +import { Data, LogLevel, Schedule } from "effect"; +import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; + +const logLevel = Logger.withMinimumLogLevel( + process.env.DEBUG ? LogLevel.Debug : LogLevel.Info, +); + +const apply = (( + ...resources: Resources +) => + plan(...resources).pipe( + Effect.tap((plan) => Effect.log(printPlan(plan))), + Effect.flatMap(applyPlan), + )) as typeof _apply; + +test( + "VPC evolution: from simple to complex", + Effect.gen(function* () { + const ec2 = yield* EC2.EC2Client; + + yield* destroy(); + + // Get available AZs for multi-AZ stages + const azResult = yield* ec2.describeAvailabilityZones({}); + const availableAzs = + azResult.AvailabilityZones?.filter((az) => az.State === "available") ?? + []; + const az1 = availableAzs[0]?.ZoneName!; + const az2 = availableAzs[1]?.ZoneName!; + + // ========================================================================= + // STAGE 1: Bare Minimum VPC + // User starts with just a VPC - the most basic setup + // ========================================================================= + yield* Effect.log("=== Stage 1: Bare Minimum VPC ==="); + { + class MyVpc extends EC2.Vpc("MyVpc", { + cidrBlock: "10.0.0.0/16", + }) {} + + const stack = yield* apply(MyVpc); + + // Verify VPC was created + expect(stack.MyVpc.vpcId).toMatch(/^vpc-/); + expect(stack.MyVpc.cidrBlock).toEqual("10.0.0.0/16"); + expect(stack.MyVpc.state).toEqual("available"); + + const vpcResult = yield* ec2.describeVpcs({ + VpcIds: [stack.MyVpc.vpcId], + }); + expect(vpcResult.Vpcs?.[0]?.CidrBlock).toEqual("10.0.0.0/16"); + } + + // ========================================================================= + // STAGE 2: Add Internet Connectivity + // User needs public internet access - add IGW, public subnet, route table + // Tests: VPC update (DNS settings), IGW create, Subnet create, Route create + // ========================================================================= + yield* Effect.log("=== Stage 2: Add Internet Connectivity ==="); + { + class MyVpc extends EC2.Vpc("MyVpc", { + cidrBlock: "10.0.0.0/16", + enableDnsSupport: true, + enableDnsHostnames: true, + }) {} + + class InternetGateway extends EC2.InternetGateway("InternetGateway", { + vpcId: Output.of(MyVpc).vpcId, + }) {} + + class PublicSubnet1 extends EC2.Subnet("PublicSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.1.0/24", + availabilityZone: az1, + mapPublicIpOnLaunch: true, + }) {} + + class PublicRouteTable extends EC2.RouteTable("PublicRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + }) {} + + class InternetRoute extends EC2.Route("InternetRoute", { + routeTableId: Output.of(PublicRouteTable).routeTableId, + destinationCidrBlock: "0.0.0.0/0", + gatewayId: Output.of(InternetGateway).internetGatewayId, + }) {} + + class PublicSubnet1Association extends EC2.RouteTableAssociation( + "PublicSubnet1Association", + { + routeTableId: Output.of(PublicRouteTable).routeTableId, + subnetId: Output.of(PublicSubnet1).subnetId, + }, + ) {} + + const stack = yield* apply( + MyVpc, + InternetGateway, + PublicSubnet1, + PublicRouteTable, + InternetRoute, + PublicSubnet1Association, + ); + + // Verify IGW + expect(stack.InternetGateway.internetGatewayId).toMatch(/^igw-/); + expect(stack.InternetGateway.vpcId).toEqual(stack.MyVpc.vpcId); + + // Verify public subnet + expect(stack.PublicSubnet1.subnetId).toMatch(/^subnet-/); + expect(stack.PublicSubnet1.mapPublicIpOnLaunch).toEqual(true); + expect(stack.PublicSubnet1.availabilityZone).toEqual(az1); + + // Verify route to IGW + expect(stack.InternetRoute.state).toEqual("active"); + expect(stack.InternetRoute.gatewayId).toEqual( + stack.InternetGateway.internetGatewayId, + ); + + // Verify association + expect(stack.PublicSubnet1Association.associationId).toMatch( + /^rtbassoc-/, + ); + } + + // ========================================================================= + // STAGE 3: Add Private Subnet + // User needs private resources (databases, internal services) + // Tests: Adding private subnet with separate route table (no internet) + // ========================================================================= + yield* Effect.log("=== Stage 3: Add Private Subnet ==="); + { + class MyVpc extends EC2.Vpc("MyVpc", { + cidrBlock: "10.0.0.0/16", + enableDnsSupport: true, + enableDnsHostnames: true, + }) {} + + class InternetGateway extends EC2.InternetGateway("InternetGateway", { + vpcId: Output.of(MyVpc).vpcId, + }) {} + + class PublicSubnet1 extends EC2.Subnet("PublicSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.1.0/24", + availabilityZone: az1, + mapPublicIpOnLaunch: true, + }) {} + + class PrivateSubnet1 extends EC2.Subnet("PrivateSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.10.0/24", + availabilityZone: az1, + }) {} + + class PublicRouteTable extends EC2.RouteTable("PublicRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + }) {} + + class PrivateRouteTable extends EC2.RouteTable("PrivateRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + }) {} + + class InternetRoute extends EC2.Route("InternetRoute", { + routeTableId: Output.of(PublicRouteTable).routeTableId, + destinationCidrBlock: "0.0.0.0/0", + gatewayId: Output.of(InternetGateway).internetGatewayId, + }) {} + + class PublicSubnet1Association extends EC2.RouteTableAssociation( + "PublicSubnet1Association", + { + routeTableId: Output.of(PublicRouteTable).routeTableId, + subnetId: Output.of(PublicSubnet1).subnetId, + }, + ) {} + + class PrivateSubnet1Association extends EC2.RouteTableAssociation( + "PrivateSubnet1Association", + { + routeTableId: Output.of(PrivateRouteTable).routeTableId, + subnetId: Output.of(PrivateSubnet1).subnetId, + }, + ) {} + + const stack = yield* apply( + MyVpc, + InternetGateway, + PublicSubnet1, + PrivateSubnet1, + PublicRouteTable, + PrivateRouteTable, + InternetRoute, + PublicSubnet1Association, + PrivateSubnet1Association, + ); + + // Verify private subnet + expect(stack.PrivateSubnet1.subnetId).toMatch(/^subnet-/); + expect(stack.PrivateSubnet1.mapPublicIpOnLaunch).toBeFalsy(); + + // Verify private route table has NO internet route + const privateRtResult = yield* ec2.describeRouteTables({ + RouteTableIds: [stack.PrivateRouteTable.routeTableId], + }); + const privateRoutes = privateRtResult.RouteTables?.[0]?.Routes ?? []; + const privateInternetRoute = privateRoutes.find( + (r) => r.DestinationCidrBlock === "0.0.0.0/0", + ); + expect(privateInternetRoute).toBeUndefined(); + } + + // ========================================================================= + // STAGE 4: Multi-AZ Expansion + // User needs high availability - add subnets in second AZ + // Tests: Adding subnets in second AZ, sharing route tables + // ========================================================================= + yield* Effect.log("=== Stage 4: Multi-AZ Expansion ==="); + { + class MyVpc extends EC2.Vpc("MyVpc", { + cidrBlock: "10.0.0.0/16", + enableDnsSupport: true, + enableDnsHostnames: true, + }) {} + + class InternetGateway extends EC2.InternetGateway("InternetGateway", { + vpcId: Output.of(MyVpc).vpcId, + }) {} + + // AZ1 subnets + class PublicSubnet1 extends EC2.Subnet("PublicSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.1.0/24", + availabilityZone: az1, + mapPublicIpOnLaunch: true, + }) {} + + class PrivateSubnet1 extends EC2.Subnet("PrivateSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.10.0/24", + availabilityZone: az1, + }) {} + + // AZ2 subnets + class PublicSubnet2 extends EC2.Subnet("PublicSubnet2", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.2.0/24", + availabilityZone: az2, + mapPublicIpOnLaunch: true, + }) {} + + class PrivateSubnet2 extends EC2.Subnet("PrivateSubnet2", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.11.0/24", + availabilityZone: az2, + }) {} + + class PublicRouteTable extends EC2.RouteTable("PublicRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + }) {} + + class PrivateRouteTable extends EC2.RouteTable("PrivateRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + }) {} + + class InternetRoute extends EC2.Route("InternetRoute", { + routeTableId: Output.of(PublicRouteTable).routeTableId, + destinationCidrBlock: "0.0.0.0/0", + gatewayId: Output.of(InternetGateway).internetGatewayId, + }) {} + + // AZ1 associations + class PublicSubnet1Association extends EC2.RouteTableAssociation( + "PublicSubnet1Association", + { + routeTableId: Output.of(PublicRouteTable).routeTableId, + subnetId: Output.of(PublicSubnet1).subnetId, + }, + ) {} + + class PrivateSubnet1Association extends EC2.RouteTableAssociation( + "PrivateSubnet1Association", + { + routeTableId: Output.of(PrivateRouteTable).routeTableId, + subnetId: Output.of(PrivateSubnet1).subnetId, + }, + ) {} + + // AZ2 associations (share route tables) + class PublicSubnet2Association extends EC2.RouteTableAssociation( + "PublicSubnet2Association", + { + routeTableId: Output.of(PublicRouteTable).routeTableId, + subnetId: Output.of(PublicSubnet2).subnetId, + }, + ) {} + + class PrivateSubnet2Association extends EC2.RouteTableAssociation( + "PrivateSubnet2Association", + { + routeTableId: Output.of(PrivateRouteTable).routeTableId, + subnetId: Output.of(PrivateSubnet2).subnetId, + }, + ) {} + + const stack = yield* apply( + MyVpc, + InternetGateway, + PublicSubnet1, + PrivateSubnet1, + PublicSubnet2, + PrivateSubnet2, + PublicRouteTable, + PrivateRouteTable, + InternetRoute, + PublicSubnet1Association, + PrivateSubnet1Association, + PublicSubnet2Association, + PrivateSubnet2Association, + ); + + // Verify subnets are in different AZs + expect(stack.PublicSubnet1.availabilityZone).toEqual(az1); + expect(stack.PublicSubnet2.availabilityZone).toEqual(az2); + expect(stack.PrivateSubnet1.availabilityZone).toEqual(az1); + expect(stack.PrivateSubnet2.availabilityZone).toEqual(az2); + + // Verify all 4 associations exist + expect(stack.PublicSubnet1Association.associationId).toMatch( + /^rtbassoc-/, + ); + expect(stack.PublicSubnet2Association.associationId).toMatch( + /^rtbassoc-/, + ); + expect(stack.PrivateSubnet1Association.associationId).toMatch( + /^rtbassoc-/, + ); + expect(stack.PrivateSubnet2Association.associationId).toMatch( + /^rtbassoc-/, + ); + + // Verify both public subnets share the same route table + expect(stack.PublicSubnet1Association.routeTableId).toEqual( + stack.PublicSubnet2Association.routeTableId, + ); + } + + // ========================================================================= + // STAGE 5: Update Tags and Properties + // User needs better organization - add tags for production + // Tests: Tag updates on existing resources + // ========================================================================= + yield* Effect.log("=== Stage 5: Update Tags and Properties ==="); + { + class MyVpc extends EC2.Vpc("MyVpc", { + cidrBlock: "10.0.0.0/16", + enableDnsSupport: true, + enableDnsHostnames: true, + tags: { + Name: "production-vpc", + Environment: "production", + }, + }) {} + + class InternetGateway extends EC2.InternetGateway("InternetGateway", { + vpcId: Output.of(MyVpc).vpcId, + tags: { + Name: "production-igw", + }, + }) {} + + class PublicSubnet1 extends EC2.Subnet("PublicSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.1.0/24", + availabilityZone: az1, + mapPublicIpOnLaunch: true, + tags: { Name: "public-1a", Tier: "public" }, + }) {} + + class PrivateSubnet1 extends EC2.Subnet("PrivateSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.10.0/24", + availabilityZone: az1, + tags: { Name: "private-1a", Tier: "private" }, + }) {} + + class PublicSubnet2 extends EC2.Subnet("PublicSubnet2", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.2.0/24", + availabilityZone: az2, + mapPublicIpOnLaunch: true, + tags: { Name: "public-1b", Tier: "public" }, + }) {} + + class PrivateSubnet2 extends EC2.Subnet("PrivateSubnet2", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.11.0/24", + availabilityZone: az2, + tags: { Name: "private-1b", Tier: "private" }, + }) {} + + class PublicRouteTable extends EC2.RouteTable("PublicRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + tags: { Name: "public-rt" }, + }) {} + + class PrivateRouteTable extends EC2.RouteTable("PrivateRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + tags: { Name: "private-rt" }, + }) {} + + class InternetRoute extends EC2.Route("InternetRoute", { + routeTableId: Output.of(PublicRouteTable).routeTableId, + destinationCidrBlock: "0.0.0.0/0", + gatewayId: Output.of(InternetGateway).internetGatewayId, + }) {} + + class PublicSubnet1Association extends EC2.RouteTableAssociation( + "PublicSubnet1Association", + { + routeTableId: Output.of(PublicRouteTable).routeTableId, + subnetId: Output.of(PublicSubnet1).subnetId, + }, + ) {} + + class PrivateSubnet1Association extends EC2.RouteTableAssociation( + "PrivateSubnet1Association", + { + routeTableId: Output.of(PrivateRouteTable).routeTableId, + subnetId: Output.of(PrivateSubnet1).subnetId, + }, + ) {} + + class PublicSubnet2Association extends EC2.RouteTableAssociation( + "PublicSubnet2Association", + { + routeTableId: Output.of(PublicRouteTable).routeTableId, + subnetId: Output.of(PublicSubnet2).subnetId, + }, + ) {} + + class PrivateSubnet2Association extends EC2.RouteTableAssociation( + "PrivateSubnet2Association", + { + routeTableId: Output.of(PrivateRouteTable).routeTableId, + subnetId: Output.of(PrivateSubnet2).subnetId, + }, + ) {} + + const stack = yield* apply( + MyVpc, + InternetGateway, + PublicSubnet1, + PrivateSubnet1, + PublicSubnet2, + PrivateSubnet2, + PublicRouteTable, + PrivateRouteTable, + InternetRoute, + PublicSubnet1Association, + PrivateSubnet1Association, + PublicSubnet2Association, + PrivateSubnet2Association, + ); + + // Verify tags were applied by checking AWS (with retry for eventual consistency) + yield* assertVpcTags(stack.MyVpc.vpcId, { + Name: "production-vpc", + Environment: "production", + }); + } + + // ========================================================================= + // STAGE 6: Re-associate Subnet to Different Route Table + // User wants to move PublicSubnet2 to a dedicated route table + // Tests: Route table association update (replaceRouteTableAssociation) + // ========================================================================= + yield* Effect.log("=== Stage 6: Re-associate Subnet ==="); + { + class MyVpc extends EC2.Vpc("MyVpc", { + cidrBlock: "10.0.0.0/16", + enableDnsSupport: true, + enableDnsHostnames: true, + tags: { + Name: "production-vpc", + Environment: "production", + }, + }) {} + + class InternetGateway extends EC2.InternetGateway("InternetGateway", { + vpcId: Output.of(MyVpc).vpcId, + tags: { Name: "production-igw" }, + }) {} + + class PublicSubnet1 extends EC2.Subnet("PublicSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.1.0/24", + availabilityZone: az1, + mapPublicIpOnLaunch: true, + tags: { Name: "public-1a", Tier: "public" }, + }) {} + + class PrivateSubnet1 extends EC2.Subnet("PrivateSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.10.0/24", + availabilityZone: az1, + tags: { Name: "private-1a", Tier: "private" }, + }) {} + + class PublicSubnet2 extends EC2.Subnet("PublicSubnet2", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.2.0/24", + availabilityZone: az2, + mapPublicIpOnLaunch: true, + tags: { Name: "public-1b", Tier: "public" }, + }) {} + + class PrivateSubnet2 extends EC2.Subnet("PrivateSubnet2", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.11.0/24", + availabilityZone: az2, + tags: { Name: "private-1b", Tier: "private" }, + }) {} + + class PublicRouteTable extends EC2.RouteTable("PublicRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + tags: { Name: "public-rt" }, + }) {} + + class PrivateRouteTable extends EC2.RouteTable("PrivateRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + tags: { Name: "private-rt" }, + }) {} + + // NEW: Dedicated route table for AZ2 public subnet + class PublicRouteTable2 extends EC2.RouteTable("PublicRouteTable2", { + vpcId: Output.of(MyVpc).vpcId, + tags: { Name: "public-rt-az2" }, + }) {} + + class InternetRoute extends EC2.Route("InternetRoute", { + routeTableId: Output.of(PublicRouteTable).routeTableId, + destinationCidrBlock: "0.0.0.0/0", + gatewayId: Output.of(InternetGateway).internetGatewayId, + }) {} + + // NEW: Internet route for AZ2 public route table + class InternetRoute2 extends EC2.Route("InternetRoute2", { + routeTableId: Output.of(PublicRouteTable2).routeTableId, + destinationCidrBlock: "0.0.0.0/0", + gatewayId: Output.of(InternetGateway).internetGatewayId, + }) {} + + class PublicSubnet1Association extends EC2.RouteTableAssociation( + "PublicSubnet1Association", + { + routeTableId: Output.of(PublicRouteTable).routeTableId, + subnetId: Output.of(PublicSubnet1).subnetId, + }, + ) {} + + class PrivateSubnet1Association extends EC2.RouteTableAssociation( + "PrivateSubnet1Association", + { + routeTableId: Output.of(PrivateRouteTable).routeTableId, + subnetId: Output.of(PrivateSubnet1).subnetId, + }, + ) {} + + // CHANGED: PublicSubnet2 now uses PublicRouteTable2 + class PublicSubnet2Association extends EC2.RouteTableAssociation( + "PublicSubnet2Association", + { + routeTableId: Output.of(PublicRouteTable2).routeTableId, + subnetId: Output.of(PublicSubnet2).subnetId, + }, + ) {} + + class PrivateSubnet2Association extends EC2.RouteTableAssociation( + "PrivateSubnet2Association", + { + routeTableId: Output.of(PrivateRouteTable).routeTableId, + subnetId: Output.of(PrivateSubnet2).subnetId, + }, + ) {} + + const stack = yield* apply( + MyVpc, + InternetGateway, + PublicSubnet1, + PrivateSubnet1, + PublicSubnet2, + PrivateSubnet2, + PublicRouteTable, + PrivateRouteTable, + PublicRouteTable2, + InternetRoute, + InternetRoute2, + PublicSubnet1Association, + PrivateSubnet1Association, + PublicSubnet2Association, + PrivateSubnet2Association, + ); + + // Verify PublicSubnet2 is now associated with a different route table + expect(stack.PublicSubnet2Association.routeTableId).toEqual( + stack.PublicRouteTable2.routeTableId, + ); + expect(stack.PublicSubnet2Association.routeTableId).not.toEqual( + stack.PublicSubnet1Association.routeTableId, + ); + + // Verify the new route table has an internet route + expect(stack.InternetRoute2.state).toEqual("active"); + } + + // ========================================================================= + // STAGE 7: Scale Down + // User removes AZ2 resources (cost savings) + // Tests: Resource deletion, dependency ordering during delete + // ========================================================================= + yield* Effect.log("=== Stage 7: Scale Down ==="); + { + class MyVpc extends EC2.Vpc("MyVpc", { + cidrBlock: "10.0.0.0/16", + enableDnsSupport: true, + enableDnsHostnames: true, + tags: { + Name: "production-vpc", + Environment: "production", + }, + }) {} + + class InternetGateway extends EC2.InternetGateway("InternetGateway", { + vpcId: Output.of(MyVpc).vpcId, + tags: { Name: "production-igw" }, + }) {} + + // Only AZ1 subnets remain + class PublicSubnet1 extends EC2.Subnet("PublicSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.1.0/24", + availabilityZone: az1, + mapPublicIpOnLaunch: true, + tags: { Name: "public-1a", Tier: "public" }, + }) {} + + class PrivateSubnet1 extends EC2.Subnet("PrivateSubnet1", { + vpcId: Output.of(MyVpc).vpcId, + cidrBlock: "10.0.10.0/24", + availabilityZone: az1, + tags: { Name: "private-1a", Tier: "private" }, + }) {} + + class PublicRouteTable extends EC2.RouteTable("PublicRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + tags: { Name: "public-rt" }, + }) {} + + class PrivateRouteTable extends EC2.RouteTable("PrivateRouteTable", { + vpcId: Output.of(MyVpc).vpcId, + tags: { Name: "private-rt" }, + }) {} + + class InternetRoute extends EC2.Route("InternetRoute", { + routeTableId: Output.of(PublicRouteTable).routeTableId, + destinationCidrBlock: "0.0.0.0/0", + gatewayId: Output.of(InternetGateway).internetGatewayId, + }) {} + + class PublicSubnet1Association extends EC2.RouteTableAssociation( + "PublicSubnet1Association", + { + routeTableId: Output.of(PublicRouteTable).routeTableId, + subnetId: Output.of(PublicSubnet1).subnetId, + }, + ) {} + + class PrivateSubnet1Association extends EC2.RouteTableAssociation( + "PrivateSubnet1Association", + { + routeTableId: Output.of(PrivateRouteTable).routeTableId, + subnetId: Output.of(PrivateSubnet1).subnetId, + }, + ) {} + + // Note: PublicSubnet2, PrivateSubnet2, PublicRouteTable2, InternetRoute2, + // and their associations are NOT included - they will be deleted + + const stack = yield* apply( + MyVpc, + InternetGateway, + PublicSubnet1, + PrivateSubnet1, + PublicRouteTable, + PrivateRouteTable, + InternetRoute, + PublicSubnet1Association, + PrivateSubnet1Association, + ); + + // Verify only 2 subnets exist now + const subnetsResult = yield* ec2.describeSubnets({ + Filters: [{ Name: "vpc-id", Values: [stack.MyVpc.vpcId] }], + }); + expect(subnetsResult.Subnets).toHaveLength(2); + + // Verify remaining subnets are in AZ1 + for (const subnet of subnetsResult.Subnets ?? []) { + expect(subnet.AvailabilityZone).toEqual(az1); + } + } + + // ========================================================================= + // STAGE 8: Final Cleanup + // Destroy everything and verify + // ========================================================================= + yield* Effect.log("=== Stage 8: Final Cleanup ==="); + const vpcId = (yield* EC2.EC2Client) + .describeVpcs({ + Filters: [{ Name: "tag:Name", Values: ["production-vpc"] }], + }) + .pipe(Effect.map((r) => r.Vpcs?.[0]?.VpcId)); + + const capturedVpcId = yield* vpcId; + + yield* destroy(); + + // Verify VPC is deleted + if (capturedVpcId) { + yield* ec2.describeVpcs({ VpcIds: [capturedVpcId] }).pipe( + Effect.flatMap(() => Effect.fail(new Error("VPC still exists"))), + Effect.catchTag("InvalidVpcID.NotFound", () => Effect.void), + ); + } + + yield* Effect.log("=== All stages completed successfully! ==="); + }).pipe(Effect.provide(AWS.providers()), logLevel), +); + +// ============================================================================ +// Eventually Consistent Check Utilities +// ============================================================================ + +class TagsNotPropagated extends Data.TaggedError("TagsNotPropagated")<{ + readonly expected: Record; + readonly actual: Record; +}> {} + +/** + * Asserts that a VPC has the expected tags, retrying until eventually consistent. + */ +const assertVpcTags = Effect.fn(function* ( + vpcId: string, + expectedTags: Record, +) { + const ec2 = yield* EC2.EC2Client; + + yield* ec2.describeVpcs({ VpcIds: [vpcId] }).pipe( + Effect.flatMap((result) => { + const tags = result.Vpcs?.[0]?.Tags ?? []; + const actual: Record = {}; + + for (const key of Object.keys(expectedTags)) { + actual[key] = tags.find((t) => t.Key === key)?.Value; + } + + const allMatch = Object.entries(expectedTags).every( + ([key, value]) => actual[key] === value, + ); + + return allMatch + ? Effect.succeed(result) + : Effect.fail( + new TagsNotPropagated({ expected: expectedTags, actual }), + ); + }), + Effect.retry({ + while: (e) => e._tag === "TagsNotPropagated", + schedule: Schedule.exponential(100).pipe( + Schedule.intersect(Schedule.recurs(10)), + ), + }), + ); +}); diff --git a/alchemy-effect/test/evaluate.test.ts b/alchemy-effect/test/evaluate.test.ts index 9315167..65cd7f6 100644 --- a/alchemy-effect/test/evaluate.test.ts +++ b/alchemy-effect/test/evaluate.test.ts @@ -1,12 +1,11 @@ import * as EC2 from "@/aws/ec2"; import * as R2 from "@/cloudflare/r2"; -import { $, App, ref } from "@/index"; -import { Stage } from "@/stage"; +import { $, App } from "@/index"; import * as Output from "@/output"; +import { test } from "@/test"; import { expect, it } from "@effect/vitest"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; -import { test } from "@/test"; import * as Layer from "effect/Layer"; class TestVpc extends EC2.Vpc("TestVpc", { @@ -230,13 +229,16 @@ it.live("Output.ref('TestVpc', 'other-stage').vpcId", () => "test-app": { "other-stage": { TestVpc: { - type: "Test.TestVpc", - id: "TestVpc", + resourceType: "Test.TestVpc", + logicalId: "TestVpc", status: "created", props: {}, - output: { + attr: { vpcId: "vpc-0987654321", }, + downstream: [], + instanceId: "1234567890", + providerVersion: 0, }, }, }, diff --git a/alchemy-effect/test/plan.test.ts b/alchemy-effect/test/plan.test.ts index 1152cdb..7c7aa5a 100644 --- a/alchemy-effect/test/plan.test.ts +++ b/alchemy-effect/test/plan.test.ts @@ -1,12 +1,9 @@ -import type { Resource } from "@/resource"; import type { Input, InputProps } from "@/input"; import * as Output from "@/output"; -import { plan, type TransitiveResources, type TraverseResources } from "@/plan"; -import * as State from "@/state"; +import { type CRUD, type IPlan, plan, type TraverseResources } from "@/plan"; import { test } from "@/test"; import { describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import { Bucket, Function, @@ -15,10 +12,12 @@ import { TestResource, type TestResourceProps, } from "./test.resources"; -import * as App from "@/app"; +import type { ResourceState, ResourceStatus } from "@/state"; const _test = test; +const instanceId = "852f6ec2e19b66589825efe14dca2971"; + class MyBucket extends Bucket("MyBucket", { name: "test-bucket", }) {} @@ -45,19 +44,19 @@ test( MyBucket: { action: "create", bindings: [], - news: { + props: { name: "test-bucket", }, - attributes: undefined, + state: undefined, resource: MyBucket, }, MyQueue: { action: "create", bindings: [], - news: { + props: { name: "test-queue", }, - attributes: undefined, + state: undefined, resource: MyQueue, }, }, @@ -71,16 +70,19 @@ test( { state: test.state({ MyBucket: { - id: "MyBucket", - type: "Test.Bucket", + instanceId, + providerVersion: 0, + logicalId: "MyBucket", + resourceType: "Test.Bucket", status: "created", props: { name: "test-bucket", }, - output: { + attr: { name: "test-bucket", }, bindings: [], + downstream: [], }, }), }, @@ -90,17 +92,19 @@ test( MyBucket: { action: "noop", bindings: [], - attributes: undefined, resource: MyBucket, + state: { + status: "created", + }, }, MyQueue: { action: "create", bindings: [], - news: { + props: { name: "test-queue", }, - attributes: undefined, resource: MyQueue, + state: undefined, }, }, deletions: expect.emptyObject(), @@ -109,32 +113,38 @@ test( ); test( - "delete oprhaned resources", + "delete orphaned resources", { state: test.state({ MyBucket: { - id: "MyBucket", - type: "Test.Bucket", + instanceId, + providerVersion: 0, + logicalId: "MyBucket", + resourceType: "Test.Bucket", status: "created", props: { name: "test-bucket", }, - output: { + attr: { name: "test-bucket", }, bindings: [], + downstream: [], }, MyQueue: { - id: "MyQueue", - type: "Test.Queue", + instanceId, + providerVersion: 0, + logicalId: "MyQueue", + resourceType: "Test.Queue", status: "created", props: { name: "test-queue", }, - output: { + attr: { name: "test-queue", }, bindings: [], + downstream: [], }, }), }, @@ -144,16 +154,21 @@ test( MyQueue: { action: "noop", bindings: [], - attributes: undefined, resource: MyQueue, + state: { + status: "created", + }, }, }, deletions: { MyBucket: { action: "delete", bindings: [], - attributes: { - name: "test-bucket", + state: { + status: "created", + attr: { + name: "test-bucket", + }, }, resource: { id: "MyBucket", @@ -168,6 +183,437 @@ test( }).pipe(Effect.provide(TestLayers)), ); +test( + "replace resource when replaceString changes", + { + state: test.state({ + A: { + instanceId, + providerVersion: 0, + logicalId: "A", + resourceType: "Test.TestResource", + status: "created", + props: { + replaceString: "A", + }, + attr: {}, + downstream: [], + }, + }), + }, + Effect.gen(function* () { + { + class A extends TestResource("A", { + replaceString: "A", + }) {} + + // replaceString is the same + expect(yield* plan(A)).toMatchObject({ + resources: { + A: { + action: "noop", + }, + }, + }); + } + + { + class A extends TestResource("A", { + replaceString: "B", + }) {} + expect(yield* plan(A)).toMatchObject({ + resources: { + A: { + action: "replace", + props: { + replaceString: "B", + }, + }, + }, + deletions: expect.emptyObject(), + }); + } + + { + class B extends TestResource("B", { + string: "A", + }) {} + class A extends TestResource("A", { + string: Output.of(B).string, + }) {} + + const p = yield* plan(A); + expect(p).toMatchObject({ + resources: { + A: { + action: "replace", + props: { + string: expect.propExpr("string", B), + }, + }, + }, + deletions: expect.emptyObject(), + }); + } + }).pipe(Effect.provide(TestLayers)), +); + +const createTestResourceState = (options: { + logicalId: string; + status: ResourceStatus; + props: TestResourceProps; + attr?: {}; +}) => + ({ + instanceId, + providerVersion: 0, + ...options, + resourceType: "Test.TestResource", + attr: options.attr ?? {}, + downstream: [], + }) as ResourceState; + +const testSimple = ( + title: string, + testCase: { + state: { + status: ResourceStatus; + props: TestResourceProps; + attr?: {}; + old?: Partial; + }; + props: TestResourceProps; + plan?: any; + fail?: string; + }, +) => + test( + title, + { + state: test.state({ + A: createTestResourceState({ + ...testCase.state, + logicalId: "A", + }), + }), + }, + Effect.gen(function* () { + { + class A extends TestResource("A", testCase.props) {} + if (testCase.fail) { + const result = yield* plan(A).pipe( + Effect.map(() => false), + // @ts-expect-error + Effect.catchTag(testCase.fail, () => Effect.succeed(true)), + Effect.catchAll(() => Effect.succeed(false)), + ) as Effect.Effect; + if (!result) { + expect.fail(`Expected error '${testCase.fail}`); + } + } else { + expect(yield* plan(A)).toMatchObject({ + resources: { + A: testCase.plan, + }, + deletions: expect.emptyObject(), + }); + } + } + }).pipe(Effect.provide(TestLayers)), + ); + +describe("prior crash in 'creating' state", () => { + testSimple("create if props unchanged", { + state: { + status: "creating", + props: { + string: "A", + }, + }, + props: { + string: "A", + }, + plan: { + action: "create", + props: { + string: "A", + }, + }, + }); + + testSimple("create if changed props can be updated", { + state: { + status: "creating", + props: { + string: "A", + }, + }, + props: { + string: "B", + }, + plan: { + action: "create", + props: { + string: "B", + }, + }, + }); + + testSimple("replace if changed props cannot be updated", { + state: { + status: "creating", + props: { + replaceString: "A", + }, + }, + props: { + replaceString: "B", + }, + plan: { + action: "replace", + props: { + replaceString: "B", + }, + state: { + status: "creating", + props: { + replaceString: "A", + }, + }, + }, + }); +}); + +describe("prior crash in 'updating' state", () => { + testSimple("update if props unchanged", { + state: { + status: "updating", + props: { + string: "A", + }, + }, + props: { + string: "A", + }, + plan: { + action: "update", + props: { + string: "A", + }, + state: { + status: "updating", + props: { + string: "A", + }, + }, + }, + }); + + testSimple("update if changed props can be updated", { + state: { + status: "updating", + props: { + string: "A", + }, + }, + props: { + string: "B", + }, + plan: { + action: "update", + props: { + string: "B", + }, + state: { + status: "updating", + props: { + string: "A", + }, + }, + }, + }); + + testSimple("replace if changed props can not be updated", { + state: { + status: "updating", + props: { + replaceString: "A", + }, + }, + props: { + replaceString: "B", + }, + plan: { + action: "replace", + props: { + replaceString: "B", + }, + state: { + status: "updating", + props: { + replaceString: "A", + }, + }, + }, + }); +}); + +describe("prior crash in 'replacing' state", () => { + const priorStates = ["created", "creating", "updated", "updating"] as const; + + const testUnchanged = ({ + old, + }: { + old: { + status: ResourceStatus; + }; + }) => + testSimple( + `"continue 'replace' if props are unchanged and previous state is '${old.status}'"`, + { + state: { + status: "replacing", + props: { + string: "A", + }, + old, + }, + props: { + string: "A", + }, + plan: { + action: "replace", + props: { + string: "A", + }, + state: { + status: "replacing", + props: { + string: "A", + }, + old, + }, + }, + }, + ); + + priorStates.forEach((status) => + testUnchanged({ + old: { + status, + }, + }), + ); + + const testMinorChange = ({ + old, + }: { + old: { + status: ResourceStatus; + }; + }) => + testSimple( + `"continue 'replace' if props can be updated and previous state is '${old.status}'"`, + { + state: { + status: "replacing", + props: { + string: "A", + }, + old, + }, + props: { + string: "B", + }, + plan: { + action: "replace", + props: { + string: "B", + }, + state: { + status: "replacing", + props: { + string: "A", + }, + old, + }, + }, + }, + ); + + priorStates.forEach((status) => + testMinorChange({ + old: { + status, + }, + }), + ); + + const testReplacement = ( + title: string, + { + old, + plan, + fail, + }: { + old: { + status: ResourceStatus; + }; + plan?: any; + fail?: string; + }, + ) => + testSimple(title, { + state: { + status: "replacing", + props: { + replaceString: "A", + }, + old, + }, + props: { + replaceString: "B", + }, + plan, + fail, + }); + + (["replaced", "replacing"] as const).forEach((status) => + testReplacement( + `fail if trying to replace a partially replaced resource in state '${status}'`, + { + old: { + status, + }, + fail: "CannotReplacePartiallyReplacedResource", + }, + ), + ); +}); + +describe("prior crash in 'deleting' state", () => { + testSimple( + "create the resource if props are unchanged and the previous state is 'deleting'", + { + state: { + status: "deleting", + props: { + string: "A", + }, + }, + props: { + string: "A", + }, + plan: { + action: "create", + props: { + string: "A", + }, + }, + }, + ); +}); + test( "lazy Output queue.queueUrl to Function.env", Effect.gen(function* () { @@ -176,14 +622,14 @@ test( MyFunction: { action: "create", bindings: [], - attributes: undefined, resource: MyFunction, - news: { + props: { name: "test-function", env: { QUEUE_URL: expect.propExpr("queueUrl", MyQueue), }, }, + state: undefined, }, }, deletions: expect.emptyObject(), @@ -196,15 +642,18 @@ test( { state: test.state({ MyQueue: { - id: "MyQueue", - type: "Test.Queue", + instanceId, + providerVersion: 0, + logicalId: "MyQueue", + resourceType: "Test.Queue", status: "created", props: { name: "test-queue-old", }, - output: { + attr: { queueUrl: "https://test.queue.com/test-queue-old", }, + downstream: [], }, }), }, @@ -214,14 +663,14 @@ test( MyFunction: { action: "create", bindings: [], - attributes: undefined, resource: MyFunction, - news: { + props: { name: "test-function", env: { QUEUE_URL: expect.propExpr("queueUrl", MyQueue), }, }, + state: undefined, }, }, deletions: expect.emptyObject(), @@ -232,35 +681,36 @@ test( describe("Outputs should resolve to old values", () => { const state = _test.state({ A: { - id: "A", - type: "Test.TestResource", + instanceId, + providerVersion: 0, + logicalId: "A", + resourceType: "Test.TestResource", status: "created", props: { string: "test-string", stringArray: ["test-string"], }, - output: { + attr: { string: "test-string", stringArray: ["test-string"], }, + downstream: [], }, }); class A extends TestResource("A", { string: "test-string", stringArray: ["test-string"], }) {} - const expected = (news: TestResourceProps) => ({ + const expected = (props: TestResourceProps) => ({ resources: { A: { action: "noop", bindings: [], - attributes: undefined, }, B: { action: "create", bindings: [], - attributes: undefined, - news, + props: props, }, }, deletions: expect.emptyObject(), @@ -272,7 +722,7 @@ describe("Outputs should resolve to old values", () => { const test = >( description: string, input: I, - output: Input.Resolve, + attr: Input.Resolve, ) => _test( description, @@ -280,7 +730,7 @@ describe("Outputs should resolve to old values", () => { state, }, Effect.gen(function* () { - expect(yield* createPlan(input)).toMatchObject(expected(output)); + expect(yield* createPlan(input)).toMatchObject(expected(attr)); }).pipe(Effect.provide(TestLayers)), ); @@ -340,21 +790,26 @@ describe("stable properties should not cause downstream changes", () => { { state: _test.state({ A: { - id: "A", - type: "Test.TestResource", + instanceId, + providerVersion: 0, + logicalId: "A", + resourceType: "Test.TestResource", status: "created", props: { string: "test-string-old", }, - output: { + attr: { string: "test-string-old", stableString: "A", stableArray: ["A"], }, + downstream: [], }, B: { - id: "B", - type: "Test.TestResource", + instanceId, + providerVersion: 0, + logicalId: "B", + resourceType: "Test.TestResource", status: "created", props: Object.fromEntries( Object.entries({ @@ -362,9 +817,10 @@ describe("stable properties should not cause downstream changes", () => { stringArray: ["A"], }).filter(([key]) => key in input), ), - output: { + attr: { stableString: "A", }, + downstream: [], }, }), }, @@ -373,7 +829,7 @@ describe("stable properties should not cause downstream changes", () => { resources: { A: { action: "update", - news: { + props: { string: "test-string", }, }, diff --git a/alchemy-effect/test/test.resources.ts b/alchemy-effect/test/test.resources.ts index 6dcfa9a..9e54e1b 100644 --- a/alchemy-effect/test/test.resources.ts +++ b/alchemy-effect/test/test.resources.ts @@ -1,8 +1,12 @@ import type { Input, InputProps } from "@/input"; +import * as Context from "effect/Context"; +import * as Option from "effect/Option"; import { Resource } from "@/resource"; import { isUnknown } from "@/unknown"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import type { ProviderService } from "@/provider"; +import * as State from "@/state"; // Bucket export type BucketProps = { @@ -135,6 +139,7 @@ export type TestResourceProps = { object?: { string: string; }; + replaceString?: string; }; export type TestResourceAttr = { @@ -144,6 +149,7 @@ export type TestResourceAttr = { : string[]; stableString: string; stableArray: string[]; + replaceString: Props["replaceString"]; }; export interface TestResource< @@ -157,6 +163,15 @@ export interface TestResource< TestResource > {} +export class TestResourceHooks extends Context.Tag("TestResourceHooks")< + TestResourceHooks, + { + create?: (id: string, props: TestResourceProps) => Effect.Effect; + update?: (id: string, props: TestResourceProps) => Effect.Effect; + delete?: (id: string) => Effect.Effect; + } +>() {} + export const TestResource = Resource<{ >( id: ID, @@ -164,44 +179,70 @@ export const TestResource = Resource<{ ): TestResource; }>("Test.TestResource"); -export const testResourceProvider = TestResource.provider.succeed({ - diff: Effect.fn(function* ({ id, news, olds }) { - if (isUnknown(news.stringArray)) { - news.stringArray; - } - return isUnknown(news.string) || - isUnknown(news.stringArray) || - news.string !== olds.string || - news.stringArray?.length !== olds.stringArray?.length || - !!news.stringArray !== !!olds.stringArray || - news.stringArray?.some(isUnknown) || - news.stringArray?.some((s, i) => s !== olds.stringArray?.[i]) - ? { - action: "update", - stables: ["stableString", "stableArray"], - } - : undefined; - }), - create: Effect.fn(function* ({ id, news }) { +export const testResourceProvider = TestResource.provider.effect( + Effect.gen(function* () { return { - string: news.string ?? id, - stringArray: news.stringArray ?? [], - stableString: id, - stableArray: [id], - }; - }), - update: Effect.fn(function* ({ id, news, output }) { - return { - string: news.string ?? id, - stringArray: news.stringArray ?? [], - stableString: id, - stableArray: [id], + diff: Effect.fn(function* ({ id, news, olds }) { + if (news.replaceString !== olds.replaceString) { + return { + action: "replace", + }; + } + return isUnknown(news.string) || + isUnknown(news.stringArray) || + news.string !== olds.string || + news.stringArray?.length !== olds.stringArray?.length || + !!news.stringArray !== !!olds.stringArray || + news.stringArray?.some(isUnknown) || + news.stringArray?.some((s, i) => s !== olds.stringArray?.[i]) + ? { + action: "update", + stables: ["stableString", "stableArray"], + } + : undefined; + }), + create: Effect.fn(function* ({ id, news }) { + const hooks = Option.getOrUndefined( + yield* Effect.serviceOption(TestResourceHooks), + ); + if (hooks?.create) { + yield* hooks.create(id, news); + } + return { + string: news.string ?? id, + stringArray: news.stringArray ?? [], + stableString: id, + stableArray: [id], + replaceString: news.replaceString, + }; + }), + update: Effect.fn(function* ({ id, news, output }) { + const hooks = Option.getOrUndefined( + yield* Effect.serviceOption(TestResourceHooks), + ); + if (hooks?.update) { + yield* hooks.update(id, news); + } + return { + string: news.string ?? id, + stringArray: news.stringArray ?? [], + stableString: id, + stableArray: [id], + replaceString: news.replaceString, + }; + }), + delete: Effect.fn(function* ({ id }) { + const hooks = Option.getOrUndefined( + yield* Effect.serviceOption(TestResourceHooks), + ); + if (hooks?.delete) { + yield* hooks.delete(id); + } + return; + }), }; }), - delete: Effect.fn(function* ({ output }) { - return; - }), -}); +); // Layers export const TestLayers = Layer.mergeAll( @@ -210,3 +251,6 @@ export const TestLayers = Layer.mergeAll( functionProvider, testResourceProvider, ); + +export const InMemoryTestLayers = () => + Layer.mergeAll(TestLayers, State.inMemory()); diff --git a/alchemy-effect/tsconfig.test.json b/alchemy-effect/tsconfig.test.json index f83b5b0..3955ab9 100644 --- a/alchemy-effect/tsconfig.test.json +++ b/alchemy-effect/tsconfig.test.json @@ -1,6 +1,10 @@ { "extends": "../tsconfig.base.json", - "include": ["package.json", "src", "test"], + "include": [ + "package.json", + "src", + "test" + ], "compilerOptions": { "composite": true, "noEmit": true, @@ -9,12 +13,17 @@ "module": "Preserve", "moduleResolution": "Bundler", "target": "ESNext", - "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] }, // ink "jsx": "react", - "lib": ["DOM", "DOM.Iterable", "ES2023"] + "lib": [ + "DOM", + "DOM.Iterable", + "ES2023" + ] } -} +} \ No newline at end of file diff --git a/bun.lock b/bun.lock index f773a61..c9e74dd 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "alchemy-effect": { "name": "alchemy-effect", - "version": "0.2.0", + "version": "0.3.0", "bin": { "alchemy-effect": "bin/alchemy-effect.js", }, @@ -113,7 +113,7 @@ "@types/node": "latest", "cloudflare": "^5.2.0", "effect": "^3.19.3", - "itty-aws": "^0.7.1", + "itty-aws": "^0.8.3", "typescript": "^5.9.3", "vite": "^7.1.9", "wrangler": "^4.42.2", @@ -885,7 +885,7 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "itty-aws": ["itty-aws@0.7.1", "", { "dependencies": { "aws4fetch": "^1.0.20", "itty-aws": ".", "pathe": "^2.0.3" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3", "@effect/platform": "^0.87.12", "@effect/platform-node": "^0.89.5", "effect": "^3" } }, "sha512-ha1rY2Qk6O99AWN8QIjxdYjw7NMXYx2EZf7g0r69ovCuecp2ErMyQABCRytLHvLNlwkMuVU56uDGL84EsWSTTQ=="], + "itty-aws": ["itty-aws@0.8.3", "", { "dependencies": { "aws4fetch": "^1.0.20" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3", "@effect/platform": "^0.87.12", "@effect/platform-node": "^0.89.5", "effect": "^3" } }, "sha512-2wwRvS9cMZ1ZfdIh3aWuR/FFt6dVe2SS/bW6+Jzf3mKEfJuBvzxK72h3K7TglsBW1Qfe0LWPKzaqkWoKNzJUQw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1233,6 +1233,8 @@ "@typescript/analyze-trace/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "alchemy-effect/@types/node": ["@types/node@25.0.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "c12/pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], @@ -1255,12 +1257,6 @@ "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "itty-aws/@effect/platform": ["@effect/platform@0.92.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.18.1" } }, "sha512-XXWCBVwyhaKZISN7aM1fv/3fWDGyxr84ObywnUrL8aHvJLoIeskWFAP/fqw3c5MFCrJ3ZV97RWLbv6JiBQugdg=="], - - "itty-aws/@effect/platform-node": ["@effect/platform-node@0.98.4", "", { "dependencies": { "@effect/platform-node-shared": "^0.51.6", "mime": "^3.0.0", "undici": "^7.10.0", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.50.6", "@effect/platform": "^0.92.1", "@effect/rpc": "^0.71.1", "@effect/sql": "^0.46.0", "effect": "^3.18.4" } }, "sha512-16tFBJtTP4hCjRd5p3SEQ7n+X3Sf/I0ovAhV1EWCJaro2gXV14orQ2cPtYFSaD1VdZmgEzbjnTN1CQBVgjRD/Q=="], - - "itty-aws/itty-aws": ["itty-aws@file:.", {}], - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1331,10 +1327,6 @@ "cloudflare/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "itty-aws/@effect/platform-node/@effect/cluster": ["@effect/cluster@0.50.6", "", { "peerDependencies": { "@effect/platform": "^0.92.1", "@effect/rpc": "^0.71.1", "@effect/sql": "^0.46.0", "@effect/workflow": "^0.11.5", "effect": "^3.18.4" } }, "sha512-JZTctj1SjMWZuG8RnkJCd1myAXmpDYEbLPURookJ6jXhpg8ZU29us49YtQa+MCUNmIr80qjeok/I1KVaQmJfrQ=="], - - "itty-aws/@effect/platform-node/@effect/platform-node-shared": ["@effect/platform-node-shared@0.51.6", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.7", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.50.6", "@effect/platform": "^0.92.1", "@effect/rpc": "^0.71.1", "@effect/sql": "^0.46.0", "effect": "^3.18.4" } }, "sha512-0Px0qpKR6vwoSuTbHPap9FOcUvt9MPLyYR6ZrskXJny4wZYHt9MfF0xR8a4MPbBaJ3UtGH9z74ijQw2cmzYxEg=="], - "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], diff --git a/package.json b/package.json index 852fbc3..6ac2082 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@types/node": "latest", "cloudflare": "^5.2.0", "effect": "^3.19.3", - "itty-aws": "^0.7.1", + "itty-aws": "^0.8.3", "typescript": "^5.9.3", "vite": "^7.1.9", "wrangler": "^4.42.2"