@alchemy.run/fly 2.0.0-beta.85 → 2.0.0-beta.86

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 (271) hide show
  1. package/lib/App.d.ts +26 -4
  2. package/lib/App.d.ts.map +1 -1
  3. package/lib/App.js +25 -2
  4. package/lib/App.js.map +1 -1
  5. package/lib/AuthProvider.d.ts +1 -1
  6. package/lib/AuthProvider.d.ts.map +1 -1
  7. package/lib/AuthProvider.js.map +1 -1
  8. package/lib/BindService.d.ts +7 -7
  9. package/lib/BindService.d.ts.map +1 -1
  10. package/lib/BindService.js +20 -27
  11. package/lib/BindService.js.map +1 -1
  12. package/lib/Bootstrap.d.ts.map +1 -1
  13. package/lib/Bootstrap.js +2 -1
  14. package/lib/Bootstrap.js.map +1 -1
  15. package/lib/Bucket.d.ts +36 -2
  16. package/lib/Bucket.d.ts.map +1 -1
  17. package/lib/Bucket.js +181 -28
  18. package/lib/Bucket.js.map +1 -1
  19. package/lib/BucketBinding.d.ts.map +1 -1
  20. package/lib/BucketBinding.js.map +1 -1
  21. package/lib/Catalog.d.ts.map +1 -1
  22. package/lib/Catalog.js.map +1 -1
  23. package/lib/Certificate.d.ts +25 -1
  24. package/lib/Certificate.d.ts.map +1 -1
  25. package/lib/Certificate.js +18 -0
  26. package/lib/Certificate.js.map +1 -1
  27. package/lib/Checkpoint.d.ts.map +1 -1
  28. package/lib/Checkpoint.js.map +1 -1
  29. package/lib/CheckpointHttp.d.ts.map +1 -1
  30. package/lib/CheckpointHttp.js.map +1 -1
  31. package/lib/ConnectPostgres.d.ts.map +1 -1
  32. package/lib/ConnectPostgres.js.map +1 -1
  33. package/lib/ConnectPostgresHttp.d.ts.map +1 -1
  34. package/lib/ConnectPostgresHttp.js.map +1 -1
  35. package/lib/Contributions.d.ts +1 -1
  36. package/lib/Contributions.d.ts.map +1 -1
  37. package/lib/Contributions.js.map +1 -1
  38. package/lib/Credentials.d.ts +4 -3
  39. package/lib/Credentials.d.ts.map +1 -1
  40. package/lib/Credentials.js +9 -5
  41. package/lib/Credentials.js.map +1 -1
  42. package/lib/Decrypt.d.ts.map +1 -1
  43. package/lib/Decrypt.js.map +1 -1
  44. package/lib/DecryptHttp.d.ts.map +1 -1
  45. package/lib/DecryptHttp.js.map +1 -1
  46. package/lib/DeleteObject.d.ts.map +1 -1
  47. package/lib/DeleteObject.js.map +1 -1
  48. package/lib/DeleteObjectHttp.d.ts.map +1 -1
  49. package/lib/DeleteObjectHttp.js.map +1 -1
  50. package/lib/Deployment.d.ts.map +1 -1
  51. package/lib/Deployment.js.map +1 -1
  52. package/lib/DeploymentImages.d.ts.map +1 -1
  53. package/lib/DeploymentImages.js.map +1 -1
  54. package/lib/DeploymentState.d.ts.map +1 -1
  55. package/lib/DeploymentState.js.map +1 -1
  56. package/lib/Encrypt.d.ts.map +1 -1
  57. package/lib/Encrypt.js.map +1 -1
  58. package/lib/EncryptHttp.d.ts.map +1 -1
  59. package/lib/EncryptHttp.js.map +1 -1
  60. package/lib/Environment.d.ts.map +1 -1
  61. package/lib/Environment.js.map +1 -1
  62. package/lib/Errors.d.ts.map +1 -1
  63. package/lib/Errors.js.map +1 -1
  64. package/lib/Exec.d.ts.map +1 -1
  65. package/lib/Exec.js.map +1 -1
  66. package/lib/ExecHttp.d.ts.map +1 -1
  67. package/lib/ExecHttp.js.map +1 -1
  68. package/lib/GetObject.d.ts.map +1 -1
  69. package/lib/GetObject.js.map +1 -1
  70. package/lib/GetObjectHttp.d.ts.map +1 -1
  71. package/lib/GetObjectHttp.js.map +1 -1
  72. package/lib/GetSecret.d.ts.map +1 -1
  73. package/lib/GetSecret.js.map +1 -1
  74. package/lib/GetSecretHttp.d.ts.map +1 -1
  75. package/lib/GetSecretHttp.js.map +1 -1
  76. package/lib/HeadObject.d.ts.map +1 -1
  77. package/lib/HeadObject.js.map +1 -1
  78. package/lib/HeadObjectHttp.d.ts.map +1 -1
  79. package/lib/HeadObjectHttp.js.map +1 -1
  80. package/lib/IpAssignment.d.ts +24 -1
  81. package/lib/IpAssignment.d.ts.map +1 -1
  82. package/lib/IpAssignment.js +17 -0
  83. package/lib/IpAssignment.js.map +1 -1
  84. package/lib/ListObjectsV2.d.ts.map +1 -1
  85. package/lib/ListObjectsV2.js.map +1 -1
  86. package/lib/ListObjectsV2Http.d.ts.map +1 -1
  87. package/lib/ListObjectsV2Http.js.map +1 -1
  88. package/lib/ListSecrets.d.ts.map +1 -1
  89. package/lib/ListSecrets.js.map +1 -1
  90. package/lib/ListSecretsHttp.d.ts.map +1 -1
  91. package/lib/ListSecretsHttp.js.map +1 -1
  92. package/lib/Machine.d.ts +26 -1
  93. package/lib/Machine.d.ts.map +1 -1
  94. package/lib/Machine.js +16 -1
  95. package/lib/Machine.js.map +1 -1
  96. package/lib/MachineContainers.d.ts.map +1 -1
  97. package/lib/MachineContainers.js.map +1 -1
  98. package/lib/Metadata.d.ts.map +1 -1
  99. package/lib/Metadata.js.map +1 -1
  100. package/lib/MountVolume.d.ts +7 -2
  101. package/lib/MountVolume.d.ts.map +1 -1
  102. package/lib/MountVolume.js.map +1 -1
  103. package/lib/Postgres.d.ts +33 -1
  104. package/lib/Postgres.d.ts.map +1 -1
  105. package/lib/Postgres.js +20 -0
  106. package/lib/Postgres.js.map +1 -1
  107. package/lib/PostgresMigrations.d.ts.map +1 -1
  108. package/lib/PostgresMigrations.js.map +1 -1
  109. package/lib/Providers.d.ts +1 -1
  110. package/lib/Providers.d.ts.map +1 -1
  111. package/lib/Providers.js.map +1 -1
  112. package/lib/PutObject.d.ts.map +1 -1
  113. package/lib/PutObject.js.map +1 -1
  114. package/lib/PutObjectHttp.d.ts.map +1 -1
  115. package/lib/PutObjectHttp.js.map +1 -1
  116. package/lib/ReadRedis.d.ts.map +1 -1
  117. package/lib/ReadRedis.js.map +1 -1
  118. package/lib/ReadRedisHttp.d.ts.map +1 -1
  119. package/lib/ReadRedisHttp.js.map +1 -1
  120. package/lib/ReadWriteRedis.d.ts.map +1 -1
  121. package/lib/ReadWriteRedis.js.map +1 -1
  122. package/lib/ReadWriteRedisHttp.d.ts.map +1 -1
  123. package/lib/ReadWriteRedisHttp.js.map +1 -1
  124. package/lib/Redis.d.ts +21 -1
  125. package/lib/Redis.d.ts.map +1 -1
  126. package/lib/Redis.js +28 -0
  127. package/lib/Redis.js.map +1 -1
  128. package/lib/RedisBinding.d.ts.map +1 -1
  129. package/lib/RedisBinding.js.map +1 -1
  130. package/lib/RedisHttp.d.ts.map +1 -1
  131. package/lib/RedisHttp.js.map +1 -1
  132. package/lib/Region.d.ts.map +1 -1
  133. package/lib/Region.js.map +1 -1
  134. package/lib/Secret.d.ts +22 -1
  135. package/lib/Secret.d.ts.map +1 -1
  136. package/lib/Secret.js +15 -0
  137. package/lib/Secret.js.map +1 -1
  138. package/lib/SecretHttp.d.ts.map +1 -1
  139. package/lib/SecretHttp.js.map +1 -1
  140. package/lib/SecretKey.d.ts +22 -1
  141. package/lib/SecretKey.d.ts.map +1 -1
  142. package/lib/SecretKey.js +15 -0
  143. package/lib/SecretKey.js.map +1 -1
  144. package/lib/SecretKeyHttp.d.ts.map +1 -1
  145. package/lib/SecretKeyHttp.js.map +1 -1
  146. package/lib/Service.d.ts +96 -21
  147. package/lib/Service.d.ts.map +1 -1
  148. package/lib/Service.js +251 -37
  149. package/lib/Service.js.map +1 -1
  150. package/lib/Sign.d.ts.map +1 -1
  151. package/lib/Sign.js.map +1 -1
  152. package/lib/SignHttp.d.ts.map +1 -1
  153. package/lib/SignHttp.js.map +1 -1
  154. package/lib/Sprite.d.ts +22 -3
  155. package/lib/Sprite.d.ts.map +1 -1
  156. package/lib/Sprite.js +15 -0
  157. package/lib/Sprite.js.map +1 -1
  158. package/lib/SpriteHttp.d.ts.map +1 -1
  159. package/lib/SpriteHttp.js.map +1 -1
  160. package/lib/Verify.d.ts.map +1 -1
  161. package/lib/Verify.js.map +1 -1
  162. package/lib/VerifyHttp.d.ts.map +1 -1
  163. package/lib/VerifyHttp.js.map +1 -1
  164. package/lib/Volume.d.ts +3 -3
  165. package/lib/Volume.d.ts.map +1 -1
  166. package/lib/Volume.js +17 -1
  167. package/lib/Volume.js.map +1 -1
  168. package/lib/VolumeSnapshot.d.ts +32 -1
  169. package/lib/VolumeSnapshot.d.ts.map +1 -1
  170. package/lib/VolumeSnapshot.js +23 -0
  171. package/lib/VolumeSnapshot.js.map +1 -1
  172. package/lib/Website/AssetDeployment.d.ts.map +1 -1
  173. package/lib/Website/AssetDeployment.js.map +1 -1
  174. package/lib/Website/Astro.d.ts.map +1 -1
  175. package/lib/Website/Astro.js.map +1 -1
  176. package/lib/Website/Foldkit.d.ts.map +1 -1
  177. package/lib/Website/Foldkit.js.map +1 -1
  178. package/lib/Website/FrameworkSite.d.ts.map +1 -1
  179. package/lib/Website/FrameworkSite.js.map +1 -1
  180. package/lib/Website/Nextjs.d.ts.map +1 -1
  181. package/lib/Website/Nextjs.js.map +1 -1
  182. package/lib/Website/Nuxt.d.ts.map +1 -1
  183. package/lib/Website/Nuxt.js.map +1 -1
  184. package/lib/Website/Octane.d.ts.map +1 -1
  185. package/lib/Website/Octane.js.map +1 -1
  186. package/lib/Website/ReactRouter.d.ts.map +1 -1
  187. package/lib/Website/ReactRouter.js.map +1 -1
  188. package/lib/Website/SolidStart.d.ts.map +1 -1
  189. package/lib/Website/SolidStart.js.map +1 -1
  190. package/lib/Website/StaticSite.d.ts.map +1 -1
  191. package/lib/Website/StaticSite.js.map +1 -1
  192. package/lib/Website/SvelteKit.d.ts.map +1 -1
  193. package/lib/Website/SvelteKit.js.map +1 -1
  194. package/lib/Website/TanStackStart.d.ts.map +1 -1
  195. package/lib/Website/TanStackStart.js.map +1 -1
  196. package/lib/Website/Vinext.d.ts.map +1 -1
  197. package/lib/Website/Vinext.js.map +1 -1
  198. package/lib/Website/Vite.d.ts.map +1 -1
  199. package/lib/Website/Vite.js.map +1 -1
  200. package/lib/Website/Vocs.d.ts.map +1 -1
  201. package/lib/Website/Vocs.js.map +1 -1
  202. package/lib/Website/Waku.d.ts.map +1 -1
  203. package/lib/Website/Waku.js.map +1 -1
  204. package/lib/Website/index.d.ts.map +1 -1
  205. package/lib/Website/index.js.map +1 -1
  206. package/lib/WriteCertificates.d.ts.map +1 -1
  207. package/lib/WriteCertificates.js.map +1 -1
  208. package/lib/WriteCertificatesHttp.d.ts.map +1 -1
  209. package/lib/WriteCertificatesHttp.js.map +1 -1
  210. package/lib/WriteRedis.d.ts.map +1 -1
  211. package/lib/WriteRedis.js.map +1 -1
  212. package/lib/WriteRedisHttp.d.ts.map +1 -1
  213. package/lib/WriteRedisHttp.js.map +1 -1
  214. package/lib/WriteSecret.d.ts.map +1 -1
  215. package/lib/WriteSecret.js.map +1 -1
  216. package/lib/WriteSecretHttp.d.ts.map +1 -1
  217. package/lib/WriteSecretHttp.js.map +1 -1
  218. package/lib/bluegreen.d.ts +5 -4
  219. package/lib/bluegreen.d.ts.map +1 -1
  220. package/lib/bluegreen.js +1 -1
  221. package/lib/bluegreen.js.map +1 -1
  222. package/lib/hosted.d.ts +16 -0
  223. package/lib/hosted.d.ts.map +1 -1
  224. package/lib/hosted.js +101 -11
  225. package/lib/hosted.js.map +1 -1
  226. package/lib/index.d.ts.map +1 -1
  227. package/lib/index.js.map +1 -1
  228. package/lib/internal/Oidc.d.ts +15 -0
  229. package/lib/internal/Oidc.d.ts.map +1 -0
  230. package/lib/internal/Oidc.js +73 -0
  231. package/lib/internal/Oidc.js.map +1 -0
  232. package/lib/internal/RemoteBuilder.d.ts.map +1 -1
  233. package/lib/internal/RemoteBuilder.js.map +1 -1
  234. package/lib/leases.d.ts +6 -2
  235. package/lib/leases.d.ts.map +1 -1
  236. package/lib/leases.js +25 -5
  237. package/lib/leases.js.map +1 -1
  238. package/lib/ports.d.ts.map +1 -1
  239. package/lib/ports.js.map +1 -1
  240. package/lib/replicas.d.ts +38 -26
  241. package/lib/replicas.d.ts.map +1 -1
  242. package/lib/replicas.js +57 -2
  243. package/lib/replicas.js.map +1 -1
  244. package/lib/rpc.d.ts +14 -12
  245. package/lib/rpc.d.ts.map +1 -1
  246. package/lib/rpc.js +38 -40
  247. package/lib/rpc.js.map +1 -1
  248. package/package.json +15 -15
  249. package/src/App.ts +36 -3
  250. package/src/BindService.ts +27 -34
  251. package/src/Bootstrap.ts +2 -0
  252. package/src/Bucket.ts +264 -33
  253. package/src/Certificate.ts +32 -1
  254. package/src/Credentials.ts +9 -5
  255. package/src/IpAssignment.ts +31 -1
  256. package/src/Machine.ts +34 -1
  257. package/src/MountVolume.ts +5 -2
  258. package/src/Postgres.ts +43 -1
  259. package/src/Redis.ts +43 -1
  260. package/src/Secret.ts +29 -1
  261. package/src/SecretKey.ts +29 -1
  262. package/src/Service.ts +389 -70
  263. package/src/Sprite.ts +34 -8
  264. package/src/Volume.ts +25 -1
  265. package/src/VolumeSnapshot.ts +39 -1
  266. package/src/bluegreen.ts +4 -1
  267. package/src/hosted.ts +118 -13
  268. package/src/internal/Oidc.ts +99 -0
  269. package/src/leases.ts +35 -7
  270. package/src/replicas.ts +63 -2
  271. package/src/rpc.ts +37 -40
package/src/Sprite.ts CHANGED
@@ -5,7 +5,7 @@ import * as Bundle from "alchemy/Bundle/Bundle";
5
5
  import { isResolved } from "alchemy/Diff";
6
6
  import { Platform, type Main, type PlatformProps } from "alchemy/Platform";
7
7
  import * as Provider from "alchemy/Provider";
8
- import type { Resource, ResourceBinding } from "alchemy/Resource";
8
+ import type { LookupMethod, Resource, ResourceBinding } from "alchemy/Resource";
9
9
  import type { ServerHost } from "alchemy/Runtime/Process";
10
10
  import { Stack } from "alchemy/Stack";
11
11
  import { createInternalTags } from "alchemy/Tags";
@@ -119,9 +119,19 @@ export type Sprite = Resource<
119
119
  };
120
120
  },
121
121
  SpriteBinding,
122
- Providers
122
+ Providers,
123
+ SpriteLookup
123
124
  >;
124
125
 
126
+ /**
127
+ * Props for {@link Sprite.lookup}: find a Sprite this stack doesn't
128
+ * manage by its name.
129
+ */
130
+ export interface SpriteLookup {
131
+ /** Sprite name (unique per org). */
132
+ name: string;
133
+ }
134
+
125
135
  export const isSprite = (value: unknown): value is Sprite =>
126
136
  typeof value === "object" && value !== null && (value as { Type?: string }).Type === "Fly.Sprite";
127
137
 
@@ -307,15 +317,24 @@ export type SpriteRuntimeContext = FlyHostRuntimeContext;
307
317
  * ) {}
308
318
  * ```
309
319
  *
320
+ * ### Looking up an existing Sprite
321
+ * `Sprite.lookup` references a Sprite Alchemy doesn't manage (created with
322
+ * `sprite create` or by another stack). It is read during plan/deploy,
323
+ * never created, updated or deleted. `code.hash` is empty: the Sprite's
324
+ * program isn't Alchemy's.
325
+ *
326
+ * **Example:** Reference an existing Sprite
327
+ * ```typescript
328
+ * const box = Fly.Sprite.lookup({ name: "my-box" });
329
+ * ```
330
+ *
310
331
  * @resource
311
332
  * @product Sprite
312
333
  */
313
- export const Sprite: Platform<Sprite, SpriteServices, SpriteShape, SpriteRuntimeContext> = Platform(
314
- "Fly.Sprite",
315
- {
316
- createRuntimeContext: createFlyHostRuntimeContext("Fly.Sprite"),
317
- },
318
- );
334
+ export const Sprite: Platform<Sprite, SpriteServices, SpriteShape, SpriteRuntimeContext> &
335
+ LookupMethod<Sprite> = Platform("Fly.Sprite", {
336
+ createRuntimeContext: createFlyHostRuntimeContext("Fly.Sprite"),
337
+ });
319
338
 
320
339
  export class SpriteNotCreated extends Data.TaggedError("Fly.SpriteNotCreated")<{
321
340
  resourceName: string;
@@ -575,6 +594,13 @@ export const SpriteProvider = () =>
575
594
  return undefined;
576
595
  }),
577
596
 
597
+ lookup: Effect.fn(function* ({ name }) {
598
+ const found = yield* getByName(name).pipe(
599
+ Effect.catchTag("SpritesNotEnabled", () => Effect.undefined),
600
+ );
601
+ return found === undefined ? undefined : toAttrs(found, name, "");
602
+ }),
603
+
578
604
  read: Effect.fn(function* ({ id, olds, output }) {
579
605
  const name = yield* resolveSpriteName(id, olds?.name, output?.name);
580
606
  const found = yield* (
package/src/Volume.ts CHANGED
@@ -116,6 +116,11 @@ export const createVolume = Effect.fn(function* (input: {
116
116
  disk: DiskSpec;
117
117
  }) {
118
118
  const sizeGb = Math.max(input.disk.sizeGb, MIN_SIZE_GB);
119
+ // The volumes this group held before the create: a storage host that fails
120
+ // with an unknown outcome may still have made one.
121
+ const before = new Set(
122
+ (yield* listVolumeGroup(input.appName, input.name, input.region)).map((volume) => volume.id),
123
+ );
119
124
  const created = yield* machines
120
125
  .createVolume({
121
126
  app_name: input.appName,
@@ -130,7 +135,26 @@ export const createVolume = Effect.fn(function* (input: {
130
135
  source_volume_id: input.disk.sourceVolumeId,
131
136
  require_unique_zone: input.disk.requireUniqueZone,
132
137
  })
133
- .pipe(Effect.catchTag("Conflict", () => Effect.undefined));
138
+ .pipe(
139
+ Effect.catchTags({
140
+ Conflict: () => Effect.undefined,
141
+ StorageHostCapacity: (error) =>
142
+ Effect.gen(function* () {
143
+ // Its outcome is unknown: a single new volume in the group is the
144
+ // one this call made. Otherwise create again after a pause.
145
+ yield* Effect.sleep(Duration.seconds(5));
146
+ const fresh = (yield* listVolumeGroup(input.appName, input.name, input.region)).filter(
147
+ (volume) => volume.id !== undefined && !before.has(volume.id),
148
+ );
149
+ return fresh.length === 1 ? fresh[0] : yield* error;
150
+ }),
151
+ }),
152
+ Effect.retry({
153
+ while: (error) => error._tag === "StorageHostCapacity",
154
+ schedule: Schedule.spaced(Duration.seconds(5)),
155
+ times: 6,
156
+ }),
157
+ );
134
158
  if (created?.id !== undefined && created.id.length > 0) {
135
159
  return (yield* waitUntilVolumeReady(input.appName, created.id)) ?? created;
136
160
  }
@@ -56,9 +56,23 @@ export type VolumeSnapshot = Resource<
56
56
  createdAt: string | undefined;
57
57
  },
58
58
  never,
59
- Providers
59
+ Providers,
60
+ VolumeSnapshotLookup
60
61
  >;
61
62
 
63
+ /**
64
+ * Props for {@link VolumeSnapshot.lookup}: find a snapshot this stack
65
+ * doesn't manage by its App, Volume and id.
66
+ */
67
+ export interface VolumeSnapshotLookup {
68
+ /** Fly App name the Volume belongs to. */
69
+ appName: string;
70
+ /** Fly Volume id (`vol_…`). */
71
+ volumeId: string;
72
+ /** Fly snapshot id (`vs_…`). */
73
+ snapshotId: string;
74
+ }
75
+
62
76
  /**
63
77
  * A Fly.VolumeSnapshot is an on-demand snapshot of a mounted disk.
64
78
  *
@@ -127,6 +141,25 @@ export type VolumeSnapshot = Resource<
127
141
  * ) {}
128
142
  * ```
129
143
  *
144
+ * ### Looking up an existing snapshot
145
+ * `VolumeSnapshot.lookup` references a snapshot Alchemy doesn't manage
146
+ * (taken by Fly's daily schedule or by another stack). It is read during
147
+ * plan/deploy and never created or deleted.
148
+ *
149
+ * **Example:** Restore a disk from an existing snapshot
150
+ * ```typescript
151
+ * const snapshot = Fly.VolumeSnapshot.lookup({
152
+ * appName: "my-app",
153
+ * volumeId: "vol_123",
154
+ * snapshotId: "vs_456",
155
+ * });
156
+ * const disk = yield* Fly.MountVolume({
157
+ * path: "/data",
158
+ * sizeGb: 1,
159
+ * snapshotId: snapshot.snapshotId,
160
+ * });
161
+ * ```
162
+ *
130
163
  * @resource
131
164
  * @product Volume
132
165
  */
@@ -235,6 +268,11 @@ export const VolumeSnapshotProvider = () =>
235
268
  return undefined;
236
269
  }),
237
270
 
271
+ lookup: Effect.fn(function* ({ appName, volumeId, snapshotId }) {
272
+ const found = yield* findById(appName, volumeId, snapshotId);
273
+ return found === undefined ? undefined : toAttrs(appName, volumeId, found);
274
+ }),
275
+
238
276
  read: Effect.fn(function* ({ olds, output }) {
239
277
  const appName = output?.appName ?? appNameOf(olds?.app);
240
278
  const volumeId = output?.volumeId ?? volumeIdOf(olds?.volumeId);
package/src/bluegreen.ts CHANGED
@@ -444,7 +444,10 @@ export const reconcileBlueGreen = Effect.fn(function* (
444
444
  .pipe(
445
445
  Retry.none,
446
446
  Effect.timeout("30 seconds"),
447
- Effect.catchTag(["Conflict", "GatewayTimeout", "TimeoutError"], () => readback),
447
+ Effect.catchTag(
448
+ ["Conflict", "GatewayTimeout", "TimeoutError", "StorageHostCapacity"],
449
+ () => readback,
450
+ ),
448
451
  Effect.catchReason("HttpClientError", "TransportError", () => readback),
449
452
  );
450
453
  }
package/src/hosted.ts CHANGED
@@ -9,9 +9,12 @@ import {
9
9
  type PackageInstall,
10
10
  } from "alchemy/Bundle/InstalledPackages";
11
11
  import { findCwdForBundle, getStableContextDir, resolveMainPath } from "alchemy/Bundle/TempRoot";
12
+ import { hashDirectory } from "alchemy/Command/Memo";
12
13
  import { safeHttpEffect } from "alchemy/Http";
14
+ import * as Output from "alchemy/Output";
13
15
  import type { ResourceBinding } from "alchemy/Resource";
14
16
  import { createContainerRuntimeContext, type HostRuntimeContext } from "alchemy/Runtime/Process";
17
+ import { packEnvValueKeepRedacted } from "alchemy/RuntimeContext";
15
18
  import {
16
19
  copyExtraFiles,
17
20
  contextRootOf,
@@ -30,6 +33,7 @@ import * as Path from "effect/Path";
30
33
  import * as Redacted from "effect/Redacted";
31
34
  import * as Schedule from "effect/Schedule";
32
35
  import type * as rolldown from "rolldown";
36
+ import { flyHostBundle, flyOidcToken } from "./internal/Oidc.ts";
33
37
  import { buildOnRemoteBuilder } from "./internal/RemoteBuilder.ts";
34
38
  import type { BoundTarget, DiskSpec, ServiceBinding } from "./MountVolume.ts";
35
39
  import { rpcMethodsOf, serveFlyRpc } from "./rpc.ts";
@@ -59,7 +63,20 @@ export const createFlyHostRuntimeContext =
59
63
  boots ? { shape: { ...shape, fetch: shape?.fetch ?? wrapped } } : options,
60
64
  ) as Effect.Effect<void, never, any>;
61
65
  };
62
- return Object.assign(base, { serve });
66
+ // Keep the `Redacted` wrapper outside the packed string so the Service
67
+ // reconcile can store those values as App secrets instead of Machine env.
68
+ const set: HostRuntimeContext["set"] = (bindingId, output) =>
69
+ Effect.sync(() => {
70
+ const key = bindingId.replaceAll(/[^a-zA-Z0-9]/g, "_");
71
+ base.env[key] = output.pipe(Output.map(packEnvValueKeepRedacted));
72
+ return key;
73
+ });
74
+ // A Service runs on Fly Machines, which mint OIDC tokens for their
75
+ // bindings to exchange with AWS and GCP (see `internal/Oidc.ts`).
76
+ return Object.assign(
77
+ base,
78
+ type === "Fly.Service" ? { serve, set, oidcToken: flyOidcToken } : { serve, set },
79
+ );
63
80
  };
64
81
 
65
82
  export const FLY_REGISTRY = "registry.fly.io";
@@ -189,10 +206,14 @@ export const collectBindingState = (bindings: readonly ResourceBinding<ServiceBi
189
206
  const active = bindings.filter(
190
207
  (binding: ResourceBinding<ServiceBinding> & { action?: string }) => binding.action !== "delete",
191
208
  );
192
- const env = toEnvRecord(
193
- active
194
- .map((binding) => binding?.data?.env)
195
- .reduce<Record<string, any>>((acc, value) => ({ ...acc, ...value }), {}),
209
+ const merged = active
210
+ .map((binding) => binding?.data?.env)
211
+ .reduce<Record<string, any>>((acc, value) => ({ ...acc, ...value }), {});
212
+ const env = toEnvRecord(merged);
213
+ // Names of bound `Config` values and Redacted outputs. Only the names:
214
+ // the values stay in `env` until the Service moves them into App secrets.
215
+ const secretNames = Object.keys(toEnvRecord(merged)).filter((key) =>
216
+ Redacted.isRedacted(merged[key]),
196
217
  );
197
218
  const mounts: DiskSpec[] = [];
198
219
  const seen = new Set<string>();
@@ -200,9 +221,12 @@ export const collectBindingState = (bindings: readonly ResourceBinding<ServiceBi
200
221
  const buckets: { name: string; id?: string; values?: Record<string, string> }[] = [];
201
222
  const postgres: { clusterId: string; variableName?: string }[] = [];
202
223
  const targets: BoundTarget[] = [];
224
+ const callers = new Set<string>();
203
225
  for (const binding of active) {
204
226
  const target = binding?.data?.target;
205
227
  if (target !== undefined) targets.push(target);
228
+ const caller = coerceBindingName(binding?.data?.caller?.appName);
229
+ if (caller !== undefined) callers.add(caller);
206
230
  for (const mount of binding?.data?.mounts ?? []) {
207
231
  if (seen.has(mount.path)) continue;
208
232
  seen.add(mount.path);
@@ -256,7 +280,16 @@ export const collectBindingState = (bindings: readonly ResourceBinding<ServiceBi
256
280
  });
257
281
  }
258
282
  }
259
- return { env, mounts, redis, buckets, postgres, targets };
283
+ return {
284
+ env,
285
+ secretNames,
286
+ mounts,
287
+ redis,
288
+ buckets,
289
+ postgres,
290
+ targets,
291
+ callers: [...callers].sort(),
292
+ };
260
293
  };
261
294
 
262
295
  /**
@@ -377,7 +410,7 @@ export const createFlyHostedSupport = ({
377
410
  plugins?: rolldown.RolldownPluginOption,
378
411
  ) {
379
412
  return yield* Bundle.build(
380
- {
413
+ flyHostBundle({
381
414
  ...props.build?.input,
382
415
  input: entry,
383
416
  cwd,
@@ -394,7 +427,7 @@ export const createFlyHostedSupport = ({
394
427
  ...props.build?.input?.resolve,
395
428
  },
396
429
  plugins: [props.build?.input?.plugins, plugins],
397
- },
430
+ }),
398
431
  {
399
432
  ...props.build?.output,
400
433
  format: "esm",
@@ -539,10 +572,10 @@ export const createFlyHostedSupport = ({
539
572
  );
540
573
  const entryRel = posixRelUnder(root, realMain, path) ?? path.basename(realMain);
541
574
  const dest = path.join(contextDir, entryRel);
542
- if (!(yield* fs.exists(dest).pipe(Effect.orElseSucceed(() => false)))) {
543
- yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
544
- yield* fs.copy(realMain, dest, { overwrite: true });
545
- }
575
+ // The context directory is reused across deploys: always copy the
576
+ // entry, or a changed entry ships the previous deploy's copy.
577
+ yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
578
+ yield* fs.copy(realMain, dest, { overwrite: true });
546
579
  }
547
580
  return contextDir;
548
581
  });
@@ -583,7 +616,79 @@ export const createFlyHostedSupport = ({
583
616
  return codeHash;
584
617
  });
585
618
 
586
- return { alchemyEnv, bundleProgram, computeCodeHash, resolveImage, hash };
619
+ /** A Docker build context's content hash: its image changes when any file in it does. */
620
+ const contextHash = Effect.fn(function* (context: string) {
621
+ return (yield* hashDirectory({ cwd: context })).slice(0, 16);
622
+ });
623
+
624
+ /**
625
+ * Build the image a `context` directory's `Dockerfile` describes, as it
626
+ * is, and push it to `registry.fly.io/{appName}:{logicalId}-{hash}`.
627
+ * When `previousHash` matches, skip build and push.
628
+ */
629
+ const resolveContextImage = Effect.fn(function* (input: {
630
+ id: string;
631
+ appName: string;
632
+ context: string;
633
+ remote: boolean | undefined;
634
+ previousHash?: string;
635
+ session?: { note: (message: string) => Effect.Effect<void> };
636
+ }) {
637
+ const note = input.session?.note ?? ((_message: string) => Effect.void);
638
+ const codeHash = yield* contextHash(input.context);
639
+ const imageRef = `${FLY_REGISTRY}/${input.appName}:${sanitizeImageRepo(input.id)}-${codeHash}`;
640
+ if (input.previousHash === codeHash) return { imageRef, codeHash };
641
+
642
+ const minted = yield* machines.createAppDeployToken({ app_name: input.appName });
643
+ const token =
644
+ minted.token === undefined || Redacted.isRedacted(minted.token)
645
+ ? minted.token
646
+ : Redacted.make(minted.token);
647
+ if (token === undefined || Redacted.value(token).length === 0) {
648
+ return yield* new DeployTokenMissing({ appName: input.appName });
649
+ }
650
+ const registry = { server: FLY_REGISTRY, username: "x", password: token };
651
+
652
+ if (input.remote !== false) {
653
+ yield* note(`Building ${imageRef} on a Fly remote builder...`);
654
+ yield* buildOnRemoteBuilder({
655
+ docker,
656
+ appName: input.appName,
657
+ contextDir: input.context,
658
+ imageRef,
659
+ platform: MACHINE_PLATFORM,
660
+ registry,
661
+ note,
662
+ });
663
+ yield* note(`Pushed ${imageRef}`);
664
+ return { imageRef, codeHash };
665
+ }
666
+
667
+ if (!(yield* imageExists(imageRef))) {
668
+ yield* note(`Building container image ${imageRef}...`);
669
+ yield* docker.image.build({
670
+ context: input.context,
671
+ tag: imageRef,
672
+ platform: MACHINE_PLATFORM,
673
+ });
674
+ }
675
+ yield* note(`Pushing ${imageRef}...`);
676
+ yield* docker.image
677
+ .push(imageRef, registry)
678
+ .pipe(Effect.retry({ times: 3, schedule: pushBackoff }));
679
+ yield* note(`Pushed ${imageRef}`);
680
+ return { imageRef, codeHash };
681
+ });
682
+
683
+ return {
684
+ alchemyEnv,
685
+ bundleProgram,
686
+ computeCodeHash,
687
+ resolveImage,
688
+ hash,
689
+ contextHash,
690
+ resolveContextImage,
691
+ };
587
692
  };
588
693
 
589
694
  /**
@@ -0,0 +1,99 @@
1
+ import * as http from "node:http";
2
+ import { OidcTokenError } from "alchemy/RuntimeContext";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Redacted from "effect/Redacted";
5
+ import type * as rolldown from "rolldown";
6
+
7
+ /**
8
+ * Fly Machine OIDC tokens. Every Machine serves part of the Machines API on
9
+ * the `/.fly/api` socket, authenticated as that Machine, so a process can
10
+ * mint a token without holding one. A token is signed by
11
+ * `https://oidc.fly.io/<org>`, lives 10 minutes, and names its caller as
12
+ * `sub = <org>:<app>:<machine>`. AWS and GCP trust it through an OIDC
13
+ * provider, so a Fly host reaches them without a key Alchemy created.
14
+ *
15
+ * See https://fly.io/docs/security/openid-connect/.
16
+ */
17
+
18
+ /** The socket a Machine serves its Machines API on. */
19
+ const FLY_API_SOCKET = "/.fly/api";
20
+
21
+ /** Fly's OIDC issuer for an organization. */
22
+ export const flyOidcIssuer = (orgSlug: string) => `https://oidc.fly.io/${orgSlug}`;
23
+
24
+ /**
25
+ * Bundle options for a Fly Service: defines `__ALCHEMY_HOST_IDENTITY__` as
26
+ * `"Fly.OIDC"` so bindings keep only the code that exchanges the Machine's
27
+ * OIDC token, and drop the created-key path.
28
+ */
29
+ export const flyHostBundle = (input: rolldown.InputOptions): rolldown.InputOptions => ({
30
+ ...input,
31
+ transform: {
32
+ ...input.transform,
33
+ define: {
34
+ ...input.transform?.define,
35
+ "globalThis.__ALCHEMY_HOST_IDENTITY__": JSON.stringify("Fly.OIDC"),
36
+ },
37
+ },
38
+ });
39
+
40
+ /** Mint an OIDC token for `audience` from the Machine's `/.fly/api` socket. */
41
+ export const flyOidcToken = (
42
+ audience: string,
43
+ ): Effect.Effect<Redacted.Redacted<string>, OidcTokenError> =>
44
+ Effect.callback<Redacted.Redacted<string>, OidcTokenError>((resume) => {
45
+ const body = JSON.stringify({ aud: audience });
46
+ const request = http.request(
47
+ {
48
+ socketPath: FLY_API_SOCKET,
49
+ path: "/v1/tokens/oidc",
50
+ method: "POST",
51
+ headers: {
52
+ "content-type": "application/json",
53
+ "content-length": Buffer.byteLength(body),
54
+ },
55
+ },
56
+ (response) => {
57
+ let text = "";
58
+ response.setEncoding("utf8");
59
+ response.on("data", (chunk: string) => {
60
+ text += chunk;
61
+ });
62
+ response.on("end", () => {
63
+ if (response.statusCode === 200 && text.trim().length > 0) {
64
+ resume(Effect.succeed(Redacted.make(text.trim())));
65
+ } else {
66
+ resume(
67
+ Effect.fail(
68
+ new OidcTokenError({
69
+ message: `${FLY_API_SOCKET} returned HTTP ${response.statusCode} for an OIDC token: ${text.slice(0, 200)}`,
70
+ }),
71
+ ),
72
+ );
73
+ }
74
+ });
75
+ response.on("error", (cause) =>
76
+ resume(
77
+ Effect.fail(
78
+ new OidcTokenError({
79
+ message: `Reading the OIDC token from ${FLY_API_SOCKET}`,
80
+ cause,
81
+ }),
82
+ ),
83
+ ),
84
+ );
85
+ },
86
+ );
87
+ request.on("error", (cause) =>
88
+ resume(
89
+ Effect.fail(
90
+ new OidcTokenError({
91
+ message: `Cannot reach ${FLY_API_SOCKET} for an OIDC token. It is served only on Fly Machines, to root.`,
92
+ cause,
93
+ }),
94
+ ),
95
+ ),
96
+ );
97
+ request.end(body);
98
+ return Effect.sync(() => request.destroy());
99
+ });
package/src/leases.ts CHANGED
@@ -17,6 +17,7 @@ import * as FetchHttpClient from "effect/http/FetchHttpClient";
17
17
  import * as HttpClient from "effect/http/HttpClient";
18
18
  import * as HttpClientRequest from "effect/http/HttpClientRequest";
19
19
  import * as Option from "effect/Option";
20
+ import * as Random from "effect/Random";
20
21
  import * as Schedule from "effect/Schedule";
21
22
 
22
23
  const TTL_SECONDS = 120;
@@ -37,14 +38,20 @@ const retrySchedule = <E>(): Schedule.Schedule<Duration.Duration, E> =>
37
38
  ),
38
39
  ),
39
40
  Schedule.addDelay<Duration.Duration, E>(() =>
40
- Effect.sync(() => Duration.millis(Math.random() * 50)),
41
+ Effect.map(Random.next, (random) => Duration.millis(random * 50)),
41
42
  ),
42
43
  );
43
44
 
44
45
  export class MachineLeaseBusy extends Data.TaggedError("Fly.MachineLeaseBusy")<{
45
46
  appName: string;
46
47
  machineId: string;
47
- }> {}
48
+ /** Fly's conflict message: the lease holder and its expiry. */
49
+ reason?: string;
50
+ }> {
51
+ get message() {
52
+ return `Machine lease for ${this.appName}/${this.machineId} is held by another operation${this.reason ? `: ${this.reason}` : ""}`;
53
+ }
54
+ }
48
55
 
49
56
  export class MachineLeaseLost extends Data.TaggedError("Fly.MachineLeaseLost")<{
50
57
  appName: string;
@@ -71,6 +78,9 @@ export const makeMachineLeases = Effect.fn(function* (appName: string) {
71
78
  const scope = yield* Effect.scope;
72
79
  const held = new Map<string, HeldLease>();
73
80
  const deleting = new Set<string>();
81
+ // Machines this lease set deleted; a later snapshot of the same operation
82
+ // must not wait for them to accept a lease.
83
+ const removed = new Set<string>();
74
84
  const renewals = new Set<Fiber.Fiber<void, never>>();
75
85
  const uncertain = new Set<string>();
76
86
  const lost = yield* Deferred.make<never, MachineLeaseLost>();
@@ -131,8 +141,10 @@ export const makeMachineLeases = Effect.fn(function* (appName: string) {
131
141
  while: (error) => error._tag === "Conflict" || error._tag === "TooManyRequests",
132
142
  }),
133
143
  Effect.timeout("30 seconds"),
134
- Effect.catchTag("Conflict", () =>
135
- Effect.fail(new MachineLeaseBusy({ appName, machineId })),
144
+ Effect.catchTag("Conflict", (error) =>
145
+ Effect.fail(
146
+ new MachineLeaseBusy({ appName, machineId, reason: error.message }),
147
+ ),
136
148
  ),
137
149
  Effect.catchTag(["HttpClientError", "GatewayTimeout", "TimeoutError"], (error) =>
138
150
  lose(machineId, `acquisition uncertain: ${error._tag}`),
@@ -274,7 +286,10 @@ export const makeMachineLeases = Effect.fn(function* (appName: string) {
274
286
  Effect.asVoid,
275
287
  Effect.catch((error) =>
276
288
  Effect.gen(function* () {
277
- if (error._tag === "NotFound") return yield* forget(machineId);
289
+ if (error._tag === "NotFound") {
290
+ removed.add(machineId);
291
+ return yield* forget(machineId);
292
+ }
278
293
  if (
279
294
  error._tag === "Fly.MachineMutationUncertain" ||
280
295
  error._tag === "InternalServerError" ||
@@ -282,12 +297,16 @@ export const makeMachineLeases = Effect.fn(function* (appName: string) {
282
297
  error._tag === "ServiceUnavailable"
283
298
  ) {
284
299
  // Keep renewal aware of the removal until its exact target has been observed.
285
- if (yield* confirmRemoved(machineId)) return;
300
+ if (yield* confirmRemoved(machineId)) {
301
+ removed.add(machineId);
302
+ return;
303
+ }
286
304
  }
287
305
  return yield* Effect.fail(error);
288
306
  }),
289
307
  ),
290
308
  );
309
+ removed.add(machineId);
291
310
  yield* forget(machineId);
292
311
  }).pipe(
293
312
  Effect.ensuring(
@@ -360,7 +379,14 @@ export const makeMachineLeases = Effect.fn(function* (appName: string) {
360
379
  Effect.retry({
361
380
  times: 8,
362
381
  schedule: retrySchedule<machines.CreateMachineLeaseError | MachineLeaseLost>(),
363
- while: (error) => error._tag === "TooManyRequests",
382
+ // A renewal re-sends the nonce we hold, so replaying it after a
383
+ // transient 5xx extends the same lease instead of taking a new one.
384
+ while: (error) =>
385
+ error._tag === "TooManyRequests" ||
386
+ error._tag === "InternalServerError" ||
387
+ error._tag === "BadGateway" ||
388
+ error._tag === "ServiceUnavailable" ||
389
+ error._tag === "GatewayTimeout",
364
390
  }),
365
391
  Effect.timeout("75 seconds"),
366
392
  Effect.catch((error) =>
@@ -407,6 +433,7 @@ export const makeMachineLeases = Effect.fn(function* (appName: string) {
407
433
  Effect.forkScoped,
408
434
  );
409
435
  const checkTarget = (machineId: string) => nonce(machineId).pipe(Effect.asVoid);
436
+ const wasRemoved = (machineId: string) => removed.has(machineId);
410
437
  return {
411
438
  acquire,
412
439
  check,
@@ -416,6 +443,7 @@ export const makeMachineLeases = Effect.fn(function* (appName: string) {
416
443
  remove,
417
444
  forget,
418
445
  nonceIfHeld,
446
+ wasRemoved,
419
447
  };
420
448
  });
421
449