Skip to content

Commit 55b1cce

Browse files
committed
fix(app): split controller bootstrap helpers
1 parent eb22a6b commit 55b1cce

4 files changed

Lines changed: 383 additions & 488 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { Effect } from "effect"
2+
3+
import { shouldBuildControllerImage } from "./controller-bootstrap-plan.js"
4+
import * as ControllerDocker from "./controller-docker.js"
5+
import { findReachableApiBaseUrl } from "./controller-health.js"
6+
import { inspectControllerImageRevision } from "./controller-image-revision.js"
7+
import {
8+
buildApiBaseUrlCandidates,
9+
type DockerNetworkIps,
10+
resolveApiPort,
11+
resolveConfiguredApiBaseUrl,
12+
resolveDefaultLocalApiBaseUrl,
13+
resolveExplicitApiBaseUrl
14+
} from "./controller-reachability.js"
15+
import {
16+
prepareControllerResourceLimitEnv,
17+
shouldForceRecreateForControllerResourceLimits
18+
} from "./controller-resource-limits-shell.js"
19+
import { shouldForceRecreateController } from "./controller-revision.js"
20+
import { prepareControllerRuntimeEnv } from "./controller-runtime-shell.js"
21+
import type { ControllerBootstrapError } from "./host-errors.js"
22+
23+
type ControllerEffect<A> = Effect.Effect<A, ControllerBootstrapError, ControllerDocker.ControllerRuntime>
24+
25+
export type ControllerBootstrapContext = {
26+
readonly explicitApiBaseUrl: string | undefined
27+
readonly localControllerRevision: string
28+
readonly currentControllerRevision: string | null
29+
readonly currentImageRevision: string | null
30+
readonly isControllerRunning: boolean
31+
readonly buildController: boolean
32+
readonly forceRecreateController: boolean
33+
readonly currentContainerNetworks: DockerNetworkIps
34+
readonly initialControllerNetworks: DockerNetworkIps
35+
}
36+
37+
type BuildBootstrapCandidateUrlsInput = {
38+
readonly explicitApiBaseUrl: string | undefined
39+
readonly cachedApiBaseUrl: string | undefined
40+
readonly currentContainerNetworks: DockerNetworkIps
41+
readonly controllerNetworks: DockerNetworkIps
42+
}
43+
44+
const findReachableApiBaseUrlOrNull = (
45+
candidateUrls: ReadonlyArray<string>,
46+
expectedRevision: string | undefined
47+
): Effect.Effect<string | null> =>
48+
findReachableApiBaseUrl(candidateUrls, expectedRevision).pipe(
49+
Effect.match({
50+
onFailure: () => null,
51+
onSuccess: (apiBaseUrl) => apiBaseUrl
52+
})
53+
)
54+
55+
export const loadControllerBootstrapContext = (): ControllerEffect<ControllerBootstrapContext> =>
56+
Effect.gen(function*(_) {
57+
yield* _(prepareControllerRuntimeEnv())
58+
yield* _(prepareControllerResourceLimitEnv())
59+
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
60+
const localControllerRevision = yield* _(ControllerDocker.prepareLocalControllerRevision())
61+
const isCurrentControllerExists = yield* _(ControllerDocker.controllerExists())
62+
const isControllerRunning = isCurrentControllerExists
63+
? yield* _(ControllerDocker.inspectControllerRunning())
64+
: false
65+
const currentControllerRevision = yield* _(ControllerDocker.inspectControllerRevision())
66+
const currentImageRevision = yield* _(inspectControllerImageRevision())
67+
const currentContainerNetworks = yield* _(ControllerDocker.resolveCurrentContainerNetworks())
68+
const initialControllerNetworks = yield* _(
69+
ControllerDocker.inspectContainerNetworks(ControllerDocker.controllerContainerName)
70+
)
71+
const isForceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits()
72+
const isForceRecreateController = isForceRecreateForResourceLimits ||
73+
shouldForceRecreateController(isCurrentControllerExists, localControllerRevision, currentControllerRevision)
74+
75+
return {
76+
explicitApiBaseUrl,
77+
localControllerRevision,
78+
currentControllerRevision,
79+
currentImageRevision,
80+
isControllerRunning,
81+
buildController: shouldBuildControllerImage({
82+
currentControllerRevision,
83+
currentImageRevision,
84+
forceRecreateController: isForceRecreateController,
85+
localControllerRevision
86+
}),
87+
forceRecreateController: isForceRecreateController,
88+
currentContainerNetworks,
89+
initialControllerNetworks
90+
}
91+
})
92+
93+
export const buildBootstrapCandidateUrls = ({
94+
cachedApiBaseUrl,
95+
controllerNetworks,
96+
currentContainerNetworks,
97+
explicitApiBaseUrl
98+
}: BuildBootstrapCandidateUrlsInput): ReadonlyArray<string> =>
99+
buildApiBaseUrlCandidates({
100+
explicitApiBaseUrl,
101+
defaultLocalApiBaseUrl: resolveDefaultLocalApiBaseUrl(),
102+
cachedApiBaseUrl,
103+
defaultApiBaseUrl: resolveConfiguredApiBaseUrl(),
104+
currentContainerNetworks,
105+
controllerNetworks,
106+
port: resolveApiPort()
107+
})
108+
109+
export const upgradeContextForRuntimeDrift = (
110+
context: ControllerBootstrapContext,
111+
cachedApiBaseUrl: string | undefined
112+
): ControllerEffect<ControllerBootstrapContext> =>
113+
Effect.gen(function*(_) {
114+
if (
115+
context.explicitApiBaseUrl !== undefined ||
116+
context.forceRecreateController ||
117+
!context.isControllerRunning ||
118+
context.currentControllerRevision !== context.localControllerRevision
119+
) {
120+
return context
121+
}
122+
123+
const reachableApiBaseUrl = yield* _(
124+
findReachableApiBaseUrlOrNull(
125+
buildBootstrapCandidateUrls({
126+
explicitApiBaseUrl: context.explicitApiBaseUrl,
127+
cachedApiBaseUrl,
128+
currentContainerNetworks: context.currentContainerNetworks,
129+
controllerNetworks: context.initialControllerNetworks
130+
}),
131+
context.localControllerRevision
132+
)
133+
)
134+
135+
if (reachableApiBaseUrl !== null) {
136+
return context
137+
}
138+
139+
yield* _(
140+
Effect.logWarning(
141+
[
142+
"Detected docker-git controller runtime drift.",
143+
`Running container ${ControllerDocker.controllerContainerName} exposes inspect revision ${context.currentControllerRevision}`,
144+
`but no reachable endpoint answered revision ${context.localControllerRevision}.`,
145+
"Forcing container recreation to discard stale writable-layer startup state."
146+
].join(" ")
147+
)
148+
)
149+
150+
const isForceRecreateController = true
151+
return {
152+
...context,
153+
buildController: shouldBuildControllerImage({
154+
currentControllerRevision: context.currentControllerRevision,
155+
currentImageRevision: context.currentImageRevision,
156+
forceRecreateController: isForceRecreateController,
157+
localControllerRevision: context.localControllerRevision
158+
}),
159+
forceRecreateController: isForceRecreateController
160+
}
161+
})
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import type { PlatformError } from "@effect/platform/Error"
3+
import type * as FileSystem from "@effect/platform/FileSystem"
4+
import type * as Path from "@effect/platform/Path"
5+
import { Effect } from "effect"
6+
7+
import { type DockerProbeOutcome, renderDockerAccessDeniedMessage } from "./controller-docker-diagnostics.js"
8+
import { resolveConfiguredApiBaseUrl } from "./controller-reachability.js"
9+
import {
10+
runCommandCaptureWithFailureOutput,
11+
runCommandExitCode,
12+
runCommandWithCapturedOutput
13+
} from "./frontend-lib/shell/command-runner.js"
14+
import type { ControllerBootstrapError } from "./host-errors.js"
15+
16+
export type ControllerDockerCommandRuntime = CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path
17+
18+
const controllerBootstrapError = (message: string): ControllerBootstrapError => ({
19+
_tag: "ControllerBootstrapError",
20+
message
21+
})
22+
23+
const runExitCode = (
24+
command: string,
25+
args: ReadonlyArray<string>
26+
): Effect.Effect<number, never, CommandExecutor.CommandExecutor> =>
27+
runCommandExitCode({
28+
cwd: process.cwd(),
29+
command,
30+
args
31+
}).pipe(
32+
Effect.match({
33+
onFailure: () => 1,
34+
onSuccess: (exitCode) => exitCode
35+
})
36+
)
37+
38+
type ProbeFailure = {
39+
readonly _tag: "ProbeFailure"
40+
readonly outcome: DockerProbeOutcome
41+
}
42+
43+
const captureProbeOutcome = (
44+
command: string,
45+
args: ReadonlyArray<string>
46+
): Effect.Effect<DockerProbeOutcome, never, CommandExecutor.CommandExecutor> =>
47+
runCommandWithCapturedOutput(
48+
{ cwd: process.cwd(), command, args },
49+
[0],
50+
(exitCode, output): ProbeFailure => ({
51+
_tag: "ProbeFailure",
52+
outcome: { exitCode, stderr: output }
53+
})
54+
).pipe(
55+
Effect.match({
56+
onFailure: (error) =>
57+
"outcome" in error
58+
? error.outcome
59+
: { exitCode: 127, stderr: String(error) },
60+
onSuccess: () => ({ exitCode: 0, stderr: "" })
61+
})
62+
)
63+
64+
export const resolveDockerCommand = (): Effect.Effect<
65+
ReadonlyArray<string>,
66+
ControllerBootstrapError,
67+
CommandExecutor.CommandExecutor
68+
> =>
69+
Effect.gen(function*(_) {
70+
const directProbe = yield* _(captureProbeOutcome("docker", ["info"]))
71+
if (directProbe.exitCode === 0) {
72+
return ["docker"]
73+
}
74+
75+
const sudoProbe = yield* _(captureProbeOutcome("sudo", ["-n", "docker", "info"]))
76+
if (sudoProbe.exitCode === 0) {
77+
return ["sudo", "-n", "docker"]
78+
}
79+
80+
const dockerHostRaw = process.env["DOCKER_HOST"]?.trim() ?? ""
81+
const accessDeniedMessage = renderDockerAccessDeniedMessage({
82+
directProbe,
83+
sudoProbe,
84+
apiBaseUrl: resolveConfiguredApiBaseUrl(),
85+
dockerHost: dockerHostRaw.length > 0 ? dockerHostRaw : null
86+
})
87+
return yield* _(Effect.fail(controllerBootstrapError(accessDeniedMessage)))
88+
})
89+
90+
type DockerInvocation = {
91+
readonly command: string
92+
readonly args: ReadonlyArray<string>
93+
}
94+
95+
export const buildDockerInvocation = (
96+
dockerCommand: ReadonlyArray<string>,
97+
args: ReadonlyArray<string>
98+
): DockerInvocation => ({
99+
command: dockerCommand[0] ?? "docker",
100+
args: [...dockerCommand.slice(1), ...args]
101+
})
102+
103+
export const formatDockerInvocationFailure = (
104+
headline: string,
105+
invocation: DockerInvocation,
106+
exitCode: number
107+
): string =>
108+
[
109+
headline,
110+
`Command: ${[invocation.command, ...invocation.args].join(" ")}`,
111+
`Exit code: ${exitCode}`
112+
].join("\n")
113+
114+
const formatDockerInvocationFailureWithOutput = (
115+
headline: string,
116+
invocation: DockerInvocation,
117+
exitCode: number,
118+
output: string
119+
): string =>
120+
[
121+
formatDockerInvocationFailure(headline, invocation, exitCode),
122+
output.trim().length > 0 ? `Output:\n${output.trim()}` : ""
123+
].filter((part) => part.length > 0).join("\n")
124+
125+
const resolveDockerInvocation = (
126+
args: ReadonlyArray<string>
127+
): Effect.Effect<DockerInvocation, ControllerBootstrapError, ControllerDockerCommandRuntime> =>
128+
resolveDockerCommand().pipe(
129+
Effect.map((dockerCommand) => buildDockerInvocation(dockerCommand, args))
130+
)
131+
132+
export const runDockerExitCodeCommand = (
133+
args: ReadonlyArray<string>
134+
): Effect.Effect<number, ControllerBootstrapError, ControllerDockerCommandRuntime> =>
135+
resolveDockerInvocation(args).pipe(
136+
Effect.flatMap((invocation) => runExitCode(invocation.command, invocation.args))
137+
)
138+
139+
const mapDockerCaptureError =
140+
(label: string) => (error: ControllerBootstrapError | PlatformError): ControllerBootstrapError =>
141+
error._tag === "ControllerBootstrapError"
142+
? error
143+
: controllerBootstrapError(`${label} failed.\nDetails: ${String(error)}`)
144+
145+
const formatDockerCaptureFailure = (
146+
label: string,
147+
invocation: DockerInvocation,
148+
exitCode: number,
149+
output: string,
150+
shouldIncludeOutput: boolean
151+
): string =>
152+
shouldIncludeOutput
153+
? formatDockerInvocationFailureWithOutput(`${label} failed.`, invocation, exitCode, output)
154+
: formatDockerInvocationFailure(`${label} failed.`, invocation, exitCode)
155+
156+
const runDockerCaptureWithOutputMode = (
157+
args: ReadonlyArray<string>,
158+
label: string,
159+
shouldIncludeOutput: boolean
160+
): Effect.Effect<string, ControllerBootstrapError, ControllerDockerCommandRuntime> =>
161+
resolveDockerInvocation(args).pipe(
162+
Effect.flatMap((invocation) =>
163+
runCommandCaptureWithFailureOutput(
164+
{
165+
cwd: process.cwd(),
166+
command: invocation.command,
167+
args: invocation.args
168+
},
169+
[0],
170+
(exitCode, output) =>
171+
controllerBootstrapError(formatDockerCaptureFailure(label, invocation, exitCode, output, shouldIncludeOutput))
172+
)
173+
),
174+
Effect.mapError(mapDockerCaptureError(label))
175+
)
176+
177+
export const runDockerCapture = (
178+
args: ReadonlyArray<string>,
179+
label: string
180+
): Effect.Effect<string, ControllerBootstrapError, ControllerDockerCommandRuntime> =>
181+
runDockerCaptureWithOutputMode(args, label, false)
182+
183+
export const runDockerCaptureWithFailureOutput = (
184+
args: ReadonlyArray<string>,
185+
label: string
186+
): Effect.Effect<string, ControllerBootstrapError, ControllerDockerCommandRuntime> =>
187+
runDockerCaptureWithOutputMode(args, label, true)

0 commit comments

Comments
 (0)