Skip to content

Commit e635acd

Browse files
committed
fix(app): make controller image revision inspection best effort
1 parent 834b994 commit e635acd

2 files changed

Lines changed: 122 additions & 2 deletions

File tree

packages/app/src/docker-git/controller-image-revision.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ const inspectControllerComposeImageName = (): Effect.Effect<
166166
*
167167
* @pure false
168168
* @effect Docker CLI through ControllerRuntime.
169-
* @invariant Missing image or missing label resolves to null rather than throwing.
169+
* @invariant Missing or ambiguous compose image output resolves to null rather than throwing.
170170
* @precondition Docker is reachable through the configured runtime.
171171
* @postcondition Returned revision is normalized by label parsing.
172172
* @complexity O(1) Docker inspections.
@@ -180,14 +180,15 @@ const inspectControllerComposeImageName = (): Effect.Effect<
180180
// FORMAT THEOREM: image_label(image) = local_revision -> no rebuild is required
181181
// PURITY: SHELL
182182
// EFFECT: Effect<string | null, ControllerBootstrapError, ControllerRuntime>
183-
// INVARIANT: missing image or missing label resolves to null rather than throwing
183+
// INVARIANT: missing or unresolvable image metadata resolves to null rather than throwing
184184
// COMPLEXITY: O(1) Docker inspections
185185
export const inspectControllerImageRevision = (): Effect.Effect<
186186
string | null,
187187
ControllerBootstrapError,
188188
ControllerRuntime
189189
> =>
190190
inspectControllerComposeImageName().pipe(
191+
Effect.orElseSucceed((): string | null => null),
191192
Effect.flatMap((imageName) =>
192193
imageName === null
193194
? Effect.succeed<string | null>(null)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import * as Command from "@effect/platform/Command"
2+
import * as CommandExecutor from "@effect/platform/CommandExecutor"
3+
import * as FileSystem from "@effect/platform/FileSystem"
4+
import * as Path from "@effect/platform/Path"
5+
import { describe, expect, it } from "@effect/vitest"
6+
import { Effect, Layer } from "effect"
7+
import * as Inspectable from "effect/Inspectable"
8+
import * as Sink from "effect/Sink"
9+
import * as Stream from "effect/Stream"
10+
11+
import { inspectControllerImageRevision } from "../../src/docker-git/controller-image-revision.js"
12+
13+
type TestCommandResult = {
14+
readonly exitCode: number
15+
readonly stderr: string
16+
readonly stdout: string
17+
}
18+
19+
const emptyCommandResult: TestCommandResult = {
20+
exitCode: 0,
21+
stderr: "",
22+
stdout: ""
23+
}
24+
25+
const encodeText = (value: string): Uint8Array => new TextEncoder().encode(value)
26+
27+
const textStream = (value: string) => value.length === 0 ? Stream.empty : Stream.succeed(encodeText(value))
28+
29+
/**
30+
* Builds a completed process for controller image revision shell tests.
31+
*
32+
* @param result - Command result emitted by the fake process.
33+
* @returns A completed Effect platform process.
34+
* @pure true
35+
* @effect none
36+
* @invariant The process is already stopped and its exit code is deterministic.
37+
* @precondition `result.stdout` and `result.stderr` are finite strings.
38+
* @postcondition Consumers observe exactly the provided stdout, stderr, and exit code.
39+
* @complexity O(n) time and O(n) space where n = |stdout| + |stderr|.
40+
* @throws Never
41+
*/
42+
// CHANGE: model Docker CLI process output without touching the host Docker daemon
43+
// WHY: image revision fallback invariants must be unit-testable without external services
44+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
45+
// REF: CodeRabbit PR #344 review 4349211730
46+
// SOURCE: n/a
47+
// FORMAT THEOREM: process(result).stdout = result.stdout and process(result).exit = result.exitCode
48+
// PURITY: CORE
49+
// EFFECT: none
50+
// INVARIANT: fake process is not running after construction
51+
// COMPLEXITY: O(n)
52+
const completedProcess = (result: TestCommandResult): CommandExecutor.Process => ({
53+
[CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId,
54+
[Inspectable.NodeInspectSymbol]: () => ({ _tag: "TestProcess" }),
55+
exitCode: Effect.succeed(CommandExecutor.ExitCode(result.exitCode)),
56+
isRunning: Effect.succeed(false),
57+
kill: () => Effect.void,
58+
pid: CommandExecutor.ProcessId(0),
59+
stderr: textStream(result.stderr),
60+
stdin: Sink.drain,
61+
stdout: textStream(result.stdout),
62+
toJSON: () => ({ _tag: "TestProcess" }),
63+
toString: () => "TestProcess"
64+
})
65+
66+
type TestCommandHandler = (command: Command.StandardCommand) => TestCommandResult
67+
68+
/**
69+
* Creates a command-executor layer backed by a pure command handler.
70+
*
71+
* @param handler - Total handler for standard commands.
72+
* @returns Layer providing CommandExecutor.
73+
* @pure true
74+
* @effect none
75+
* @invariant Every started command maps to exactly one completed fake process.
76+
* @precondition The handler is total for all commands issued by the test subject.
77+
* @postcondition Command effects never reach the real operating system.
78+
* @complexity O(1) layer construction.
79+
* @throws Never
80+
*/
81+
// CHANGE: provide typed Effect dependency injection for Docker command tests
82+
// WHY: controller image revision inspection is a shell effect and must be tested through its service boundary
83+
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
84+
// REF: CodeRabbit PR #344 review 4349211730
85+
// SOURCE: n/a
86+
// FORMAT THEOREM: start(command) = completedProcess(handler(command))
87+
// PURITY: SHELL
88+
// EFFECT: Layer<CommandExecutor>
89+
// INVARIANT: no command escapes the fake executor
90+
// COMPLEXITY: O(1)
91+
const commandExecutorLayer = (handler: TestCommandHandler) =>
92+
Layer.succeed(
93+
CommandExecutor.CommandExecutor,
94+
CommandExecutor.makeExecutor((command) => {
95+
const standardCommand = Command.flatten(command)[0]
96+
return Effect.succeed(completedProcess(handler(standardCommand)))
97+
})
98+
)
99+
100+
describe("controller image revision", () => {
101+
it.effect("falls back to null when compose image resolution is ambiguous", () =>
102+
Effect.gen(function*(_) {
103+
const revision = yield* _(
104+
inspectControllerImageRevision().pipe(
105+
Effect.provide(
106+
commandExecutorLayer((command) =>
107+
command.command === "docker" && command.args.includes("--images")
108+
? { exitCode: 0, stderr: "", stdout: "app-api:latest\nanother-image:latest\n" }
109+
: emptyCommandResult
110+
)
111+
),
112+
Effect.provide(FileSystem.layerNoop({})),
113+
Effect.provide(Path.layer)
114+
)
115+
)
116+
117+
expect(revision).toBeNull()
118+
}))
119+
})

0 commit comments

Comments
 (0)