Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions packages/app/src/docker-git/controller-health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { FetchHttpClient, HttpClient } from "@effect/platform"
import * as ParseResult from "@effect/schema/ParseResult"
import * as Schema from "@effect/schema/Schema"
import { Effect, Either } from "effect"

import { buildApiBaseUrlCandidates, resolveApiPort, resolveConfiguredApiBaseUrl } from "./controller-reachability.js"
import type { ControllerBootstrapError } from "./host-errors.js"

type HealthProbeResult = {
readonly apiBaseUrl: string
readonly revision: string | null
}

const HealthProbeBodySchema = Schema.Struct({
revision: Schema.optional(Schema.String)
})

const HealthProbeBodyFromStringSchema = Schema.parseJson(HealthProbeBodySchema)

const controllerBootstrapError = (message: string): ControllerBootstrapError => ({
_tag: "ControllerBootstrapError",
message
})

const parseHealthRevision = (text: string): string | null =>
Either.match(ParseResult.decodeUnknownEither(HealthProbeBodyFromStringSchema)(text), {
onLeft: () => null,
onRight: (body) => {
const revision = body.revision
return revision !== undefined && revision.trim().length > 0 ? revision.trim() : null
}
})

const probeHealth = (apiBaseUrl: string): Effect.Effect<HealthProbeResult, ControllerBootstrapError> =>
Effect.gen(function*(_) {
const client = yield* _(HttpClient.HttpClient)
const response = yield* _(client.get(`${apiBaseUrl}/health`, { headers: { accept: "application/json" } }))
const bodyText = yield* _(response.text)

if (response.status >= 200 && response.status < 300) {
return {
apiBaseUrl,
revision: parseHealthRevision(bodyText)
}
}

return yield* _(
Effect.fail(
controllerBootstrapError(
`docker-git controller health returned ${response.status} at ${apiBaseUrl}/health`
)
)
)
}).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.mapError((error): ControllerBootstrapError =>
error._tag === "ControllerBootstrapError"
? error
: {
_tag: "ControllerBootstrapError",
message: `docker-git controller health probe failed at ${apiBaseUrl}/health\nDetails: ${String(error)}`
}
)
)

const findReachableHealthProbe = (
candidateUrls: ReadonlyArray<string>
): Effect.Effect<HealthProbeResult, ControllerBootstrapError> =>
Effect.gen(function*(_) {
if (candidateUrls.length === 0) {
return yield* _(
Effect.fail(controllerBootstrapError("No docker-git controller endpoint candidates were generated."))
)
}

for (const candidateUrl of candidateUrls) {
const healthy = yield* _(probeHealth(candidateUrl).pipe(Effect.either))
if (Either.isRight(healthy)) {
return healthy.right
}
}

return yield* _(Effect.fail(controllerBootstrapError("No docker-git controller endpoint responded to /health.")))
})

const findReachableHealthProbeOrNull = (
candidateUrls: ReadonlyArray<string>
): Effect.Effect<HealthProbeResult | null> =>
findReachableHealthProbe(candidateUrls).pipe(
Effect.match({
onFailure: () => null,
onSuccess: (probe) => probe
})
)

export const findReachableApiBaseUrl = (
candidateUrls: ReadonlyArray<string>
): Effect.Effect<string, ControllerBootstrapError> =>
findReachableHealthProbe(candidateUrls).pipe(Effect.map(({ apiBaseUrl }) => apiBaseUrl))

export const findReachableDirectHealthProbe = (options: {
readonly explicitApiBaseUrl: string | undefined
readonly cachedApiBaseUrl: string | undefined
}): Effect.Effect<HealthProbeResult | null> =>
findReachableHealthProbeOrNull(
buildApiBaseUrlCandidates({
explicitApiBaseUrl: options.explicitApiBaseUrl,
cachedApiBaseUrl: options.cachedApiBaseUrl,
defaultApiBaseUrl: resolveConfiguredApiBaseUrl(),
currentContainerNetworks: {},
controllerNetworks: {},
port: resolveApiPort()
})
)
147 changes: 25 additions & 122 deletions packages/app/src/docker-git/controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { Duration, Effect, pipe, Schedule } from "effect"

import {
Expand All @@ -13,6 +12,7 @@ import {
resolveCurrentContainerNetworks,
runCompose
} from "./controller-docker.js"
import { findReachableApiBaseUrl, findReachableDirectHealthProbe } from "./controller-health.js"
import {
buildApiBaseUrlCandidates,
type DockerNetworkIps,
Expand All @@ -31,11 +31,6 @@ export { buildApiBaseUrlCandidates, isRemoteDockerHost } from "./controller-reac

let selectedApiBaseUrl: string | undefined

type HealthProbeResult = {
readonly apiBaseUrl: string
readonly revision: string | null
}

const controllerBootstrapError = (message: string): ControllerBootstrapError => ({
_tag: "ControllerBootstrapError",
message
Expand All @@ -48,84 +43,6 @@ const rememberSelectedApiBaseUrl = (value: string): void => {
export const resolveApiBaseUrl = (): string =>
resolveExplicitApiBaseUrl() ?? selectedApiBaseUrl ?? resolveConfiguredApiBaseUrl()

const parseHealthRevision = (text: string): string | null => {
try {
const parsed: unknown = JSON.parse(text)
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return null
}
const revision = Reflect.get(parsed, "revision")
return typeof revision === "string" && revision.trim().length > 0 ? revision.trim() : null
} catch {
return null
}
}

const probeHealth = (apiBaseUrl: string): Effect.Effect<HealthProbeResult, ControllerBootstrapError> =>
Effect.gen(function*(_) {
const client = yield* _(HttpClient.HttpClient)
const response = yield* _(client.get(`${apiBaseUrl}/health`, { headers: { accept: "application/json" } }))
const bodyText = yield* _(response.text)

if (response.status >= 200 && response.status < 300) {
return {
apiBaseUrl,
revision: parseHealthRevision(bodyText)
}
}

return yield* _(
Effect.fail(
controllerBootstrapError(
`docker-git controller health returned ${response.status} at ${apiBaseUrl}/health`
)
)
)
}).pipe(
Effect.provide(FetchHttpClient.layer),
Effect.mapError((error): ControllerBootstrapError =>
error._tag === "ControllerBootstrapError"
? error
: {
_tag: "ControllerBootstrapError",
message: `docker-git controller health probe failed at ${apiBaseUrl}/health\nDetails: ${String(error)}`
}
)
)

const findReachableApiBaseUrl = (
candidateUrls: ReadonlyArray<string>
): Effect.Effect<string, ControllerBootstrapError> =>
findReachableHealthProbe(candidateUrls).pipe(Effect.map(({ apiBaseUrl }) => apiBaseUrl))

const findReachableHealthProbe = (
candidateUrls: ReadonlyArray<string>
): Effect.Effect<HealthProbeResult, ControllerBootstrapError> =>
Effect.gen(function*(_) {
if (candidateUrls.length === 0) {
return yield* _(
Effect.fail(controllerBootstrapError("No docker-git controller endpoint candidates were generated."))
)
}

for (const candidateUrl of candidateUrls) {
const healthy = yield* _(
probeHealth(candidateUrl).pipe(
Effect.match({
onFailure: () => undefined,
onSuccess: (result) => result
})
)
)

if (healthy !== undefined) {
return healthy
}
}

return yield* _(Effect.fail(controllerBootstrapError("No docker-git controller endpoint responded to /health.")))
})

const collectReachabilityDiagnostics = (
candidateUrls: ReadonlyArray<string>,
currentContainerNetworks: DockerNetworkIps,
Expand Down Expand Up @@ -193,40 +110,16 @@ const failIfRemoteDockerWithoutApiUrl = (): Effect.Effect<void, ControllerBootst
)
}

const findReachableApiBaseUrlOption = (
const findReachableApiBaseUrlOrNull = (
candidateUrls: ReadonlyArray<string>
): Effect.Effect<string | undefined, ControllerBootstrapError> =>
): Effect.Effect<string | null> =>
findReachableApiBaseUrl(candidateUrls).pipe(
Effect.match({
onFailure: (): string | undefined => undefined,
onFailure: () => null,
onSuccess: (apiBaseUrl) => apiBaseUrl
})
)

const findReachableHealthProbeOption = (
candidateUrls: ReadonlyArray<string>
): Effect.Effect<HealthProbeResult | undefined, ControllerBootstrapError> =>
findReachableHealthProbe(candidateUrls).pipe(
Effect.match({
onFailure: (): HealthProbeResult | undefined => undefined,
onSuccess: (probe) => probe
})
)

const findReachableDirectHealthProbe = (
explicitApiBaseUrl: string | undefined
): Effect.Effect<HealthProbeResult | undefined, ControllerBootstrapError> =>
findReachableHealthProbeOption(
buildApiBaseUrlCandidates({
explicitApiBaseUrl,
cachedApiBaseUrl: selectedApiBaseUrl,
defaultApiBaseUrl: resolveConfiguredApiBaseUrl(),
currentContainerNetworks: {},
controllerNetworks: {},
port: resolveApiPort()
})
)

const failIfExplicitApiUrlIsUnreachable = (
explicitApiBaseUrl: string | undefined
): Effect.Effect<void, ControllerBootstrapError> =>
Expand Down Expand Up @@ -294,15 +187,15 @@ const buildBootstrapCandidateUrls = (
const reuseReachableControllerIfPossible = (
context: ControllerBootstrapContext
): Effect.Effect<boolean, ControllerBootstrapError> =>
findReachableApiBaseUrlOption(
findReachableApiBaseUrlOrNull(
buildBootstrapCandidateUrls(
context.explicitApiBaseUrl,
context.currentContainerNetworks,
context.initialControllerNetworks
)
).pipe(
Effect.map((reachableApiBaseUrl) => {
if (reachableApiBaseUrl === undefined || context.forceRecreateController) {
if (reachableApiBaseUrl === null || context.forceRecreateController) {
return false
}
rememberSelectedApiBaseUrl(reachableApiBaseUrl)
Expand Down Expand Up @@ -362,22 +255,32 @@ export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrap
yield* _(failIfRemoteDockerWithoutApiUrl())
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
const localControllerRevision = yield* _(prepareLocalControllerRevision())
if (explicitApiBaseUrl !== undefined) {
const reachableBeforeDocker = yield* _(findReachableDirectHealthProbe(explicitApiBaseUrl))
if (reachableBeforeDocker !== undefined) {
if (explicitApiBaseUrl === undefined) {
const reachableBeforeDocker = yield* _(
findReachableDirectHealthProbe({
explicitApiBaseUrl,
cachedApiBaseUrl: selectedApiBaseUrl
})
)
if (
reachableBeforeDocker !== null &&
reachableBeforeDocker.revision === localControllerRevision
) {
rememberSelectedApiBaseUrl(reachableBeforeDocker.apiBaseUrl)
return
}
yield* _(failIfExplicitApiUrlIsUnreachable(explicitApiBaseUrl))
} else {
const reachableBeforeDocker = yield* _(findReachableDirectHealthProbe(undefined))
if (
reachableBeforeDocker !== undefined &&
reachableBeforeDocker.revision === localControllerRevision
) {
const reachableBeforeDocker = yield* _(
findReachableDirectHealthProbe({
explicitApiBaseUrl,
cachedApiBaseUrl: selectedApiBaseUrl
})
)
if (reachableBeforeDocker !== null) {
rememberSelectedApiBaseUrl(reachableBeforeDocker.apiBaseUrl)
return
}
yield* _(failIfExplicitApiUrlIsUnreachable(explicitApiBaseUrl))
}

const bootstrapContext = yield* _(loadControllerBootstrapContext())
Expand Down
Loading