diff --git a/sdk/lib/StartSdk.ts b/sdk/lib/StartSdk.ts index 484817b76..c31218208 100644 --- a/sdk/lib/StartSdk.ts +++ b/sdk/lib/StartSdk.ts @@ -75,7 +75,10 @@ import * as T from "./types" import { testTypeVersion, ValidateExVer } from "./exver" import { ExposedStorePaths } from "./store/setupExposeStore" import { PathBuilder, extractJsonPath, pathBuilder } from "./store/PathBuilder" -import { checkAllDependencies } from "./dependencies/dependencies" +import { + CheckDependencies, + checkDependencies, +} from "./dependencies/dependencies" import { health } from "." import { GetSslCertificate } from "./util/GetSslCertificate" @@ -142,7 +145,13 @@ export class StartSdk { } return { - checkAllDependencies, + checkDependencies: checkDependencies as < + DependencyId extends keyof Manifest["dependencies"] & + PackageId = keyof Manifest["dependencies"] & PackageId, + >( + effects: Effects, + packageIds?: DependencyId[], + ) => Promise>, serviceInterface: { getOwn: (effects: E, id: ServiceInterfaceId) => removeCallbackTypes(effects)( diff --git a/sdk/lib/dependencies/dependencies.ts b/sdk/lib/dependencies/dependencies.ts index 28b04a07b..287f63b06 100644 --- a/sdk/lib/dependencies/dependencies.ts +++ b/sdk/lib/dependencies/dependencies.ts @@ -1,131 +1,206 @@ +import { ExtendedVersion, VersionRange } from "../exver" import { Effects, PackageId, DependencyRequirement, SetHealth, CheckDependenciesResult, + HealthCheckId, } from "../types" -export type CheckAllDependencies = { - notInstalled: () => Promise - notRunning: () => Promise - configNotSatisfied: () => Promise - healthErrors: () => Promise<{ [id: string]: SetHealth[] }> +export type CheckDependencies = { + installedSatisfied: (packageId: DependencyId) => boolean + installedVersionSatisfied: (packageId: DependencyId) => boolean + runningSatisfied: (packageId: DependencyId) => boolean + configSatisfied: (packageId: DependencyId) => boolean + healthCheckSatisfied: ( + packageId: DependencyId, + healthCheckId: HealthCheckId, + ) => boolean + satisfied: () => boolean - isValid: () => Promise - - throwIfNotRunning: () => Promise - throwIfNotInstalled: () => Promise - throwIfConfigNotSatisfied: () => Promise - throwIfHealthError: () => Promise - - throwIfNotValid: () => Promise + throwIfInstalledNotSatisfied: (packageId: DependencyId) => void + throwIfInstalledVersionNotSatisfied: (packageId: DependencyId) => void + throwIfRunningNotSatisfied: (packageId: DependencyId) => void + throwIfConfigNotSatisfied: (packageId: DependencyId) => void + throwIfHealthNotSatisfied: ( + packageId: DependencyId, + healthCheckId?: HealthCheckId, + ) => void + throwIfNotSatisfied: (packageId?: DependencyId) => void } -export function checkAllDependencies(effects: Effects): CheckAllDependencies { - const dependenciesPromise = effects.getDependencies() - const resultsPromise = dependenciesPromise.then((dependencies) => +export async function checkDependencies< + DependencyId extends PackageId = PackageId, +>( + effects: Effects, + packageIds?: DependencyId[], +): Promise> { + let [dependencies, results] = await Promise.all([ + effects.getDependencies(), effects.checkDependencies({ - packageIds: dependencies.map((dep) => dep.id), + packageIds, }), - ) - - const dependenciesByIdPromise = dependenciesPromise.then((d) => - d.reduce( - (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 = (x: { [k: string]: B }) => Object.entries(x) - const first = (x: A[]): A | undefined => x[0] - const sinkVoid = (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, + ]) + if (packageIds) { + dependencies = dependencies.filter((d) => + (packageIds as PackageId[]).includes(d.id), ) + } + + 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 { - notRunning, - notInstalled, - configNotSatisfied, - healthErrors, - throwIfNotRunning, + installedSatisfied, + installedVersionSatisfied, + runningSatisfied, + configSatisfied, + healthCheckSatisfied, + satisfied, + throwIfInstalledNotSatisfied, + throwIfInstalledVersionNotSatisfied, + throwIfRunningNotSatisfied, throwIfConfigNotSatisfied, - throwIfNotValid, - throwIfNotInstalled, - throwIfHealthError, - isValid, + throwIfHealthNotSatisfied, + throwIfNotSatisfied, } } diff --git a/sdk/lib/exver/index.ts b/sdk/lib/exver/index.ts index 913194875..012cb532e 100644 --- a/sdk/lib/exver/index.ts +++ b/sdk/lib/exver/index.ts @@ -44,7 +44,7 @@ type Not = { } 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 { 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 { switch (atom.type) { case "Not": @@ -207,6 +146,10 @@ export class VersionRange { static none() { return new VersionRange({ type: "None" }) } + + satisfiedBy(version: Version | ExtendedVersion) { + return version.satisfies(this) + } } export class Version { @@ -266,6 +209,12 @@ export class Version { const parsed = P.parse(version, { startRule: "Version" }) 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 @@ -404,6 +353,67 @@ export class ExtendedVersion { 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: T & ValidateExVer) => t diff --git a/sdk/lib/osBindings/CheckDependenciesResult.ts b/sdk/lib/osBindings/CheckDependenciesResult.ts index de58264bc..a435ff87f 100644 --- a/sdk/lib/osBindings/CheckDependenciesResult.ts +++ b/sdk/lib/osBindings/CheckDependenciesResult.ts @@ -5,9 +5,10 @@ import type { PackageId } from "./PackageId" export type CheckDependenciesResult = { packageId: PackageId - isInstalled: boolean + title: string | null + installedVersion: string | null + satisfies: string[] isRunning: boolean configSatisfied: boolean healthChecks: { [key: HealthCheckId]: NamedHealthCheckResult } - version: string | null }