alchemy 2.0.0-beta.28 → 2.0.0-beta.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/bin/exec.js +98 -6
  2. package/bin/exec.js.map +1 -1
  3. package/lib/AWS/IAM/SAMLProvider.d.ts +2 -1
  4. package/lib/AWS/IAM/SAMLProvider.d.ts.map +1 -1
  5. package/lib/AWS/IAM/SAMLProvider.js +30 -9
  6. package/lib/AWS/IAM/SAMLProvider.js.map +1 -1
  7. package/lib/Apply.d.ts.map +1 -1
  8. package/lib/Apply.js +55 -11
  9. package/lib/Apply.js.map +1 -1
  10. package/lib/Cli/commands/bootstrap.d.ts +1 -1
  11. package/lib/Cli/commands/bootstrap.d.ts.map +1 -1
  12. package/lib/Cli/commands/bootstrap.js +2 -1
  13. package/lib/Cli/commands/bootstrap.js.map +1 -1
  14. package/lib/Cloudflare/Access.d.ts +19 -0
  15. package/lib/Cloudflare/Access.d.ts.map +1 -0
  16. package/lib/Cloudflare/Access.js +64 -0
  17. package/lib/Cloudflare/Access.js.map +1 -0
  18. package/lib/Cloudflare/AiGateway/AiGateway.d.ts +244 -0
  19. package/lib/Cloudflare/AiGateway/AiGateway.d.ts.map +1 -0
  20. package/lib/Cloudflare/AiGateway/AiGateway.js +269 -0
  21. package/lib/Cloudflare/AiGateway/AiGateway.js.map +1 -0
  22. package/lib/Cloudflare/AiGateway/AiGatewayBinding.d.ts +103 -0
  23. package/lib/Cloudflare/AiGateway/AiGatewayBinding.d.ts.map +1 -0
  24. package/lib/Cloudflare/AiGateway/AiGatewayBinding.js +99 -0
  25. package/lib/Cloudflare/AiGateway/AiGatewayBinding.js.map +1 -0
  26. package/lib/Cloudflare/AiGateway/index.d.ts +3 -0
  27. package/lib/Cloudflare/AiGateway/index.d.ts.map +1 -0
  28. package/lib/Cloudflare/AiGateway/index.js +3 -0
  29. package/lib/Cloudflare/AiGateway/index.js.map +1 -0
  30. package/lib/Cloudflare/EdgeSession.d.ts +2 -1
  31. package/lib/Cloudflare/EdgeSession.d.ts.map +1 -1
  32. package/lib/Cloudflare/EdgeSession.js +8 -4
  33. package/lib/Cloudflare/EdgeSession.js.map +1 -1
  34. package/lib/Cloudflare/Providers.d.ts +2 -1
  35. package/lib/Cloudflare/Providers.d.ts.map +1 -1
  36. package/lib/Cloudflare/Providers.js +5 -1
  37. package/lib/Cloudflare/Providers.js.map +1 -1
  38. package/lib/Cloudflare/StateStore/Api.d.ts +10 -0
  39. package/lib/Cloudflare/StateStore/Api.d.ts.map +1 -1
  40. package/lib/Cloudflare/StateStore/Api.js +12 -1
  41. package/lib/Cloudflare/StateStore/Api.js.map +1 -1
  42. package/lib/Cloudflare/StateStore/State.d.ts +4 -3
  43. package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
  44. package/lib/Cloudflare/StateStore/State.js +103 -13
  45. package/lib/Cloudflare/StateStore/State.js.map +1 -1
  46. package/lib/Cloudflare/Workers/InferEnv.d.ts +1 -1
  47. package/lib/Cloudflare/Workers/InferEnv.d.ts.map +1 -1
  48. package/lib/Cloudflare/Workers/Worker.d.ts +2 -1
  49. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  50. package/lib/Cloudflare/Workers/Worker.js +40 -31
  51. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  52. package/lib/Cloudflare/index.d.ts +1 -0
  53. package/lib/Cloudflare/index.d.ts.map +1 -1
  54. package/lib/Cloudflare/index.js +1 -0
  55. package/lib/Cloudflare/index.js.map +1 -1
  56. package/lib/Plan.d.ts +8 -0
  57. package/lib/Plan.d.ts.map +1 -1
  58. package/lib/Plan.js +12 -5
  59. package/lib/Plan.js.map +1 -1
  60. package/lib/State/HttpStateApi.d.ts +19 -1
  61. package/lib/State/HttpStateApi.d.ts.map +1 -1
  62. package/lib/State/HttpStateApi.js +18 -1
  63. package/lib/State/HttpStateApi.js.map +1 -1
  64. package/lib/Util/cached-function.d.ts +38 -0
  65. package/lib/Util/cached-function.d.ts.map +1 -0
  66. package/lib/Util/cached-function.js +53 -0
  67. package/lib/Util/cached-function.js.map +1 -0
  68. package/lib/Util/scc.d.ts +12 -0
  69. package/lib/Util/scc.d.ts.map +1 -0
  70. package/lib/Util/scc.js +81 -0
  71. package/lib/Util/scc.js.map +1 -0
  72. package/lib/tsconfig.test.tsbuildinfo +1 -1
  73. package/package.json +2 -2
  74. package/src/AWS/IAM/SAMLProvider.ts +47 -18
  75. package/src/Apply.ts +72 -8
  76. package/src/Cli/commands/bootstrap.ts +2 -0
  77. package/src/Cloudflare/Access.ts +110 -0
  78. package/src/Cloudflare/AiGateway/AiGateway.ts +526 -0
  79. package/src/Cloudflare/AiGateway/AiGatewayBinding.ts +190 -0
  80. package/src/Cloudflare/AiGateway/index.ts +2 -0
  81. package/src/Cloudflare/EdgeSession.ts +15 -5
  82. package/src/Cloudflare/Providers.ts +7 -0
  83. package/src/Cloudflare/StateStore/Api.ts +18 -0
  84. package/src/Cloudflare/StateStore/State.ts +130 -12
  85. package/src/Cloudflare/Workers/InferEnv.ts +7 -5
  86. package/src/Cloudflare/Workers/Worker.ts +44 -33
  87. package/src/Cloudflare/index.ts +1 -0
  88. package/src/Plan.ts +21 -5
  89. package/src/State/HttpStateApi.ts +22 -1
  90. package/src/Util/cached-function.ts +75 -0
  91. package/src/Util/scc.ts +89 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alchemy",
3
- "version": "2.0.0-beta.28",
3
+ "version": "2.0.0-beta.29",
4
4
  "homepage": "https://alchemy.run",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sam Goodwin <sam@alchemy.run>",
@@ -261,7 +261,7 @@
261
261
  "@distilled.cloud/axiom": "^0.15.2",
262
262
  "@distilled.cloud/cloudflare": "^0.15.2",
263
263
  "@distilled.cloud/cloudflare-rolldown-plugin": "0.3.0",
264
- "@distilled.cloud/cloudflare-runtime": "0.1.1",
264
+ "@distilled.cloud/cloudflare-runtime": "0.1.2",
265
265
  "@distilled.cloud/cloudflare-vite-plugin": "0.2.0",
266
266
  "@distilled.cloud/core": "^0.15.2",
267
267
  "@effect/vitest": ">=4.0.0-beta.58 || >=4.0.0",
@@ -1,11 +1,12 @@
1
1
  import * as iam from "@distilled.cloud/aws/iam";
2
2
  import * as Effect from "effect/Effect";
3
3
  import * as Redacted from "effect/Redacted";
4
+ import { AWSEnvironment } from "../Environment.ts";
4
5
  import { isResolved } from "../../Diff.ts";
5
6
  import * as Provider from "../../Provider.ts";
6
7
  import { Resource } from "../../Resource.ts";
7
8
  import type { Providers } from "../Providers.ts";
8
- import { diffTags } from "../../Tags.ts";
9
+ import { createInternalTags, diffTags, hasTags } from "../../Tags.ts";
9
10
  import { toTagRecord, unwrapRedactedString } from "./common.ts";
10
11
 
11
12
  export interface SAMLProviderProps {
@@ -100,19 +101,44 @@ export const SAMLProviderProvider = () =>
100
101
  tags: toTagRecord(tags.Tags),
101
102
  };
102
103
  }),
103
- create: Effect.fn(function* ({ news, session }) {
104
- const response = yield* iam.createSAMLProvider({
105
- Name: news.name,
106
- SAMLMetadataDocument: news.samlMetadataDocument,
107
- AssertionEncryptionMode: news.assertionEncryptionMode,
108
- AddPrivateKey: news.addPrivateKey
109
- ? unwrapRedactedString(news.addPrivateKey)
110
- : undefined,
111
- Tags: Object.entries(news.tags ?? {}).map(([Key, Value]) => ({
112
- Key,
113
- Value,
114
- })),
115
- });
104
+ create: Effect.fn(function* ({ id, news, session }) {
105
+ const internalTags = yield* createInternalTags(id);
106
+ const tags = {
107
+ ...internalTags,
108
+ ...news.tags,
109
+ };
110
+ const response = yield* iam
111
+ .createSAMLProvider({
112
+ Name: news.name,
113
+ SAMLMetadataDocument: news.samlMetadataDocument,
114
+ AssertionEncryptionMode: news.assertionEncryptionMode,
115
+ AddPrivateKey: news.addPrivateKey
116
+ ? unwrapRedactedString(news.addPrivateKey)
117
+ : undefined,
118
+ Tags: Object.entries(tags).map(([Key, Value]) => ({
119
+ Key,
120
+ Value,
121
+ })),
122
+ })
123
+ .pipe(
124
+ Effect.catchTag("EntityAlreadyExistsException", () =>
125
+ Effect.gen(function* () {
126
+ const accountId = (yield* AWSEnvironment).accountId;
127
+ const existingArn = `arn:aws:iam::${accountId}:saml-provider/${news.name}`;
128
+ const existingTags = yield* iam.listSAMLProviderTags({
129
+ SAMLProviderArn: existingArn,
130
+ });
131
+ if (!hasTags(internalTags, existingTags.Tags)) {
132
+ return yield* Effect.fail(
133
+ new Error(
134
+ `SAML provider '${news.name}' already exists and is not managed by alchemy`,
135
+ ),
136
+ );
137
+ }
138
+ return { SAMLProviderArn: existingArn };
139
+ }),
140
+ ),
141
+ );
116
142
  const samlProviderArn = response.SAMLProviderArn ?? news.name;
117
143
  yield* session.note(samlProviderArn);
118
144
  return {
@@ -121,10 +147,10 @@ export const SAMLProviderProvider = () =>
121
147
  samlProviderUUID: undefined,
122
148
  samlMetadataDocument: news.samlMetadataDocument,
123
149
  assertionEncryptionMode: news.assertionEncryptionMode,
124
- tags: news.tags ?? {},
150
+ tags,
125
151
  };
126
152
  }),
127
- update: Effect.fn(function* ({ news, olds, output, session }) {
153
+ update: Effect.fn(function* ({ id, news, olds, output, session }) {
128
154
  yield* iam.updateSAMLProvider({
129
155
  SAMLProviderArn: output.samlProviderArn,
130
156
  SAMLMetadataDocument:
@@ -136,7 +162,10 @@ export const SAMLProviderProvider = () =>
136
162
  ? unwrapRedactedString(news.addPrivateKey)
137
163
  : undefined,
138
164
  });
139
- const { removed, upsert } = diffTags(olds.tags ?? {}, news.tags ?? {});
165
+ const internalTags = yield* createInternalTags(id);
166
+ const oldTags = { ...internalTags, ...(olds.tags ?? {}) };
167
+ const newTags = { ...internalTags, ...(news.tags ?? {}) };
168
+ const { removed, upsert } = diffTags(oldTags, newTags);
140
169
  if (upsert.length > 0) {
141
170
  yield* iam.tagSAMLProvider({
142
171
  SAMLProviderArn: output.samlProviderArn,
@@ -156,7 +185,7 @@ export const SAMLProviderProvider = () =>
156
185
  samlProviderUUID: output.samlProviderUUID,
157
186
  samlMetadataDocument: news.samlMetadataDocument,
158
187
  assertionEncryptionMode: news.assertionEncryptionMode,
159
- tags: news.tags ?? {},
188
+ tags: newTags,
160
189
  };
161
190
  }),
162
191
  delete: Effect.fn(function* ({ output }) {
package/src/Apply.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as Cause from "effect/Cause";
1
2
  import * as Deferred from "effect/Deferred";
2
3
  import * as Effect from "effect/Effect";
3
4
  import * as Option from "effect/Option";
@@ -246,6 +247,8 @@ const executePlan = Effect.fnUntraced(function* (
246
247
  { concurrency: "unbounded" },
247
248
  );
248
249
 
250
+ const failures: LifecycleFailure[] = [];
251
+
249
252
  yield* Effect.all(
250
253
  Object.entries(plan.resources).map(([fqn, node]) =>
251
254
  executeNode(
@@ -260,12 +263,30 @@ const executePlan = Effect.fnUntraced(function* (
260
263
  stage,
261
264
  getOutputs,
262
265
  waitForDeps,
266
+ failures,
267
+ plan.cycleMembers.has(fqn),
263
268
  ),
264
269
  ),
265
270
  { concurrency: "unbounded" },
266
271
  );
272
+
273
+ if (failures.length > 0) {
274
+ // Aggregate every collected lifecycle failure into a single parallel Cause
275
+ // so the apply ends with one combined error containing every distinct
276
+ // failure / defect that occurred across the concurrent fibers.
277
+ yield* Effect.failCause(
278
+ failures.map((f) => f.cause).reduce(Cause.combine),
279
+ );
280
+ }
267
281
  });
268
282
 
283
+ interface LifecycleFailure {
284
+ fqn: string;
285
+ logicalId: string;
286
+ type: string;
287
+ cause: Cause.Cause<unknown>;
288
+ }
289
+
269
290
  const executeNode = (
270
291
  fqn: string,
271
292
  node: Apply,
@@ -292,6 +313,8 @@ const executeNode = (
292
313
  stage: string,
293
314
  getOutputs: () => Record<string, any>,
294
315
  waitForDeps: (fqns: string[]) => Effect.Effect<void[], never, never>,
316
+ failures: LifecycleFailure[],
317
+ inCycle: boolean,
295
318
  ): Effect.Effect<void, never, never> =>
296
319
  Effect.gen(function* () {
297
320
  const logicalId = node.resource.LogicalId;
@@ -548,14 +571,26 @@ const executeNode = (
548
571
 
549
572
  // ── update ──
550
573
  if (node.action === "update") {
551
- // Updates always begin by exposing the currently live output so downstream
552
- // resources can continue to resolve references during the update pass.
553
- yield* storeAndSignal({
554
- output: node.state.attr,
555
- props: node.state.props,
556
- bindings: node.state.bindings ?? [],
557
- instanceId,
558
- });
574
+ // Cycle members publish their previous live attr *before* waiting on
575
+ // upstreams so the SCC can converge — peers in the cycle would
576
+ // otherwise deadlock waiting on each other. Phase 3 (`converge`)
577
+ // re-runs each peer's update against fresh outputs once everyone
578
+ // has settled.
579
+ //
580
+ // Linear (DAG) update nodes skip this entirely and simply wait for
581
+ // fresh upstream outputs, mirroring the create flow. This is the
582
+ // important property: a downstream of a non-cycle update never
583
+ // observes the upstream's stale attr, which prevents wasted/
584
+ // destructive intermediate updates (e.g. a Worker deploying with
585
+ // stale Build assets).
586
+ if (inCycle) {
587
+ yield* storeAndSignal({
588
+ output: node.state.attr,
589
+ props: node.state.props,
590
+ bindings: node.state.bindings ?? [],
591
+ instanceId,
592
+ });
593
+ }
559
594
 
560
595
  yield* waitForDeps(allUpstreamFqns());
561
596
  const outputs = getOutputs();
@@ -657,6 +692,10 @@ const executeNode = (
657
692
  bindings: bindingOutputs,
658
693
  instanceId,
659
694
  };
695
+ // Signal here for the linear (non-cycle) path. For in-cycle updates
696
+ // the deferred has already been resolved by the early `storeAndSignal`
697
+ // above and `signalReady` is a no-op the second time.
698
+ yield* signalReady;
660
699
 
661
700
  yield* markTerminal("updated");
662
701
  return;
@@ -830,6 +869,31 @@ const executeNode = (
830
869
  return yield* Effect.die(`Unknown action: ${node.action}`);
831
870
  });
832
871
  }).pipe(
872
+ Effect.catchCause((cause) =>
873
+ // Record the failure, propagate it to any downstream resources waiting on
874
+ // our Deferred (so their waitForDeps short-circuits instead of deadlocking),
875
+ // emit a "fail" status to the session, and resolve to void so Effect.all
876
+ // does not interrupt sibling fibers. The aggregated cause is raised at
877
+ // the end of executePlan.
878
+ Effect.gen(function* () {
879
+ failures.push({
880
+ fqn,
881
+ logicalId: node.resource.LogicalId,
882
+ type: node.resource.Type,
883
+ cause,
884
+ });
885
+ yield* Deferred.failCause(
886
+ ready[fqn],
887
+ cause as Cause.Cause<never>,
888
+ );
889
+ yield* session.emit({
890
+ kind: "status-change",
891
+ id: node.resource.LogicalId,
892
+ type: node.resource.Type,
893
+ status: "fail",
894
+ });
895
+ }),
896
+ ),
833
897
  Effect.withSpan("apply.resource", {
834
898
  attributes: {
835
899
  "alchemy.resource.fqn": fqn,
@@ -20,6 +20,7 @@ import * as AWSEnvironment from "../../AWS/Environment";
20
20
  import * as AWSRegion from "../../AWS/Region";
21
21
  import { AuthProviders } from "../../Auth/AuthProvider";
22
22
  import { withProfileOverride } from "../../Auth/Profile";
23
+ import * as CloudflareAccess from "../../Cloudflare/Access.ts";
23
24
  import { CloudflareAuth } from "../../Cloudflare/Auth/AuthProvider";
24
25
  import * as CloudflareEnvironment from "../../Cloudflare/CloudflareEnvironment";
25
26
  import * as CloudflareCredentials from "../../Cloudflare/Credentials";
@@ -190,6 +191,7 @@ const bootstrapCloudflareCommand = Command.make(
190
191
  Layer.mergeAll(
191
192
  CloudflareCredentials.fromAuthProvider(),
192
193
  CloudflareEnvironment.fromProfile(),
194
+ CloudflareAccess.AccessLive,
193
195
  ),
194
196
  authLayer,
195
197
  );
@@ -0,0 +1,110 @@
1
+ import * as Config from "effect/Config";
2
+ import * as Context from "effect/Context";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Layer from "effect/Layer";
5
+ import * as Schema from "effect/Schema";
6
+ import * as Stream from "effect/Stream";
7
+ import * as ChildProcess from "effect/unstable/process/ChildProcess";
8
+ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
9
+ import { cachedFunction } from "../Util/cached-function.ts";
10
+
11
+ export class AccessError extends Schema.TaggedErrorClass<AccessError>()(
12
+ "AccessError",
13
+ {
14
+ message: Schema.String,
15
+ cause: Schema.optional(Schema.Defect),
16
+ },
17
+ ) {}
18
+
19
+ export class Access extends Context.Service<
20
+ Access,
21
+ {
22
+ readonly getAccessHeaders: (
23
+ domain: string,
24
+ ) => Effect.Effect<Record<string, string>, AccessError>;
25
+ }
26
+ >()("alchemy/Cloudflare/Access") {}
27
+
28
+ export const AccessLive = Layer.effect(
29
+ Access,
30
+ Effect.gen(function* () {
31
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
32
+ const domainUsesAccess = yield* cachedFunction((domain: string) =>
33
+ Effect.promise((signal) =>
34
+ fetch(`https://${domain}`, { redirect: "manual", signal }),
35
+ ).pipe(
36
+ Effect.map(
37
+ (response) =>
38
+ response.status === 302 &&
39
+ (response.headers
40
+ .get("location")
41
+ ?.includes("cloudflareaccess.com") ??
42
+ false),
43
+ ),
44
+ Effect.timeout(1000),
45
+ Effect.catch(() => Effect.succeed(false)),
46
+ ),
47
+ );
48
+ const login = (domain: string) =>
49
+ ChildProcess.make("cloudflared", ["access", "login", domain]).pipe(
50
+ spawner.spawn,
51
+ Effect.flatMap((process) => Stream.runCollect(process.stdout)),
52
+ Effect.mapError(
53
+ (error) =>
54
+ new AccessError({
55
+ message:
56
+ `The domain "${domain}" uses Cloudflare Access, but \`cloudflared\` is not installed. ` +
57
+ `Please install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation.`,
58
+ cause: error,
59
+ }),
60
+ ),
61
+ Effect.flatMap((stdout) => {
62
+ const matches = stdout
63
+ .toString()
64
+ .match(/fetched your token:\n\n(.*)/m);
65
+ return matches && matches.length >= 2
66
+ ? Effect.succeed({ Cookie: `CF_Authorization=${matches[1]}` })
67
+ : Effect.fail(
68
+ new AccessError({
69
+ message: "Failed to authenticate with Cloudflare Access",
70
+ }),
71
+ );
72
+ }),
73
+ Effect.scoped,
74
+ );
75
+
76
+ const getEnv = (name: string) =>
77
+ Config.string(name)
78
+ .asEffect()
79
+ .pipe(Effect.catchTag("ConfigError", () => Effect.succeed(undefined)));
80
+
81
+ return Access.of({
82
+ getAccessHeaders: Effect.fn(function* (domain) {
83
+ if (!(yield* domainUsesAccess(domain))) {
84
+ return {};
85
+ }
86
+ const clientId = yield* getEnv("CLOUDFLARE_ACCESS_CLIENT_ID");
87
+ const clientSecret = yield* getEnv("CLOUDFLARE_ACCESS_CLIENT_SECRET");
88
+ if (clientId && clientSecret) {
89
+ return {
90
+ "CF-Access-Client-Id": clientId,
91
+ "CF-Access-Client-Secret": clientSecret,
92
+ } as Record<string, string>;
93
+ }
94
+
95
+ if (clientId !== undefined || clientSecret !== undefined) {
96
+ yield* Effect.logWarning(
97
+ "Both CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET must be set to use Access Service Token authentication. " +
98
+ `Only ${
99
+ clientId !== undefined
100
+ ? "CLOUDFLARE_ACCESS_CLIENT_ID"
101
+ : "CLOUDFLARE_ACCESS_CLIENT_SECRET"
102
+ } was found.`,
103
+ );
104
+ }
105
+
106
+ return yield* login(domain);
107
+ }),
108
+ });
109
+ }),
110
+ );