UNPKG

the-moby-effect

Version:
688 lines (613 loc) 28.9 kB
import type * as ParseResult from "effect/ParseResult"; import type * as Scope from "effect/Scope"; import type * as DockerComposeEngine from "../../DockerComposeEngine.js"; import * as PlatformError from "@effect/platform/Error"; import * as Socket from "@effect/platform/Socket"; import * as Array from "effect/Array"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Predicate from "effect/Predicate"; import * as Record from "effect/Record"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as String from "effect/String"; import * as Tuple from "effect/Tuple"; import * as DockerEngine from "../../DockerEngine.js"; import * as MobyDemux from "../../MobyDemux.js"; import * as MobyEndpoints from "../../MobyEndpoints.js"; /** @internal */ export const DockerComposeErrorTypeId: DockerComposeEngine.DockerComposeErrorTypeId = Symbol.for( "@the-moby-effect/engines/DockerCompose/DockerComposeError" ) as DockerComposeEngine.DockerComposeErrorTypeId; /** @internal */ export const TypeId: DockerComposeEngine.TypeId = Symbol.for( "@the-moby-effect/engines/DockerCompose" ) as DockerComposeEngine.TypeId; /** @internal */ export const DockerComposeProjectTypeId: DockerComposeEngine.DockerComposeProjectTypeId = Symbol.for( "@the-moby-effect/engines/DockerComposeProject" ) as DockerComposeEngine.DockerComposeProjectTypeId; /** @internal */ export const isDockerComposeError = (u: unknown): u is DockerComposeError => Predicate.hasProperty(u, DockerComposeErrorTypeId); /** @internal */ export class DockerComposeError extends PlatformError.TypeIdError(DockerComposeErrorTypeId, "DockerComposeError")<{ method: string; cause: ParseResult.ParseError | Socket.SocketError | MobyEndpoints.ExecsError | unknown; }> { get message() { return `${this.method}`; } } /** @internal */ export const DockerCompose: Context.Tag<DockerComposeEngine.DockerCompose, DockerComposeEngine.DockerCompose> = Context.GenericTag<DockerComposeEngine.DockerCompose>("@the-moby-effect/engines/DockerCompose"); /** @internal */ export const make: Effect.Effect< DockerComposeEngine.DockerCompose, MobyEndpoints.SystemsError | MobyEndpoints.ContainersError, MobyEndpoints.Containers | MobyEndpoints.Systems | Scope.Scope > = Effect.gen(function* () { const containers = yield* MobyEndpoints.Containers; yield* DockerEngine.pingHead(); const dindContainerId = yield* DockerEngine.runScoped({ spec: { Image: "docker.io/library/docker:latest", Entrypoint: ["/bin/sh"], Tty: false, OpenStdin: true, AttachStdin: true, AttachStdout: true, AttachStderr: true, HostConfig: { Privileged: true, Binds: ["/var/run/docker.sock:/var/run/docker.sock"], }, }, }).pipe(Effect.map(({ Id }) => Id)); // Helper to upload the project to the dind const uploadProject = <E1>( project: Stream.Stream<Uint8Array, E1, never> ): Effect.Effect<void, E1 | DockerComposeError, never> => Effect.catchTag( containers.putArchive(dindContainerId, { path: "/", stream: project }), "ContainersError", (cause) => new DockerComposeError({ method: "uploadProject", cause }) ); // Convert a string from camelCase to kebab-case const camelToKebab = Function.compose(String.camelToSnake, String.snakeToKebab); // Stringify options for a command const stringifyOptions = ( options: Record< string, | string | number | boolean | undefined | Array<string | number | boolean> | Record<string, string | number | boolean> > ): string => Function.pipe( options, Record.toEntries, Array.filter(Function.flow(Tuple.getSecond, Predicate.isNotUndefined)), Array.flatMap(([key, value]): Array<readonly [string, string | number | boolean]> => { if (Predicate.isString(value) || Predicate.isNumber(value) || Predicate.isBoolean(value)) { return [Tuple.make(key, value)]; } else if (Array.isArray(value)) { return Array.map(value, (v) => [key, v]); } else if (Predicate.isRecord(value)) { return Record.toEntries(value); } else { return Function.absurd(value as never); } }), Array.map(([key, value]) => `--${camelToKebab(key)}=${value}`), Array.join(" ") ); // Helper to run a command in the dind const runCommand = ( method: string, services: ReadonlyArray<string> | undefined = [], options: | Record< string, | string | number | boolean | undefined | Array<string | number | boolean> | Record<string, string | number | boolean> > | undefined = {} ): Stream.Stream<Uint8Array, DockerComposeError, never> => Effect.gen(function* () { const multiplexed = yield* DockerEngine.execWebsocketsNonBlocking({ containerId: dindContainerId, command: `COMPOSE_STATUS_STDOUT=1 docker compose ${method} ${stringifyOptions(options)} ${Array.join(services, " ")}`, }); const { stderr, stdin, stdout } = yield* MobyDemux.fan(multiplexed, { requestedCapacity: 16 }); const streamFailableEnsuring = <A, X, E1, E2, R1, R2>( stream: Stream.Stream<A, E1, R1>, effect: Effect.Effect<X, E2, R2> ): Stream.Stream<A, E1 | E2, R1 | R2> => Stream.flatMap(stream, (a: A) => Stream.fromEffect(Effect.map(effect, Function.constant(a)))); return streamFailableEnsuring( MobyDemux.mergeRawToTaggedStream(stdout, stderr), MobyDemux.demuxRawToSingleSink(stdin, Stream.make(new Socket.CloseEvent()), Sink.drain) ); }).pipe( Stream.unwrap, Stream.mapEffect((entry) => { if (Predicate.isTagged(entry, "stdout")) { return Effect.succeed(entry.value); } else { const decoded = new TextDecoder("utf-8").decode(entry.value); return Effect.fail(new DockerComposeError({ method, cause: decoded })); } }), Stream.mapError((cause) => new DockerComposeError({ method, cause })), Stream.provideService(MobyEndpoints.Containers, containers) ); // Actual compose implementation return DockerCompose.of({ [TypeId]: TypeId, build: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.BuildOptions | undefined ): Stream.Stream<string, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("build", services, { ...options })), Stream.unwrap, Stream.decodeText(), Stream.splitLines ), config: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.ConfigOptions | undefined ): Effect.Effect<string, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("config", services, { ...options })), Stream.unwrap, Stream.decodeText(), Stream.splitLines, Stream.run(Sink.mkString) ), // cp: <E1>( // project: Stream.Stream<Uint8Array, E1, never>, // service: string, // srcPath: string, // destPath: string, // options?: DockerComposeEngine.CopyOptions | undefined // ): Stream.Stream<Uint8Array, E1 | DockerComposeError, never> => {}, create: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.CreateOptions | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("create", services, { ...options })), Stream.unwrap, Stream.runDrain ), down: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.DownOptions | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("down", services, { ...options })), Stream.unwrap, Stream.runDrain ), // events: <E1>( // project: Stream.Stream<Uint8Array, E1, never>, // services?: Array<string> | undefined, // options?: DockerComposeEngine.EventsOptions | undefined // ): Stream.Stream<Uint8Array, E1 | DockerComposeError, never> => {}, // exec: <E1>( // project: Stream.Stream<Uint8Array, E1, never>, // service: string, // command: string, // args?: Array<string> | undefined, // options?: DockerComposeEngine.ExecOptions | undefined // ): Stream.Stream<Uint8Array, E1 | DockerComposeError, never> => {}, images: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.ImagesOptions | undefined ): Stream.Stream<string, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("images", services, { ...options })), Stream.unwrap, Stream.decodeText(), Stream.splitLines ), kill: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.KillOptions | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("kill", services, { ...options })), Stream.unwrap, Stream.runDrain ), logs: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.LogsOptions | undefined ): Stream.Stream<string, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("logs", services, { ...options })), Stream.unwrap, Stream.decodeText(), Stream.splitLines ), ls: <E1>( project: Stream.Stream<Uint8Array, E1, never>, options?: DockerComposeEngine.ListOptions | undefined ): Stream.Stream<string, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("ls", [], { ...options })), Stream.unwrap, Stream.decodeText(), Stream.splitLines ), pause: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("pause", services)), Stream.unwrap, Stream.runDrain ), // port: <E1>( // project: Stream.Stream<Uint8Array, E1, never>, // service: string, // privatePort: number, // options?: DockerComposeEngine.PortOptions | undefined // ): Effect.Effect<number, E1 | DockerComposeError, never> => {}, ps: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.PsOptions | undefined ): Stream.Stream<string, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("ps", services, { ...options })), Stream.unwrap, Stream.decodeText(), Stream.splitLines ), pull: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.PullOptions | undefined ): Stream.Stream<string, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("pull", services, { ...options })), Stream.unwrap, Stream.decodeText(), Stream.splitLines ), push: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.PushOptions | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("push", services, { ...options })), Stream.unwrap, Stream.runDrain ), restart: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.RestartOptions | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("restart", services, { ...options })), Stream.unwrap, Stream.runDrain ), rm: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.RmOptions | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("rm", services, { ...options })), Stream.unwrap, Stream.runDrain ), // run: <E1>( // project: Stream.Stream<Uint8Array, E1, never>, // service: string, // command: string, // args?: Array<string> | undefined, // options?: DockerComposeEngine.RunOptions | undefined // ): Stream.Stream<Uint8Array, E1 | DockerComposeError, never> => {}, start: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("start", services)), Stream.unwrap, Stream.runDrain ), stop: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.StopOptions | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("stop", services, { ...options })), Stream.unwrap, Stream.runDrain ), top: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined ): Stream.Stream<string, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("top", services)), Stream.unwrap, Stream.decodeText(), Stream.splitLines ), unpause: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("unpause", services)), Stream.unwrap, Stream.runDrain ), up: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services?: Array<string> | undefined, options?: DockerComposeEngine.UpOptions | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("up", services, { ...options })), Stream.unwrap, Stream.runDrain ), version: <E1>( project: Stream.Stream<Uint8Array, E1, never>, options?: DockerComposeEngine.VersionOptions | undefined ): Effect.Effect<string, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("version", [], { ...options })), Stream.unwrap, Stream.decodeText(), Stream.run(Sink.mkString) ), wait: <E1>( project: Stream.Stream<Uint8Array, E1, never>, services: Array.NonEmptyReadonlyArray<string>, options?: DockerComposeEngine.WaitOptions | undefined ): Effect.Effect<void, E1 | DockerComposeError, never> => Function.pipe( uploadProject(project), Effect.map(() => runCommand("wait", services, { ...options })), Stream.unwrap, Stream.runDrain ), forProject: <E1>( project: Stream.Stream<Uint8Array, E1, never> ): Effect.Effect<DockerComposeEngine.DockerComposeProject, E1 | DockerComposeError, never> => makeProjectLayer(project, uploadProject, runCommand), }); }); /** @internal */ export const layer: Layer.Layer< DockerComposeEngine.DockerCompose, MobyEndpoints.SystemsError | MobyEndpoints.ContainersError, Layer.Layer.Success<DockerEngine.DockerLayer> > = Layer.scoped(DockerCompose, make); /** @internal */ export const makeProjectLayer = <E1>( project: Stream.Stream<Uint8Array, E1, never>, uploadProject: ( project: Stream.Stream<Uint8Array, E1, never> ) => Effect.Effect<void, E1 | DockerComposeError, never>, runCommand: ( method: string, services?: ReadonlyArray<string> | undefined, options?: | Record< string, | string | number | boolean | undefined | Array<string | number | boolean> | Record<string, string | number | boolean> > | undefined ) => Stream.Stream<Uint8Array, DockerComposeError, never> ): Effect.Effect<DockerComposeEngine.DockerComposeProject, E1 | DockerComposeError, never> => Effect.gen(function* () { yield* uploadProject(project); return { [DockerComposeProjectTypeId]: DockerComposeProjectTypeId, build: ( services?: Array<string> | undefined, options?: DockerComposeEngine.BuildOptions | undefined ): Stream.Stream<string, DockerComposeError, never> => Function.pipe(runCommand("build", services, { ...options }), Stream.decodeText(), Stream.splitLines), config: ( services?: Array<string> | undefined, options?: DockerComposeEngine.ConfigOptions | undefined ): Effect.Effect<string, DockerComposeError, never> => Function.pipe( runCommand("config", services, { ...options }), Stream.decodeText(), Stream.splitLines, Stream.run(Sink.mkString) ), // cp: ( // // service: string, // srcPath: string, // destPath: string, // options?: DockerComposeEngine.CopyOptions | undefined // ): Stream.Stream<Uint8Array, DockerComposeError, never> => {}, create: ( services?: Array<string> | undefined, options?: DockerComposeEngine.CreateOptions | undefined ): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("create", services, { ...options }), Stream.runDrain), down: ( services?: Array<string> | undefined, options?: DockerComposeEngine.DownOptions | undefined ): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("down", services, { ...options }), Stream.runDrain), // events: ( // // services?: Array<string> | undefined, // options?: DockerComposeEngine.EventsOptions | undefined // ): Stream.Stream<Uint8Array, DockerComposeError, never> => {}, // exec: ( // // service: string, // command: string, // args?: Array<string> | undefined, // options?: DockerComposeEngine.ExecOptions | undefined // ): Stream.Stream<Uint8Array, DockerComposeError, never> => {}, images: ( services?: Array<string> | undefined, options?: DockerComposeEngine.ImagesOptions | undefined ): Stream.Stream<string, DockerComposeError, never> => Function.pipe(runCommand("images", services, { ...options }), Stream.decodeText(), Stream.splitLines), kill: ( services?: Array<string> | undefined, options?: DockerComposeEngine.KillOptions | undefined ): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("kill", services, { ...options }), Stream.runDrain), logs: ( services?: Array<string> | undefined, options?: DockerComposeEngine.LogsOptions | undefined ): Stream.Stream<string, DockerComposeError, never> => Function.pipe(runCommand("logs", services, { ...options }), Stream.decodeText(), Stream.splitLines), ls: ( options?: DockerComposeEngine.ListOptions | undefined ): Stream.Stream<string, DockerComposeError, never> => Function.pipe(runCommand("ls", [], { ...options }), Stream.decodeText(), Stream.splitLines), pause: (services?: Array<string> | undefined): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("pause", services), Stream.runDrain), // port: ( // // service: string, // privatePort: number, // options?: DockerComposeEngine.PortOptions | undefined // ): Effect.Effect<number, DockerComposeError, never> => {}, ps: ( services?: Array<string> | undefined, options?: DockerComposeEngine.PsOptions | undefined ): Stream.Stream<string, DockerComposeError, never> => Function.pipe(runCommand("ps", services, { ...options }), Stream.decodeText(), Stream.splitLines), pull: ( services?: Array<string> | undefined, options?: DockerComposeEngine.PullOptions | undefined ): Stream.Stream<string, DockerComposeError, never> => Function.pipe(runCommand("pull", services, { ...options }), Stream.decodeText(), Stream.splitLines), push: ( services?: Array<string> | undefined, options?: DockerComposeEngine.PushOptions | undefined ): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("push", services, { ...options }), Stream.runDrain), restart: ( services?: Array<string> | undefined, options?: DockerComposeEngine.RestartOptions | undefined ): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("restart", services, { ...options }), Stream.runDrain), rm: ( services?: Array<string> | undefined, options?: DockerComposeEngine.RmOptions | undefined ): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("rm", services, { ...options }), Stream.runDrain), // run: ( // // service: string, // command: string, // args?: Array<string> | undefined, // options?: DockerComposeEngine.RunOptions | undefined // ): Stream.Stream<Uint8Array, DockerComposeError, never> => {}, start: (services?: Array<string> | undefined): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("start", services), Stream.runDrain), stop: ( services?: Array<string> | undefined, options?: DockerComposeEngine.StopOptions | undefined ): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("stop", services, { ...options }), Stream.runDrain), top: (services?: Array<string> | undefined): Stream.Stream<string, DockerComposeError, never> => Function.pipe(runCommand("top", services), Stream.decodeText(), Stream.splitLines), unpause: (services?: Array<string> | undefined): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("unpause", services), Stream.runDrain), up: ( services?: Array<string> | undefined, options?: DockerComposeEngine.UpOptions | undefined ): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("up", services, { ...options }), Stream.runDrain), version: ( options?: DockerComposeEngine.VersionOptions | undefined ): Effect.Effect<string, DockerComposeError, never> => Function.pipe( runCommand("version", [], { ...options }), Stream.decodeText(), Stream.run(Sink.mkString) ), wait: ( services: Array.NonEmptyReadonlyArray<string>, options?: DockerComposeEngine.WaitOptions | undefined ): Effect.Effect<void, DockerComposeError, never> => Function.pipe(runCommand("wait", services, { ...options }), Stream.runDrain), }; }); /** @internal */ export const layerProject: <E1>( project: Stream.Stream<Uint8Array, E1, never>, tagIdentifier: string ) => { readonly tag: Context.Tag<DockerComposeEngine.DockerComposeProject, DockerComposeEngine.DockerComposeProject>; readonly layer: Layer.Layer< DockerComposeEngine.DockerComposeProject, E1 | DockerComposeError, DockerComposeEngine.DockerCompose >; } = <E1>(project: Stream.Stream<Uint8Array, E1, never>, tagIdentifier: string) => { const tag = Context.GenericTag<DockerComposeEngine.DockerComposeProject>(tagIdentifier); const effect = Effect.flatMap(DockerCompose, ({ forProject }) => forProject(project)); const layer = Layer.effect(tag, effect); return { tag, layer } as const; };