import { Injectable } from '@angular/core' import { Exver } from '@start9labs/shared' import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators' import { PatchDB } from 'patch-db-client' import { DataModel, InstalledState, PackageDataEntry, } from './patch-db/data-model' import deepEqual from 'fast-deep-equal' import { Observable } from 'rxjs' import { isInstalled } from 'src/app/utils/get-package-data' import { T } from '@start9labs/start-sdk' import { getInstalledBaseStatus } from './pkg-status-rendering.service' export type AllDependencyErrors = Record export type PkgDependencyErrors = Record export type DependencyError = | DependencyErrorNotInstalled | DependencyErrorNotRunning | DependencyErrorIncorrectVersion | DependencyErrorTaskRequired | DependencyErrorHealthChecksFailed | DependencyErrorTransitive export type DependencyErrorNotInstalled = { type: 'notInstalled' } export type DependencyErrorNotRunning = { type: 'notRunning' } export type DependencyErrorIncorrectVersion = { type: 'incorrectVersion' expected: string // version range received: string // version } export interface DependencyErrorTaskRequired { type: 'taskRequired' } export type DependencyErrorHealthChecksFailed = { type: 'healthChecksFailed' check?: T.NamedHealthCheckResult } export type DependencyErrorTransitive = { type: 'transitive' } @Injectable({ providedIn: 'root', }) export class DepErrorService { readonly depErrors$: Observable = this.patch .watch$('packageData') .pipe( map(pkgs => Object.keys(pkgs) .map(id => ({ id, depth: dependencyDepth(pkgs, id), })) .sort((a, b) => (b.depth > a.depth ? -1 : 1)) .reduce( (errors, { id }): AllDependencyErrors => ({ ...errors, [id]: this.getDepErrors(pkgs, id, errors), }), {} as AllDependencyErrors, ), ), distinctUntilChanged(deepEqual), shareReplay(1), ) constructor( private readonly exver: Exver, private readonly patch: PatchDB, ) {} getPkgDepErrors$(pkgId: string): Observable { return this.depErrors$.pipe( map(depErrors => depErrors[pkgId]), distinctUntilChanged(deepEqual), ) } private getDepErrors( pkgs: DataModel['packageData'], pkgId: string, outerErrors: AllDependencyErrors, ): PkgDependencyErrors { const pkg = pkgs[pkgId] if (!pkg || !isInstalled(pkg)) return {} return currentDeps(pkgs, pkgId).reduce( (innerErrors, depId): PkgDependencyErrors => ({ ...innerErrors, [depId]: this.getDepError(pkgs, pkg, depId, outerErrors), }), {} as PkgDependencyErrors, ) } private getDepError( pkgs: DataModel['packageData'], pkg: PackageDataEntry, depId: string, outerErrors: AllDependencyErrors, ): DependencyError | null { const dep = pkgs[depId] // not installed if (!dep || dep.stateInfo.state !== 'installed') { return { type: 'notInstalled', } } const currentDep = pkg.currentDependencies[depId] const depManifest = dep.stateInfo.manifest const expected = currentDep?.versionRange || '' // incorrect version if ( !this.exver.satisfies(depManifest.version, expected) && !depManifest.satisfies.some(v => this.exver.satisfies(v, expected)) ) { return { expected, type: 'incorrectVersion', received: depManifest.version, } } // action required if ( Object.values(pkg.tasks) .filter(t => !!t) .some( t => t.active && t.task.packageId === depId && t.task.severity === 'critical', ) ) { return { type: 'taskRequired', } } const depStatus = getInstalledBaseStatus(dep.statusInfo) if (currentDep?.kind === 'running') { // not running if (depStatus !== 'running' && depStatus !== 'starting') { return { type: 'notRunning', } } // health check failure for (let id of currentDep.healthChecks) { const check = dep.statusInfo.health[id] if (check?.result !== 'success') { return { type: 'healthChecksFailed', check, } } } } // transitive const transitiveError = currentDeps(pkgs, depId).some(transitiveId => Object.values(outerErrors[transitiveId] || {}).some(err => !!err), ) if (transitiveError) { return { type: 'transitive', } } return null } } function currentDeps(pkgs: DataModel['packageData'], id: string): string[] { return Object.keys(pkgs[id]?.currentDependencies || {}).filter( depId => depId !== id, ) } function dependencyDepth( pkgs: DataModel['packageData'], id: string, depth = 0, ): number { return currentDeps(pkgs, id).reduce( (prev, depId) => dependencyDepth(pkgs, depId, prev + 1), depth, ) }