redesign checkDependencies api

This commit is contained in:
Aiden McClelland
2024-08-08 15:54:46 -06:00
parent 058bfe0737
commit 0e598660b4
4 changed files with 274 additions and 179 deletions

View File

@@ -75,7 +75,10 @@ import * as T from "./types"
import { testTypeVersion, ValidateExVer } from "./exver" import { testTypeVersion, ValidateExVer } from "./exver"
import { ExposedStorePaths } from "./store/setupExposeStore" import { ExposedStorePaths } from "./store/setupExposeStore"
import { PathBuilder, extractJsonPath, pathBuilder } from "./store/PathBuilder" import { PathBuilder, extractJsonPath, pathBuilder } from "./store/PathBuilder"
import { checkAllDependencies } from "./dependencies/dependencies" import {
CheckDependencies,
checkDependencies,
} from "./dependencies/dependencies"
import { health } from "." import { health } from "."
import { GetSslCertificate } from "./util/GetSslCertificate" import { GetSslCertificate } from "./util/GetSslCertificate"
@@ -142,7 +145,13 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
} }
return { return {
checkAllDependencies, checkDependencies: checkDependencies as <
DependencyId extends keyof Manifest["dependencies"] &
PackageId = keyof Manifest["dependencies"] & PackageId,
>(
effects: Effects,
packageIds?: DependencyId[],
) => Promise<CheckDependencies<DependencyId>>,
serviceInterface: { serviceInterface: {
getOwn: <E extends Effects>(effects: E, id: ServiceInterfaceId) => getOwn: <E extends Effects>(effects: E, id: ServiceInterfaceId) =>
removeCallbackTypes<E>(effects)( removeCallbackTypes<E>(effects)(

View File

@@ -1,131 +1,206 @@
import { ExtendedVersion, VersionRange } from "../exver"
import { import {
Effects, Effects,
PackageId, PackageId,
DependencyRequirement, DependencyRequirement,
SetHealth, SetHealth,
CheckDependenciesResult, CheckDependenciesResult,
HealthCheckId,
} from "../types" } from "../types"
export type CheckAllDependencies = { export type CheckDependencies<DependencyId extends PackageId = PackageId> = {
notInstalled: () => Promise<CheckDependenciesResult[]> installedSatisfied: (packageId: DependencyId) => boolean
notRunning: () => Promise<CheckDependenciesResult[]> installedVersionSatisfied: (packageId: DependencyId) => boolean
configNotSatisfied: () => Promise<CheckDependenciesResult[]> runningSatisfied: (packageId: DependencyId) => boolean
healthErrors: () => Promise<{ [id: string]: SetHealth[] }> configSatisfied: (packageId: DependencyId) => boolean
healthCheckSatisfied: (
packageId: DependencyId,
healthCheckId: HealthCheckId,
) => boolean
satisfied: () => boolean
isValid: () => Promise<boolean> throwIfInstalledNotSatisfied: (packageId: DependencyId) => void
throwIfInstalledVersionNotSatisfied: (packageId: DependencyId) => void
throwIfNotRunning: () => Promise<void> throwIfRunningNotSatisfied: (packageId: DependencyId) => void
throwIfNotInstalled: () => Promise<void> throwIfConfigNotSatisfied: (packageId: DependencyId) => void
throwIfConfigNotSatisfied: () => Promise<void> throwIfHealthNotSatisfied: (
throwIfHealthError: () => Promise<void> packageId: DependencyId,
healthCheckId?: HealthCheckId,
throwIfNotValid: () => Promise<void> ) => void
throwIfNotSatisfied: (packageId?: DependencyId) => void
} }
export function checkAllDependencies(effects: Effects): CheckAllDependencies { export async function checkDependencies<
const dependenciesPromise = effects.getDependencies() DependencyId extends PackageId = PackageId,
const resultsPromise = dependenciesPromise.then((dependencies) => >(
effects: Effects,
packageIds?: DependencyId[],
): Promise<CheckDependencies<DependencyId>> {
let [dependencies, results] = await Promise.all([
effects.getDependencies(),
effects.checkDependencies({ effects.checkDependencies({
packageIds: dependencies.map((dep) => dep.id), packageIds,
}), }),
) ])
if (packageIds) {
const dependenciesByIdPromise = dependenciesPromise.then((d) => dependencies = dependencies.filter((d) =>
d.reduce( (packageIds as PackageId[]).includes(d.id),
(acc, dep) => {
acc[dep.id] = dep
return acc
},
{} as { [id: PackageId]: DependencyRequirement },
),
)
const healthErrors = async () => {
const results = await resultsPromise
const dependenciesById = await dependenciesByIdPromise
const answer: { [id: PackageId]: SetHealth[] } = {}
for (const result of results) {
const dependency = dependenciesById[result.packageId]
if (!dependency) continue
if (dependency.kind !== "running") continue
const healthChecks = Object.entries(result.healthChecks)
.map(([id, hc]) => ({ ...hc, id }))
.filter((x) => !!x.message)
if (healthChecks.length === 0) continue
answer[result.packageId] = healthChecks
}
return answer
}
const configNotSatisfied = () =>
resultsPromise.then((x) => x.filter((x) => !x.configSatisfied))
const notInstalled = () =>
resultsPromise.then((x) => x.filter((x) => !x.isInstalled))
const notRunning = async () => {
const results = await resultsPromise
const dependenciesById = await dependenciesByIdPromise
return results.filter((x) => {
const dependency = dependenciesById[x.packageId]
if (!dependency) return false
if (dependency.kind !== "running") return false
return !x.isRunning
})
}
const entries = <B>(x: { [k: string]: B }) => Object.entries(x)
const first = <A>(x: A[]): A | undefined => x[0]
const sinkVoid = <A>(x: A) => void 0
const throwIfHealthError = () =>
healthErrors()
.then(entries)
.then(first)
.then((x) => {
if (!x) return
const [id, healthChecks] = x
if (healthChecks.length > 0)
throw `Package ${id} has the following errors: ${healthChecks.map((x) => x.message).join(", ")}`
})
const throwIfConfigNotSatisfied = () =>
configNotSatisfied().then((results) => {
throw new Error(
`Package ${results[0].packageId} does not have a valid configuration`,
)
})
const throwIfNotRunning = () =>
notRunning().then((results) => {
if (results[0])
throw new Error(`Package ${results[0].packageId} is not running`)
})
const throwIfNotInstalled = () =>
notInstalled().then((results) => {
if (results[0])
throw new Error(`Package ${results[0].packageId} is not installed`)
})
const throwIfNotValid = async () =>
Promise.all([
throwIfNotRunning(),
throwIfNotInstalled(),
throwIfConfigNotSatisfied(),
throwIfHealthError(),
]).then(sinkVoid)
const isValid = () =>
throwIfNotValid().then(
() => true,
() => false,
) )
}
const find = (packageId: DependencyId) => {
const dependencyRequirement = dependencies.find((d) => d.id === packageId)
const dependencyResult = results.find((d) => d.packageId === packageId)
if (!dependencyRequirement || !dependencyResult) {
throw new Error(`Unknown DependencyId ${packageId}`)
}
return { requirement: dependencyRequirement, result: dependencyResult }
}
const installedSatisfied = (packageId: DependencyId) =>
!!find(packageId).result.installedVersion
const installedVersionSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
return (
!!dep.result.installedVersion &&
ExtendedVersion.parse(dep.result.installedVersion).satisfies(
VersionRange.parse(dep.requirement.versionRange),
)
)
}
const runningSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
return dep.requirement.kind !== "running" || dep.result.isRunning
}
const configSatisfied = (packageId: DependencyId) =>
find(packageId).result.configSatisfied
const healthCheckSatisfied = (
packageId: DependencyId,
healthCheckId?: HealthCheckId,
) => {
const dep = find(packageId)
if (
healthCheckId &&
(dep.requirement.kind !== "running" ||
!dep.requirement.healthChecks.includes(healthCheckId))
) {
throw new Error(`Unknown HealthCheckId ${healthCheckId}`)
}
const errors = Object.entries(dep.result.healthChecks)
.filter(([id, _]) => (healthCheckId ? id === healthCheckId : true))
.filter(([_, res]) => res.result !== "success")
return errors.length === 0
}
const pkgSatisfied = (packageId: DependencyId) =>
installedSatisfied(packageId) &&
installedVersionSatisfied(packageId) &&
runningSatisfied(packageId) &&
configSatisfied(packageId) &&
healthCheckSatisfied(packageId)
const satisfied = (packageId?: DependencyId) =>
packageId
? pkgSatisfied(packageId)
: dependencies.every((d) => pkgSatisfied(d.id as DependencyId))
const throwIfInstalledNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
if (!dep.result.installedVersion) {
throw new Error(`${dep.result.title || packageId} is not installed`)
}
}
const throwIfInstalledVersionNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
if (!dep.result.installedVersion) {
throw new Error(`${dep.result.title || packageId} is not installed`)
}
if (
![dep.result.installedVersion, ...dep.result.satisfies].find((v) =>
ExtendedVersion.parse(v).satisfies(
VersionRange.parse(dep.requirement.versionRange),
),
)
) {
throw new Error(
`Installed version ${dep.result.installedVersion} of ${dep.result.title || packageId} does not match expected version range ${dep.requirement.versionRange}`,
)
}
}
const throwIfRunningNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
if (dep.requirement.kind === "running" && !dep.result.isRunning) {
throw new Error(`${dep.result.title || packageId} is not running`)
}
}
const throwIfConfigNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
if (!dep.result.configSatisfied) {
throw new Error(
`${dep.result.title || packageId}'s configuration does not satisfy requirements`,
)
}
}
const throwIfHealthNotSatisfied = (
packageId: DependencyId,
healthCheckId?: HealthCheckId,
) => {
const dep = find(packageId)
if (
healthCheckId &&
(dep.requirement.kind !== "running" ||
!dep.requirement.healthChecks.includes(healthCheckId))
) {
throw new Error(`Unknown HealthCheckId ${healthCheckId}`)
}
const errors = Object.entries(dep.result.healthChecks)
.filter(([id, _]) => (healthCheckId ? id === healthCheckId : true))
.filter(([_, res]) => res.result !== "success")
if (errors.length) {
throw new Error(
errors
.map(
([_, e]) =>
`Health Check ${e.name} of ${dep.result.title || packageId} failed with status ${e.result}${e.message ? `: ${e.message}` : ""}`,
)
.join("; "),
)
}
}
const throwIfPkgNotSatisfied = (packageId: DependencyId) => {
throwIfInstalledNotSatisfied(packageId)
throwIfInstalledVersionNotSatisfied(packageId)
throwIfRunningNotSatisfied(packageId)
throwIfConfigNotSatisfied(packageId)
throwIfHealthNotSatisfied(packageId)
}
const throwIfNotSatisfied = (packageId?: DependencyId) =>
packageId
? throwIfPkgNotSatisfied(packageId)
: (() => {
const err = dependencies.flatMap((d) => {
try {
throwIfPkgNotSatisfied(d.id as DependencyId)
} catch (e) {
if (e instanceof Error) return [e.message]
throw e
}
return []
})
if (err.length) {
throw new Error(err.join("; "))
}
})()
return { return {
notRunning, installedSatisfied,
notInstalled, installedVersionSatisfied,
configNotSatisfied, runningSatisfied,
healthErrors, configSatisfied,
throwIfNotRunning, healthCheckSatisfied,
satisfied,
throwIfInstalledNotSatisfied,
throwIfInstalledVersionNotSatisfied,
throwIfRunningNotSatisfied,
throwIfConfigNotSatisfied, throwIfConfigNotSatisfied,
throwIfNotValid, throwIfHealthNotSatisfied,
throwIfNotInstalled, throwIfNotSatisfied,
throwIfHealthError,
isValid,
} }
} }

View File

@@ -44,7 +44,7 @@ type Not = {
} }
export class VersionRange { export class VersionRange {
private constructor(private atom: Anchor | And | Or | Not | P.Any | P.None) {} private constructor(public atom: Anchor | And | Or | Not | P.Any | P.None) {}
toString(): string { toString(): string {
switch (this.atom.type) { switch (this.atom.type) {
@@ -63,67 +63,6 @@ export class VersionRange {
} }
} }
/**
* Returns a boolean indicating whether a given version satisfies the VersionRange
* !( >= 1:1 <= 2:2) || <=#bitcoin:1.2.0-alpha:0
*/
satisfiedBy(version: ExtendedVersion): boolean {
switch (this.atom.type) {
case "Anchor":
const otherVersion = this.atom.version
switch (this.atom.operator) {
case "=":
return version.equals(otherVersion)
case ">":
return version.greaterThan(otherVersion)
case "<":
return version.lessThan(otherVersion)
case ">=":
return version.greaterThanOrEqual(otherVersion)
case "<=":
return version.lessThanOrEqual(otherVersion)
case "!=":
return !version.equals(otherVersion)
case "^":
const nextMajor = this.atom.version.incrementMajor()
if (
version.greaterThanOrEqual(otherVersion) &&
version.lessThan(nextMajor)
) {
return true
} else {
return false
}
case "~":
const nextMinor = this.atom.version.incrementMinor()
if (
version.greaterThanOrEqual(otherVersion) &&
version.lessThan(nextMinor)
) {
return true
} else {
return false
}
}
case "And":
return (
this.atom.left.satisfiedBy(version) &&
this.atom.right.satisfiedBy(version)
)
case "Or":
return (
this.atom.left.satisfiedBy(version) ||
this.atom.right.satisfiedBy(version)
)
case "Not":
return !this.atom.value.satisfiedBy(version)
case "Any":
return true
case "None":
return false
}
}
private static parseAtom(atom: P.VersionRangeAtom): VersionRange { private static parseAtom(atom: P.VersionRangeAtom): VersionRange {
switch (atom.type) { switch (atom.type) {
case "Not": case "Not":
@@ -207,6 +146,10 @@ export class VersionRange {
static none() { static none() {
return new VersionRange({ type: "None" }) return new VersionRange({ type: "None" })
} }
satisfiedBy(version: Version | ExtendedVersion) {
return version.satisfies(this)
}
} }
export class Version { export class Version {
@@ -266,6 +209,12 @@ export class Version {
const parsed = P.parse(version, { startRule: "Version" }) const parsed = P.parse(version, { startRule: "Version" })
return new Version(parsed.number, parsed.prerelease) return new Version(parsed.number, parsed.prerelease)
} }
satisfies(versionRange: VersionRange): boolean {
return new ExtendedVersion(null, this, new Version([0], [])).satisfies(
versionRange,
)
}
} }
// #flavor:0.1.2-beta.1:0 // #flavor:0.1.2-beta.1:0
@@ -404,6 +353,67 @@ export class ExtendedVersion {
updatedDownstream, updatedDownstream,
) )
} }
/**
* Returns a boolean indicating whether a given version satisfies the VersionRange
* !( >= 1:1 <= 2:2) || <=#bitcoin:1.2.0-alpha:0
*/
satisfies(versionRange: VersionRange): boolean {
switch (versionRange.atom.type) {
case "Anchor":
const otherVersion = versionRange.atom.version
switch (versionRange.atom.operator) {
case "=":
return this.equals(otherVersion)
case ">":
return this.greaterThan(otherVersion)
case "<":
return this.lessThan(otherVersion)
case ">=":
return this.greaterThanOrEqual(otherVersion)
case "<=":
return this.lessThanOrEqual(otherVersion)
case "!=":
return !this.equals(otherVersion)
case "^":
const nextMajor = versionRange.atom.version.incrementMajor()
if (
this.greaterThanOrEqual(otherVersion) &&
this.lessThan(nextMajor)
) {
return true
} else {
return false
}
case "~":
const nextMinor = versionRange.atom.version.incrementMinor()
if (
this.greaterThanOrEqual(otherVersion) &&
this.lessThan(nextMinor)
) {
return true
} else {
return false
}
}
case "And":
return (
this.satisfies(versionRange.atom.left) &&
this.satisfies(versionRange.atom.right)
)
case "Or":
return (
this.satisfies(versionRange.atom.left) ||
this.satisfies(versionRange.atom.right)
)
case "Not":
return !this.satisfies(versionRange.atom.value)
case "Any":
return true
case "None":
return false
}
}
} }
export const testTypeExVer = <T extends string>(t: T & ValidateExVer<T>) => t export const testTypeExVer = <T extends string>(t: T & ValidateExVer<T>) => t

View File

@@ -5,9 +5,10 @@ import type { PackageId } from "./PackageId"
export type CheckDependenciesResult = { export type CheckDependenciesResult = {
packageId: PackageId packageId: PackageId
isInstalled: boolean title: string | null
installedVersion: string | null
satisfies: string[]
isRunning: boolean isRunning: boolean
configSatisfied: boolean configSatisfied: boolean
healthChecks: { [key: HealthCheckId]: NamedHealthCheckResult } healthChecks: { [key: HealthCheckId]: NamedHealthCheckResult }
version: string | null
} }