1import * as r2 from "@distilled.cloud/cloudflare/r2";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12import type { Bucket } from "./Bucket.ts";
13
14const TypeId = "Cloudflare.R2.BucketSippy" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Source bucket configuration for an AWS S3 origin.
19 */
20export interface BucketSippyAwsSource {
21 /** Marks the source as an AWS S3 bucket. */
22 provider: "aws";
23 /**
24 * Name of the AWS S3 bucket to migrate objects from.
25 */
26 bucket: string;
27 /**
28 * AWS region the source bucket lives in, e.g. `us-east-1`.
29 */
30 region: string;
31 /**
32 * Access Key ID of an AWS IAM credential with read access to the
33 * source bucket. Write-only — Cloudflare never returns it.
34 */
35 accessKeyId: Redacted.Redacted<string>;
36 /**
37 * Secret Access Key paired with `accessKeyId`. Write-only.
38 */
39 secretAccessKey: Redacted.Redacted<string>;
40}
41
42/**
43 * Source bucket configuration for a Google Cloud Storage origin.
44 */
45export interface BucketSippyGcsSource {
46 /** Marks the source as a Google Cloud Storage bucket. */
47 provider: "gcs";
48 /**
49 * Name of the GCS bucket to migrate objects from.
50 */
51 bucket: string;
52 /**
53 * Client email of a GCP service account with read access to the
54 * source bucket.
55 */
56 clientEmail: string;
57 /**
58 * Private key of the GCP service account. Write-only — Cloudflare
59 * never returns it.
60 */
61 privateKey: Redacted.Redacted<string>;
62}
63
64/**
65 * The upstream bucket Sippy pulls objects from on R2 cache misses.
66 */
67export type BucketSippySource = BucketSippyAwsSource | BucketSippyGcsSource;
68
69/**
70 * R2 credentials Sippy uses to write migrated objects into the
71 * destination bucket. Create an R2 API token with write access to the
72 * bucket and pass its S3-compatible credentials here.
73 */
74export interface BucketSippyDestination {
75 /**
76 * Access Key ID of the R2 API token. Write-only at the secret level —
77 * Cloudflare echoes only the key ID back.
78 */
79 accessKeyId: Redacted.Redacted<string>;
80 /**
81 * Secret Access Key of the R2 API token. Write-only.
82 */
83 secretAccessKey: Redacted.Redacted<string>;
84}
85
86export interface BucketSippyProps {
87 /**
88 * Name of the R2 bucket to enable incremental migration into. Pass
89 * `bucket.bucketName` from a `Cloudflare.R2.Bucket`.
90 *
91 * Immutable — changing the bucket triggers a replacement.
92 */
93 bucketName: string;
94 /**
95 * Jurisdiction of the bucket (must match the bucket's own
96 * jurisdiction).
97 *
98 * Immutable — changing the jurisdiction triggers a replacement.
99 * @default "default"
100 */
101 jurisdiction?: Bucket.Jurisdiction;
102 /**
103 * The upstream AWS S3 or Google Cloud Storage bucket to migrate
104 * objects from. Source credentials are write-only — they are sent to
105 * Cloudflare but never read back.
106 */
107 source: BucketSippySource;
108 /**
109 * R2 API token credentials Sippy uses to write objects into the
110 * destination bucket.
111 */
112 destination: BucketSippyDestination;
113}
114
115export interface BucketSippyAttributes {
116 /** Name of the R2 bucket Sippy is enabled on. */
117 bucketName: string;
118 /** Account the bucket lives in. */
119 accountId: string;
120 /** Jurisdiction of the bucket. */
121 jurisdiction: Bucket.Jurisdiction;
122 /** Whether Sippy is currently enabled on the bucket. */
123 enabled: boolean;
124 /** The configured source bucket as reported by Cloudflare (sans secrets). */
125 source: BucketSippy.SourceAttributes;
126 /** The configured destination as reported by Cloudflare (sans secrets). */
127 destination: BucketSippy.DestinationAttributes;
128}
129
130export type BucketSippy = Resource<
131 TypeId,
132 BucketSippyProps,
133 BucketSippyAttributes,
134 never,
135 Providers
136>;
137
138/**
139 * Sippy — incremental migration from AWS S3 or Google Cloud Storage
140 * into a Cloudflare R2 bucket.
141 *
142 * When Sippy is enabled on a bucket, any object requested from R2 that
143 * is not yet present is fetched from the configured source bucket,
144 * served, and copied into R2 — migrating data on demand without a bulk
145 * transfer and without paying double storage during the transition.
146 *
147 * One Sippy configuration exists per bucket (it is a singleton
148 * sub-resource of the bucket). Destroying the resource disables Sippy;
149 * objects already migrated stay in the R2 bucket.
150 * ### Migrating from AWS S3
151 * **Example:** Enable Sippy on a bucket with an S3 source
152 * ```typescript
153 * const bucket = yield* Cloudflare.R2.Bucket("Media");
154 *
155 * yield* Cloudflare.R2.BucketSippy("MediaMigration", {
156 * bucketName: bucket.bucketName,
157 * source: {
158 * provider: "aws",
159 * bucket: "legacy-media",
160 * region: "us-east-1",
161 * accessKeyId: yield* Config.Redacted("AWS_ACCESS_KEY_ID"),
162 * secretAccessKey: yield* Config.Redacted("AWS_SECRET_ACCESS_KEY"),
163 * },
164 * destination: {
165 * accessKeyId: yield* Config.Redacted("R2_ACCESS_KEY_ID"),
166 * secretAccessKey: yield* Config.Redacted("R2_SECRET_ACCESS_KEY"),
167 * },
168 * });
169 * ```
170 *
171 * ### Migrating from Google Cloud Storage
172 * **Example:** Enable Sippy with a GCS source
173 * ```typescript
174 * yield* Cloudflare.R2.BucketSippy("MediaMigration", {
175 * bucketName: bucket.bucketName,
176 * source: {
177 * provider: "gcs",
178 * bucket: "legacy-media",
179 * clientEmail: "sippy@my-project.iam.gserviceaccount.com",
180 * privateKey: yield* Config.Redacted("GCS_PRIVATE_KEY"),
181 * },
182 * destination: {
183 * accessKeyId: yield* Config.Redacted("R2_ACCESS_KEY_ID"),
184 * secretAccessKey: yield* Config.Redacted("R2_SECRET_ACCESS_KEY"),
185 * },
186 * });
187 * ```
188 *
189 * @see https://developers.cloudflare.com/r2/data-migration/sippy/
190 *
191 * @resource
192 * @product R2
193 * @category Storage & Databases
194 */
195export const BucketSippy = Resource<BucketSippy>(TypeId);
196
197export declare namespace BucketSippy {
198 /**
199 * The source bucket as echoed back by Cloudflare. Secrets are never
200 * returned.
201 */
202 export type SourceAttributes = {
203 provider: "aws" | "gcs" | undefined;
204 bucket: string | undefined;
205 region: string | undefined;
206 bucketUrl: string | undefined;
207 };
208 /**
209 * The destination as echoed back by Cloudflare. Only the access key
210 * ID is returned, never the secret.
211 */
212 export type DestinationAttributes = {
213 provider: "r2" | (string & {}) | undefined;
214 account: string | undefined;
215 bucket: string | undefined;
216 accessKeyId: string | undefined;
217 };
218}
219
220/**
221 * Returns true if the given value is an BucketSippy resource.
222 */
223export const isBucketSippy = (value: unknown): value is BucketSippy =>
224 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
225
226export const BucketSippyProvider = () =>
227 Provider.succeed(BucketSippy, {
228 stables: ["bucketName", "accountId", "jurisdiction"],
229
230 diff: Effect.fn(function* ({ olds, news }) {
231 if (!isResolved(news)) return undefined;
232 // The bucket is the configuration's identity — moving Sippy to a
233 // different bucket (or jurisdiction) is a replacement. Compare
234 // only once both sides are concrete strings.
235 if (
236 typeof olds.bucketName === "string" &&
237 typeof news.bucketName === "string" &&
238 olds.bucketName !== news.bucketName
239 ) {
240 return { action: "replace" } as const;
241 }
242 if (
243 olds.bucketName !== undefined &&
244 (olds.jurisdiction ?? "default") !== (news.jurisdiction ?? "default")
245 ) {
246 return { action: "replace" } as const;
247 }
248 return undefined;
249 }),
250
251 read: Effect.fn(function* ({ output, olds }) {
252 const { accountId } = yield* yield* CloudflareEnvironment;
253 const acct = output?.accountId ?? accountId;
254 // The bucket is the configuration's identity; cold reads derive it
255 // from the last-persisted props.
256 const bucketName =
257 output?.bucketName ??
258 (typeof olds?.bucketName === "string" ? olds.bucketName : undefined);
259 if (bucketName === undefined) return undefined;
260 const jurisdiction =
261 output?.jurisdiction ?? olds?.jurisdiction ?? "default";
262
263 const observed = yield* r2
264 .getBucketSippy({ accountId: acct, bucketName, jurisdiction })
265 .pipe(
266 Effect.map((config): r2.GetBucketSippyResponse | undefined => config),
267 // The bucket itself is gone — so is its Sippy configuration.
268 Effect.catchTag("NoSuchBucket", () => Effect.succeed(undefined)),
269 );
270 // A bucket with Sippy never configured (or disabled) reads back as
271 // `{ enabled: false }` — that is "absent" for this resource.
272 if (!observed || observed.enabled !== true) return undefined;
273
274 const attrs = toAttributes(observed, acct, bucketName, jurisdiction);
275 // Sippy configs carry no ownership markers. With no prior output we
276 // cannot prove we enabled it — brand it `Unowned` so takeover is
277 // gated behind the adopt policy.
278 return output ? attrs : Unowned(attrs);
279 }),
280
281 reconcile: Effect.fn(function* ({ news, output }) {
282 const { accountId } = yield* yield* CloudflareEnvironment;
283 const acct = output?.accountId ?? accountId;
284 // Inputs have been resolved to concrete strings by Plan.
285 const bucketName = news.bucketName as string;
286 const jurisdiction = news.jurisdiction ?? "default";
287
288 // The PUT is a full upsert of the bucket's single Sippy
289 // configuration — enabling when absent and re-configuring when
290 // present — so observe/ensure/sync collapse into one idempotent
291 // call. Source/destination secrets are write-only, so an observed
292 // config can never be diffed against the desired one anyway.
293 const synced = yield* r2.putBucketSippy({
294 accountId: acct,
295 bucketName,
296 jurisdiction,
297 source: toRequestSource(news.source),
298 destination: {
299 provider: "r2",
300 accessKeyId: Redacted.value(news.destination.accessKeyId),
301 secretAccessKey: Redacted.value(news.destination.secretAccessKey),
302 },
303 });
304
305 return toAttributes(synced, acct, bucketName, jurisdiction);
306 }),
307
308 delete: Effect.fn(function* ({ output }) {
309 // Disabling Sippy on a bucket where it is already disabled is a
310 // success (`{ enabled: false }`); a missing bucket means there is
311 // nothing left to disable — both make delete idempotent.
312 yield* r2
313 .deleteBucketSippy({
314 accountId: output.accountId,
315 bucketName: output.bucketName,
316 jurisdiction: output.jurisdiction,
317 })
318 .pipe(Effect.catchTag("NoSuchBucket", () => Effect.void));
319 }),
320
321 // Sippy is a per-bucket singleton with no account-wide enumeration
322 // API, so fan out from the parent: enumerate every R2 bucket, read its
323 // Sippy config with bounded concurrency, and emit one item per bucket
324 // that actually has Sippy enabled (mirroring `read`, which treats a
325 // disabled/absent config as "not present").
326 list: Effect.fn(function* () {
327 const { accountId } = yield* yield* CloudflareEnvironment;
328 const { buckets } = yield* r2.listBuckets({ accountId });
329 const perBucket = yield* Effect.forEach(
330 buckets ?? [],
331 (bucket) => {
332 const bucketName = bucket.name;
333 if (bucketName == null) {
334 return Effect.succeed([] as BucketSippyAttributes[]);
335 }
336 const jurisdiction = (bucket.jurisdiction ??
337 "default") as Bucket.Jurisdiction;
338 return r2
339 .getBucketSippy({ accountId, bucketName, jurisdiction })
340 .pipe(
341 Effect.map((config) =>
342 config.enabled === true
343 ? [toAttributes(config, accountId, bucketName, jurisdiction)]
344 : [],
345 ),
346 // A bucket that vanished mid-enumeration, or one whose plan
347 // rejects the Sippy route, contributes nothing — skip it.
348 Effect.catchTag(
349 ["NoSuchBucket", "InvalidRoute", "Forbidden"],
350 () => Effect.succeed([] as BucketSippyAttributes[]),
351 ),
352 );
353 },
354 { concurrency: 10 },
355 );
356 return perBucket.flat();
357 }),
358 });
359
360/**
361 * Build the request body shape the distilled `putBucketSippy` method
362 * accepts. Secrets are unwrapped here because the distilled TS types
363 * declare the credential fields as plain strings.
364 */
365const toRequestSource = (source: BucketSippySource) =>
366 source.provider === "aws"
367 ? {
368 provider: "aws" as const,
369 bucket: source.bucket,
370 region: source.region,
371 accessKeyId: Redacted.value(source.accessKeyId),
372 secretAccessKey: Redacted.value(source.secretAccessKey),
373 }
374 : {
375 provider: "gcs" as const,
376 bucket: source.bucket,
377 clientEmail: source.clientEmail,
378 privateKey: Redacted.value(source.privateKey),
379 };
380
381const toAttributes = (
382 config: r2.GetBucketSippyResponse,
383 accountId: string,
384 bucketName: string,
385 jurisdiction: Bucket.Jurisdiction,
386): BucketSippyAttributes => ({
387 bucketName,
388 accountId,
389 jurisdiction,
390 enabled: config.enabled ?? false,
391 source: {
392 // Cloudflare reports AWS sources as either "aws" or the legacy "s3".
393 provider:
394 config.source?.provider === "gcs"
395 ? "gcs"
396 : config.source?.provider != null
397 ? "aws"
398 : undefined,
399 bucket: config.source?.bucket ?? undefined,
400 region: config.source?.region ?? undefined,
401 bucketUrl: config.source?.bucketUrl ?? undefined,
402 },
403 destination: {
404 provider: config.destination?.provider ?? undefined,
405 account: config.destination?.account ?? undefined,
406 bucket: config.destination?.bucket ?? undefined,
407 accessKeyId: config.destination?.accessKeyId ?? undefined,
408 },
409});