1/**
2 * The pack hasher on AWS Lambda, end to end (DESIGN §22.11): the Git host
3 * Worker binds `InvokeFunction` cross-cloud, pushes stream through Lambda
4 * chunks, and the result clones back byte-identical under `fsck --strict`.
5 * Needs BOTH provider sets (`--profile testing`; AWS via SSO).
6 */
7import * as AWS from "@/AWS";
8import * as Cloudflare from "@/Cloudflare";
9import { GitApi } from "@/Git/Api.ts";
10import * as Test from "@/Test/Alchemy";
11import { expect } from "alchemy-test";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import { MinimumLogLevel } from "effect/References";
17import * as Schedule from "effect/Schedule";
18import * as Stream from "effect/Stream";
19import * as HttpClient from "effect/http/HttpClient";
20import * as HttpClientRequest from "effect/http/HttpClientRequest";
21import * as HttpApiClient from "effect/http-api/HttpApiClient";
22import * as ChildProcess from "effect/process/ChildProcess";
23import { makeLambdaTestStack, TEST_SECRET } from "./fixtures/lambda-stack.ts";
24
25const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
26 providers: Layer.mergeAll(AWS.providers(), Cloudflare.providers()),
27});
28
29const logLevel = Effect.provideService(
30 MinimumLogLevel,
31 process.env.DEBUG ? "Debug" : "Info",
32);
33
34const Stack = makeLambdaTestStack("GitHasherLambdaStack");
35
36const makeClient = (url: string, token: string) =>
37 HttpApiClient.make(GitApi, {
38 baseUrl: url,
39 transformClient: HttpClient.mapRequest((request) =>
40 request.pipe(HttpClientRequest.bearerToken(token)),
41 ),
42 });
43
44const edgeRetry = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
45 effect.pipe(
46 Effect.timeout("10 seconds"),
47 Effect.retry({
48 while: (error: unknown) =>
49 (error as { _tag?: string })._tag === "TimeoutError" ||
50 (error as { _tag?: string })._tag === "HttpClientError",
51 schedule: Schedule.spaced("1500 millis"),
52 times: 20,
53 }),
54 );
55
56class ShellError extends Data.TaggedError("ShellError")<{
57 readonly script: string;
58 readonly exitCode: number;
59 readonly stdout: string;
60 readonly stderr: string;
61}> {
62 override get message(): string {
63 return `shell script exited ${this.exitCode}\n--- script ---\n${this.script}\n--- stdout ---\n${this.stdout}\n--- stderr ---\n${this.stderr}`;
64 }
65}
66
67const sh = Effect.fn(function* (cwd: string, script: string) {
68 const handle = yield* ChildProcess.make("sh", ["-ec", script], {
69 cwd,
70 env: {
71 GIT_TERMINAL_PROMPT: "0",
72 GIT_CONFIG_NOSYSTEM: "1",
73 GIT_CONFIG_GLOBAL: "/dev/null",
74 GIT_AUTHOR_NAME: "Hasher Bot",
75 GIT_AUTHOR_EMAIL: "hasher@example.com",
76 GIT_COMMITTER_NAME: "Hasher Bot",
77 GIT_COMMITTER_EMAIL: "hasher@example.com",
78 GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z",
79 GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z",
80 },
81 extendEnv: true,
82 });
83 const [exitCode, stdout, stderr] = yield* Effect.all(
84 [
85 handle.exitCode,
86 Stream.mkString(Stream.decodeText(handle.stdout)),
87 Stream.mkString(Stream.decodeText(handle.stderr)),
88 ],
89 { concurrency: 3 },
90 );
91 if (exitCode !== 0) {
92 return yield* new ShellError({ script, exitCode, stdout, stderr });
93 }
94 return { stdout: stdout.trim(), stderr };
95}, Effect.timeout("180 seconds"));
96
97const stack = beforeAll(
98 deploy(Stack).pipe(
99 Effect.tap(({ url }) =>
100 Effect.gen(function* () {
101 yield* Effect.logInfo(`git-service (lambda hasher) deployed at ${url}`);
102 const admin = yield* makeClient(url, TEST_SECRET);
103 yield* admin.repos.list({ query: {} }).pipe(
104 edgeRetry,
105 Effect.retry({
106 schedule: Schedule.spaced("1500 millis"),
107 times: 40,
108 }),
109 );
110 }),
111 ),
112 logLevel,
113 ),
114);
115afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack).pipe(logLevel));
116
117test(
118 "a multi-part push is hashed on Lambda and clones back under fsck --strict",
119 Effect.gen(function* () {
120 const { url } = yield* stack;
121 const admin = yield* makeClient(url, TEST_SECRET);
122 const owner = "lambda";
123 const name = "hashed";
124 yield* admin.repos.delete({ params: { owner, repo: name } }).pipe(
125 Effect.catchTag("RepoNotFound", () => Effect.void),
126 edgeRetry,
127 );
128 const created = yield* admin.repos
129 .create({ payload: { owner, name } })
130 .pipe(edgeRetry);
131 const parsed = new URL(url);
132 const remote = `${parsed.protocol}//x:${TEST_SECRET}@${parsed.host}/${owner}/${name}.git`;
133 const fs = yield* FileSystem.FileSystem;
134 const dir = yield* fs.makeTempDirectory({ prefix: "git-lambda-" });
135 // ~14 MiB of incompressible blobs plus text files that delta well: a
136 // body that spills (four Lambda chunks, two R2 parts) and carries
137 // both non-delta and delta entries across chunk edges.
138 yield* sh(
139 dir,
140 `git init -q -b main src && cd src
141 for i in $(seq 1 14); do head -c 1000000 /dev/urandom > bin-$i.dat; done
142 for i in $(seq 1 200); do seq 1 400 | sed "s/^/line $i /" > text-$i.txt; done
143 git add -A && git commit -qm base
144 for i in $(seq 1 60); do echo "edit $i" >> text-$i.txt; done
145 git add -A && git commit -qm edits
146 git push -q ${remote} main`,
147 );
148 const back = yield* sh(
149 dir,
150 `git clone -q ${remote} back && cd back && git fsck --strict && git rev-parse HEAD`,
151 );
152 const head = yield* sh(dir, "cd src && git rev-parse HEAD");
153 expect(back.stdout.trim().split("\n").pop()).toBe(head.stdout.trim());
154 const repo = yield* admin.repos
155 .get({ params: { owner, repo: name } })
156 .pipe(edgeRetry);
157 const push = repo.lastPush;
158 expect(push).not.toBeNull();
159 // Four 4 MiB chunks were dispatched to Lambda; nothing fell back.
160 expect(push?.phases?.chunks ?? 0).toBeGreaterThanOrEqual(4);
161 yield* Effect.logInfo(`lastPush: ${JSON.stringify(push)}`);
162 }),
163 {
164 tags: [
165 "provider:aws",
166 "provider:aws:lambda",
167 "provider:cloudflare",
168 "provider:cloudflare:r2",
169 "provider:cloudflare:worker",
170 "live",
171 ],
172 timeout: 600_000,
173 },
174);