the-moby-effect
Version:
Moby/Docker API client built using effect-ts
688 lines (613 loc) • 28.9 kB
text/typescript
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;
};