1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Result from "effect/Result";
6import {
7 hashDockerBuildInputs,
8 selectDockerBuildContext,
9} from "../Docker/BuildHash.ts";
10import { initialCwd } from "../Util/Node.ts";
11import { sha256Object } from "../Util/sha256.ts";
12import { tarGzipDirectory } from "../Util/tarGzip.ts";
13
14const DEFAULT_DOCKERFILE = "Dockerfile";
15const RAILWAY_BUILD_PLATFORM = "linux/amd64";
16const MAX_CONTEXT_BYTES = 32 * 1024 * 1024;
17const MAX_CONTEXT_ENTRIES = 10_000;
18const TAR_BLOCK_BYTES = 512;
19
20export interface RailwayLocalContextSource {
21 readonly context: string;
22 readonly dockerfilePath?: string;
23}
24
25export class ServiceImageOrMainRequired extends Data.TaggedError(
26 "Railway.ServiceImageOrMainRequired",
27)<{ message: string }> {}
28
29export class ServiceSourceInvalid extends Data.TaggedError(
30 "Railway.ServiceSourceInvalid",
31)<{ message: string }> {}
32
33export class ServiceDockerfilePathInvalid extends Data.TaggedError(
34 "Railway.ServiceDockerfilePathInvalid",
35)<{ dockerfilePath: string; message: string }> {}
36
37export class ServiceContextPathInvalid extends Data.TaggedError(
38 "Railway.ServiceContextPathInvalid",
39)<{ path: string; expected: "directory" | "file" }> {
40 override get message() {
41 return `Railway.Service expected ${this.path} to be an existing ${this.expected}`;
42 }
43}
44
45export class ServiceDockerfileOutsideContext extends Data.TaggedError(
46 "Railway.ServiceDockerfileOutsideContext",
47)<{ context: string; dockerfile: string }> {}
48
49export class ServiceContextSymlinkUnsupported extends Data.TaggedError(
50 "Railway.ServiceContextSymlinkUnsupported",
51)<{ path: string }> {}
52
53export class ServiceContextPathUnsupported extends Data.TaggedError(
54 "Railway.ServiceContextPathUnsupported",
55)<{ path: string }> {}
56
57export class ServiceContextTooLarge extends Data.TaggedError(
58 "Railway.ServiceContextTooLarge",
59)<{ limit: number; size: number }> {}
60
61export type RailwayServiceSource =
62 | { readonly mode: "main"; readonly main: string }
63 | {
64 readonly mode: "context";
65 readonly context: string;
66 readonly dockerfilePath: string | undefined;
67 }
68 | { readonly mode: "image"; readonly image: string }
69 | { readonly mode: "repo"; readonly repo: string };
70
71const nonEmpty = (value: string | undefined): value is string =>
72 value !== undefined && value.length > 0;
73
74export const resolveRailwayServiceSource = Effect.fn(function* (props: {
75 readonly main?: string;
76 readonly context?: string;
77 readonly image?: string;
78 readonly repo?: string;
79 readonly dockerfilePath?: string;
80}) {
81 const main = nonEmpty(props.main) ? props.main : undefined;
82 const context = nonEmpty(props.context) ? props.context : undefined;
83 const image = nonEmpty(props.image) ? props.image : undefined;
84 const repo = nonEmpty(props.repo) ? props.repo : undefined;
85 const modes = [
86 main === undefined ? undefined : "main",
87 context === undefined ? undefined : "context",
88 repo === undefined ? undefined : "repo",
89 main === undefined && image !== undefined ? "image" : undefined,
90 ].filter((mode): mode is RailwayServiceSource["mode"] => mode !== undefined);
91
92 if (modes.length === 0) {
93 return yield* new ServiceImageOrMainRequired({
94 message:
95 "Railway.Service requires `image`, `main`, `context`, or `repo`.",
96 });
97 }
98 if (modes.length !== 1) {
99 return yield* new ServiceSourceInvalid({
100 message:
101 "Railway.Service requires exactly one source: `main`, `context`, `image`, or `repo` (`image` may configure the base image for `main`).",
102 });
103 }
104 switch (modes[0]) {
105 case "main":
106 return { mode: "main", main: main! } as const;
107 case "context":
108 return {
109 mode: "context",
110 context: context!,
111 dockerfilePath: props.dockerfilePath,
112 } as const;
113 case "image":
114 return { mode: "image", image: image! } as const;
115 case "repo":
116 return { mode: "repo", repo: repo! } as const;
117 default:
118 return yield* new ServiceSourceInvalid({
119 message: "Railway.Service source could not be resolved",
120 });
121 }
122});
123
124const resolveBuild = Effect.fn(function* (source: RailwayLocalContextSource) {
125 const fs = yield* FileSystem.FileSystem;
126 const path = yield* Path.Path;
127 const context = path.resolve(initialCwd, source.context);
128 const dockerfilePath = source.dockerfilePath ?? DEFAULT_DOCKERFILE;
129 if (dockerfilePath.length === 0 || path.isAbsolute(dockerfilePath)) {
130 return yield* new ServiceDockerfilePathInvalid({
131 dockerfilePath,
132 message:
133 "Railway.Service `dockerfilePath` must be a non-empty path relative to `context`",
134 });
135 }
136 if (!(yield* fs.exists(context))) {
137 return yield* new ServiceContextPathInvalid({
138 path: context,
139 expected: "directory",
140 });
141 }
142 if (Result.isSuccess(yield* Effect.result(fs.readLink(context)))) {
143 return yield* new ServiceContextSymlinkUnsupported({ path: context });
144 }
145 const contextInfo = yield* fs.stat(context);
146 if (contextInfo.type !== "Directory") {
147 return yield* new ServiceContextPathInvalid({
148 path: context,
149 expected: "directory",
150 });
151 }
152 const dockerfile = path.resolve(context, dockerfilePath);
153 if (!(yield* fs.exists(dockerfile))) {
154 return yield* new ServiceContextPathInvalid({
155 path: dockerfile,
156 expected: "file",
157 });
158 }
159 const selected = yield* selectDockerBuildContext({ context, dockerfile });
160 if (selected.dockerfilePath === undefined) {
161 return yield* new ServiceDockerfileOutsideContext({
162 context,
163 dockerfile,
164 });
165 }
166 const parts = selected.dockerfilePath.split("/");
167 let current = context;
168 for (const part of parts) {
169 current = path.join(current, part);
170 if (Result.isSuccess(yield* Effect.result(fs.readLink(current)))) {
171 return yield* new ServiceContextSymlinkUnsupported({ path: current });
172 }
173 }
174 const dockerfileInfo = yield* fs.stat(dockerfile);
175 if (dockerfileInfo.type !== "File") {
176 return yield* new ServiceContextPathInvalid({
177 path: dockerfile,
178 expected: "file",
179 });
180 }
181 return {
182 build: {
183 context,
184 dockerfile,
185 platform: RAILWAY_BUILD_PLATFORM,
186 },
187 selected,
188 };
189});
190
191const paddedTarBytes = (size: number) =>
192 Math.ceil(size / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES;
193
194const fitsUstarPath = (relativePath: string) => {
195 if (relativePath.length <= 100) return true;
196 for (let index = relativePath.length - 101; index >= 1; index--) {
197 if (relativePath[index] !== "/") continue;
198 const nameLength = relativePath.length - index - 1;
199 if (index <= 155 && nameLength > 0 && nameLength <= 100) return true;
200 }
201 return false;
202};
203
204const tarEntryBytes = (
205 relativePath: string,
206 type: "Directory" | "File",
207 size: number,
208) => {
209 const tarPath = type === "Directory" ? `${relativePath}/` : relativePath;
210 const longNameBytes = fitsUstarPath(tarPath)
211 ? 0
212 : TAR_BLOCK_BYTES + paddedTarBytes(tarPath.length + 1);
213 return (
214 longNameBytes +
215 TAR_BLOCK_BYTES +
216 (type === "File" ? paddedTarBytes(size) : 0)
217 );
218};
219
220const checkContextLimit = (size: number, entries: number) =>
221 size > MAX_CONTEXT_BYTES || entries > MAX_CONTEXT_ENTRIES
222 ? Effect.fail(
223 new ServiceContextTooLarge({
224 limit: MAX_CONTEXT_BYTES,
225 size,
226 }),
227 )
228 : Effect.void;
229
230const selectedEntries = Effect.fn(function* (selected: {
231 context: string;
232 includes: (relativePath: string) => boolean;
233}) {
234 const fs = yield* FileSystem.FileSystem;
235 const path = yield* Path.Path;
236 const entries: Array<{
237 entry: string;
238 sourcePath: string;
239 type: "Directory" | "File";
240 mode: number;
241 }> = [];
242 let size = 2 * TAR_BLOCK_BYTES;
243 let entryCount = 0;
244 for (const entry of (yield* fs.readDirectory(selected.context, {
245 recursive: true,
246 })).sort()) {
247 const relative = entry.replaceAll("\\", "/");
248 if (!selected.includes(relative)) continue;
249 const sourcePath = path.join(selected.context, entry);
250 if (/[^\x20-\x7e]/.test(relative)) {
251 return yield* new ServiceContextPathUnsupported({ path: sourcePath });
252 }
253 if (Result.isSuccess(yield* Effect.result(fs.readLink(sourcePath)))) {
254 return yield* new ServiceContextSymlinkUnsupported({ path: sourcePath });
255 }
256 const info = yield* fs.stat(sourcePath);
257 if (info.type !== "Directory" && info.type !== "File") continue;
258 entryCount += 1;
259 size += tarEntryBytes(relative, info.type, Number(info.size));
260 yield* checkContextLimit(size, entryCount);
261 entries.push({
262 entry,
263 sourcePath,
264 type: info.type,
265 mode: info.mode & 0o7777,
266 });
267 }
268 return entries;
269});
270
271const codeHash = Effect.fn(function* (input: {
272 context: string;
273 dockerfilePath: string;
274}) {
275 const contextHash = yield* hashDockerBuildInputs(
276 {
277 context: input.context,
278 dockerfile: input.dockerfilePath,
279 platform: RAILWAY_BUILD_PLATFORM,
280 },
281 "effective",
282 );
283 return (yield* sha256Object({
284 contextHash,
285 dockerfilePath: input.dockerfilePath,
286 })).slice(0, 16);
287});
288
289export const hashRailwayLocalContext = Effect.fn(function* (
290 source: RailwayLocalContextSource,
291) {
292 const { build, selected } = yield* resolveBuild(source);
293 yield* selectedEntries(selected);
294 return yield* codeHash({
295 context: build.context,
296 dockerfilePath: selected.dockerfilePath!,
297 });
298});
299
300export const prepareRailwayLocalContext = Effect.fn(function* (
301 source: RailwayLocalContextSource,
302) {
303 const fs = yield* FileSystem.FileSystem;
304 const path = yield* Path.Path;
305 const { selected } = yield* resolveBuild(source);
306 const snapshot = yield* fs.makeTempDirectoryScoped({
307 prefix: "alchemy-railway-context-",
308 });
309 const entries = yield* selectedEntries(selected);
310 let copiedSize = 2 * TAR_BLOCK_BYTES;
311 let copiedEntries = 0;
312 for (const entry of entries) {
313 const destination = path.join(snapshot, entry.entry);
314 copiedEntries += 1;
315 if (entry.type === "Directory") {
316 copiedSize += tarEntryBytes(entry.entry, entry.type, 0);
317 yield* checkContextLimit(copiedSize, copiedEntries);
318 yield* fs.makeDirectory(destination, { recursive: true });
319 yield* fs.chmod(destination, entry.mode);
320 } else {
321 yield* fs.makeDirectory(path.dirname(destination), { recursive: true });
322 const contents = yield* fs.readFile(entry.sourcePath);
323 copiedSize += tarEntryBytes(entry.entry, entry.type, contents.byteLength);
324 yield* checkContextLimit(copiedSize, copiedEntries);
325 yield* fs.writeFile(destination, contents);
326 yield* fs.chmod(destination, entry.mode);
327 }
328 }
329 const dockerfilePath = selected.dockerfilePath!;
330 const hash = yield* codeHash({ context: snapshot, dockerfilePath });
331 const tarball = yield* tarGzipDirectory(snapshot, { preserveMode: true });
332 return { codeHash: hash, dockerfilePath, tarball };
333});