| 1 | import * as Data from "effect/Data"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import * as Result from "effect/Result"; |
| 6 | import { |
| 7 | hashDockerBuildInputs, |
| 8 | selectDockerBuildContext, |
| 9 | } from "../Docker/BuildHash.ts"; |
| 10 | import { initialCwd } from "../Util/Node.ts"; |
| 11 | import { sha256Object } from "../Util/sha256.ts"; |
| 12 | import { tarGzipDirectory } from "../Util/tarGzip.ts"; |
| 13 | |
| 14 | const DEFAULT_DOCKERFILE = "Dockerfile"; |
| 15 | const RAILWAY_BUILD_PLATFORM = "linux/amd64"; |
| 16 | const MAX_CONTEXT_BYTES = 32 * 1024 * 1024; |
| 17 | const MAX_CONTEXT_ENTRIES = 10_000; |
| 18 | const TAR_BLOCK_BYTES = 512; |
| 19 | |
| 20 | export interface RailwayLocalContextSource { |
| 21 | readonly context: string; |
| 22 | readonly dockerfilePath?: string; |
| 23 | } |
| 24 | |
| 25 | export class ServiceImageOrMainRequired extends Data.TaggedError( |
| 26 | "Railway.ServiceImageOrMainRequired", |
| 27 | )<{ message: string }> {} |
| 28 | |
| 29 | export class ServiceSourceInvalid extends Data.TaggedError( |
| 30 | "Railway.ServiceSourceInvalid", |
| 31 | )<{ message: string }> {} |
| 32 | |
| 33 | export class ServiceDockerfilePathInvalid extends Data.TaggedError( |
| 34 | "Railway.ServiceDockerfilePathInvalid", |
| 35 | )<{ dockerfilePath: string; message: string }> {} |
| 36 | |
| 37 | export 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 | |
| 45 | export class ServiceDockerfileOutsideContext extends Data.TaggedError( |
| 46 | "Railway.ServiceDockerfileOutsideContext", |
| 47 | )<{ context: string; dockerfile: string }> {} |
| 48 | |
| 49 | export class ServiceContextSymlinkUnsupported extends Data.TaggedError( |
| 50 | "Railway.ServiceContextSymlinkUnsupported", |
| 51 | )<{ path: string }> {} |
| 52 | |
| 53 | export class ServiceContextPathUnsupported extends Data.TaggedError( |
| 54 | "Railway.ServiceContextPathUnsupported", |
| 55 | )<{ path: string }> {} |
| 56 | |
| 57 | export class ServiceContextTooLarge extends Data.TaggedError( |
| 58 | "Railway.ServiceContextTooLarge", |
| 59 | )<{ limit: number; size: number }> {} |
| 60 | |
| 61 | export 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 | |
| 71 | const nonEmpty = (value: string | undefined): value is string => |
| 72 | value !== undefined && value.length > 0; |
| 73 | |
| 74 | export 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 | |
| 124 | const 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 | |
| 191 | const paddedTarBytes = (size: number) => |
| 192 | Math.ceil(size / TAR_BLOCK_BYTES) * TAR_BLOCK_BYTES; |
| 193 | |
| 194 | const 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 | |
| 204 | const 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 | |
| 220 | const 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 | |
| 230 | const 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 | |
| 271 | const 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 | |
| 289 | export 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 | |
| 300 | export 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 | }); |