From c174b654652eedf14dee0bfe46ce6311a27346ff Mon Sep 17 00:00:00 2001 From: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> Date: Thu, 15 Aug 2024 20:58:53 +0000 Subject: [PATCH] create version graph to handle migrations (#2708) * create version graph to handle migrations * Fix some version alpha test * connect dataVersion api * rename init fns * improve types and add tests * set data version after backup restore * chore: Add some types tests for version info * wip: More changes to versionInfo tests * wip: fix my stupid * update mocks * update runtime * chore: Fix the loop --------- Co-authored-by: Jade <2364004+Blu-J@users.noreply.github.com> Co-authored-by: J H --- .../src/Adapters/EffectCreator.ts | 10 + .../src/Adapters/Systems/SystemForStartOs.ts | 4 +- core/startos/src/db/model/package.rs | 5 +- core/startos/src/s9pk/v2/compat.rs | 4 +- core/startos/src/s9pk/v2/manifest.rs | 10 +- core/startos/src/service/effects/mod.rs | 19 ++ core/startos/src/service/effects/store.rs | 49 +++- core/startos/src/service/service_map.rs | 1 + sdk/lib/StartSdk.ts | 49 ++-- sdk/lib/backup/setupBackups.ts | 2 + sdk/lib/exver/index.ts | 7 +- sdk/lib/inits/migrations/Migration.ts | 35 --- sdk/lib/inits/migrations/setupMigrations.ts | 77 ------ sdk/lib/inits/setupInit.ts | 34 ++- sdk/lib/inits/setupInstall.ts | 12 +- sdk/lib/inits/setupUninstall.ts | 2 +- sdk/lib/manifest/ManifestTypes.ts | 13 +- sdk/lib/manifest/setupManifest.ts | 13 +- sdk/lib/osBindings/HardwareRequirements.ts | 2 +- sdk/lib/osBindings/Manifest.ts | 2 + sdk/lib/osBindings/PackageDataEntry.ts | 2 + sdk/lib/osBindings/SetDataVersionParams.ts | 3 + sdk/lib/osBindings/index.ts | 1 + sdk/lib/test/configBuilder.test.ts | 76 +++--- sdk/lib/test/graph.test.ts | 148 +++++++++++ sdk/lib/test/output.sdk.ts | 79 +++--- sdk/lib/test/startosTypeValidation.test.ts | 3 + sdk/lib/types.ts | 9 +- sdk/lib/util/Overlay.ts | 17 ++ sdk/lib/util/graph.ts | 244 ++++++++++++++++++ sdk/lib/versionInfo/VersionInfo.ts | 78 ++++++ sdk/lib/versionInfo/setupVersionGraph.ts | 210 +++++++++++++++ .../ui/src/app/services/api/api.fixures.ts | 9 + .../ui/src/app/services/api/mock-patch.ts | 2 + 34 files changed, 974 insertions(+), 257 deletions(-) delete mode 100644 sdk/lib/inits/migrations/Migration.ts delete mode 100644 sdk/lib/inits/migrations/setupMigrations.ts create mode 100644 sdk/lib/osBindings/SetDataVersionParams.ts create mode 100644 sdk/lib/test/graph.test.ts create mode 100644 sdk/lib/util/graph.ts create mode 100644 sdk/lib/versionInfo/VersionInfo.ts create mode 100644 sdk/lib/versionInfo/setupVersionGraph.ts diff --git a/container-runtime/src/Adapters/EffectCreator.ts b/container-runtime/src/Adapters/EffectCreator.ts index 607e18b02..1c2954cb2 100644 --- a/container-runtime/src/Adapters/EffectCreator.ts +++ b/container-runtime/src/Adapters/EffectCreator.ts @@ -284,6 +284,16 @@ function makeEffects(context: EffectContext): Effects { set: async (options: any) => rpcRound("setStore", options) as ReturnType, } as T.Effects["store"], + getDataVersion() { + return rpcRound("getDataVersion", {}) as ReturnType< + T.Effects["getDataVersion"] + > + }, + setDataVersion(...[options]: Parameters) { + return rpcRound("setDataVersion", options) as ReturnType< + T.Effects["setDataVersion"] + > + }, } return self } diff --git a/container-runtime/src/Adapters/Systems/SystemForStartOs.ts b/container-runtime/src/Adapters/Systems/SystemForStartOs.ts index 1dd0a9744..be7b0fc84 100644 --- a/container-runtime/src/Adapters/Systems/SystemForStartOs.ts +++ b/container-runtime/src/Adapters/Systems/SystemForStartOs.ts @@ -145,9 +145,7 @@ export class SystemForStartOs implements System { ): Promise { switch (options.procedure) { case "/init": { - const previousVersion = - string.optional().unsafeCast(options.input) || null - return this.abi.init({ effects, previousVersion }) + return this.abi.init({ effects }) } case "/uninit": { const nextVersion = string.optional().unsafeCast(options.input) || null diff --git a/core/startos/src/db/model/package.rs b/core/startos/src/db/model/package.rs index cb537a2b5..957e42c54 100644 --- a/core/startos/src/db/model/package.rs +++ b/core/startos/src/db/model/package.rs @@ -3,7 +3,9 @@ use std::collections::{BTreeMap, BTreeSet}; use chrono::{DateTime, Utc}; use exver::VersionRange; use imbl_value::InternedString; -use models::{ActionId, DataUrl, HealthCheckId, HostId, PackageId, ServiceInterfaceId}; +use models::{ + ActionId, DataUrl, HealthCheckId, HostId, PackageId, ServiceInterfaceId, VersionString, +}; use patch_db::json_ptr::JsonPointer; use patch_db::HasModel; use reqwest::Url; @@ -335,6 +337,7 @@ pub struct ActionMetadata { #[ts(export)] pub struct PackageDataEntry { pub state_info: PackageState, + pub data_version: Option, pub status: Status, #[ts(type = "string | null")] pub registry: Option, diff --git a/core/startos/src/s9pk/v2/compat.rs b/core/startos/src/s9pk/v2/compat.rs index 22250419a..970cefb0c 100644 --- a/core/startos/src/s9pk/v2/compat.rs +++ b/core/startos/src/s9pk/v2/compat.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::sync::Arc; -use exver::ExtendedVersion; +use exver::{ExtendedVersion, VersionRange}; use models::ImageId; use tokio::io::{AsyncRead, AsyncSeek, AsyncWriteExt}; use tokio::process::Command; @@ -203,6 +203,8 @@ impl From for Manifest { version: ExtendedVersion::from(value.version).into(), satisfies: BTreeSet::new(), release_notes: value.release_notes, + can_migrate_from: VersionRange::any(), + can_migrate_to: VersionRange::none(), license: value.license.into(), wrapper_repo: value.wrapper_repo, upstream_repo: value.upstream_repo, diff --git a/core/startos/src/s9pk/v2/manifest.rs b/core/startos/src/s9pk/v2/manifest.rs index a10a65ddb..1f24a0b73 100644 --- a/core/startos/src/s9pk/v2/manifest.rs +++ b/core/startos/src/s9pk/v2/manifest.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use color_eyre::eyre::eyre; -use exver::Version; +use exver::{Version, VersionRange}; use helpers::const_true; use imbl_value::InternedString; pub use models::PackageId; @@ -37,6 +37,10 @@ pub struct Manifest { pub satisfies: BTreeSet, pub release_notes: String, #[ts(type = "string")] + pub can_migrate_to: VersionRange, + #[ts(type = "string")] + pub can_migrate_from: VersionRange, + #[ts(type = "string")] pub license: InternedString, // type of license #[ts(type = "string")] pub wrapper_repo: Url, @@ -159,8 +163,8 @@ impl Manifest { #[ts(export)] pub struct HardwareRequirements { #[serde(default)] - #[ts(type = "{ device?: string, processor?: string }")] - pub device: BTreeMap, + #[ts(type = "{ display?: string, processor?: string }")] + pub device: BTreeMap, // TODO: array #[ts(type = "number | null")] pub ram: Option, #[ts(type = "string[] | null")] diff --git a/core/startos/src/service/effects/mod.rs b/core/startos/src/service/effects/mod.rs index 91a12a4d1..a7ee6fb4d 100644 --- a/core/startos/src/service/effects/mod.rs +++ b/core/startos/src/service/effects/mod.rs @@ -164,6 +164,25 @@ pub fn handler() -> ParentHandler { // store .subcommand("getStore", from_fn_async(store::get_store).no_cli()) .subcommand("setStore", from_fn_async(store::set_store).no_cli()) + .subcommand( + "setDataVersion", + from_fn_async(store::set_data_version) + .no_display() + .with_call_remote::(), + ) + .subcommand( + "getDataVersion", + from_fn_async(store::get_data_version) + .with_custom_display_fn(|_, v| { + if let Some(v) = v { + println!("{v}") + } else { + println!("N/A") + } + Ok(()) + }) + .with_call_remote::(), + ) // system .subcommand( "getSystemSmtp", diff --git a/core/startos/src/service/effects/store.rs b/core/startos/src/service/effects/store.rs index ab4484ab6..6c12b425e 100644 --- a/core/startos/src/service/effects/store.rs +++ b/core/startos/src/service/effects/store.rs @@ -1,6 +1,6 @@ use imbl::vector; use imbl_value::json; -use models::PackageId; +use models::{PackageId, VersionString}; use patch_db::json_ptr::JsonPointer; use crate::service::effects::callbacks::CallbackHandler; @@ -91,3 +91,50 @@ pub async fn set_store( Ok(()) } + +#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SetDataVersionParams { + #[ts(type = "string")] + version: VersionString, +} +pub async fn set_data_version( + context: EffectContext, + SetDataVersionParams { version }: SetDataVersionParams, +) -> Result<(), Error> { + let context = context.deref()?; + let package_id = &context.seed.id; + context + .seed + .ctx + .db + .mutate(|db| { + db.as_public_mut() + .as_package_data_mut() + .as_idx_mut(package_id) + .or_not_found(package_id)? + .as_data_version_mut() + .ser(&Some(version)) + }) + .await?; + + Ok(()) +} + +pub async fn get_data_version(context: EffectContext) -> Result, Error> { + let context = context.deref()?; + let package_id = &context.seed.id; + context + .seed + .ctx + .db + .peek() + .await + .as_public() + .as_package_data() + .as_idx(package_id) + .or_not_found(package_id)? + .as_data_version() + .de() +} diff --git a/core/startos/src/service/service_map.rs b/core/startos/src/service/service_map.rs index 90223216c..0e6a959ae 100644 --- a/core/startos/src/service/service_map.rs +++ b/core/startos/src/service/service_map.rs @@ -173,6 +173,7 @@ impl ServiceMap { } else { PackageState::Installing(installing) }, + data_version: None, status: Status { configured: false, main: MainStatus::Stopped, diff --git a/sdk/lib/StartSdk.ts b/sdk/lib/StartSdk.ts index 9989604cf..0b38d90af 100644 --- a/sdk/lib/StartSdk.ts +++ b/sdk/lib/StartSdk.ts @@ -30,7 +30,7 @@ import { healthCheck, HealthCheckParams } from "./health/HealthCheck" import { checkPortListening } from "./health/checkFns/checkPortListening" import { checkWebUrl, runHealthScript } from "./health/checkFns" import { List } from "./config/builder/list" -import { Migration } from "./inits/migrations/Migration" +import { VersionInfo, VersionOptions } from "./versionInfo/VersionInfo" import { Install, InstallFn } from "./inits/setupInstall" import { setupActions } from "./actions/setupActions" import { setupDependencyConfig } from "./dependencies/setupDependencyConfig" @@ -38,9 +38,9 @@ import { SetupBackupsParams, setupBackups } from "./backup/setupBackups" import { setupInit } from "./inits/setupInit" import { EnsureUniqueId, - Migrations, - setupMigrations, -} from "./inits/migrations/setupMigrations" + VersionGraph, + setupVersionGraph, +} from "./versionInfo/setupVersionGraph" import { Uninstall, UninstallFn, setupUninstall } from "./inits/setupUninstall" import { setupMain } from "./mainFn" import { defaultTrigger } from "./trigger/defaultTrigger" @@ -319,7 +319,7 @@ export class StartSdk { setupActions: (...createdActions: CreatedAction[]) => setupActions(...createdActions), setupBackups: (...args: SetupBackupsParams) => - setupBackups(...args), + setupBackups(this.manifest, ...args), setupConfig: < ConfigType extends Config | Config, Type extends Record = ExtractConfigType, @@ -388,7 +388,7 @@ export class StartSdk { } }, setupInit: ( - migrations: Migrations, + versions: VersionGraph, install: Install, uninstall: Uninstall, setInterfaces: SetInterfaces, @@ -399,7 +399,7 @@ export class StartSdk { exposedStore: ExposedStorePaths, ) => setupInit( - migrations, + versions, install, uninstall, setInterfaces, @@ -420,15 +420,13 @@ export class StartSdk { started(onTerm: () => PromiseLike): PromiseLike }) => Promise>, ) => setupMain(fn), - setupMigrations: < - Migrations extends Array>, + setupVersionGraph: < + CurrentVersion extends string, + OtherVersions extends Array>, >( - ...migrations: EnsureUniqueId - ) => - setupMigrations( - this.manifest, - ...migrations, - ), + current: VersionInfo, + ...other: EnsureUniqueId + ) => setupVersionGraph(current, ...other), setupProperties: ( fn: (options: { effects: Effects }) => Promise, @@ -549,12 +547,9 @@ export class StartSdk { >, ) => List.dynamicText(getA), }, - Migration: { - of: (options: { - version: Version & ValidateExVer - up: (opts: { effects: Effects }) => Promise - down: (opts: { effects: Effects }) => Promise - }) => Migration.of(options), + VersionInfo: { + of: (options: VersionOptions) => + VersionInfo.of(options), }, StorePath: pathBuilder(), Value: { @@ -755,15 +750,9 @@ export async function runCommand( }, ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { const commands = splitCommand(command) - const overlay = await Overlay.of(effects, image) - try { - for (let mount of options.mounts || []) { - await overlay.mount(mount.options, mount.path) - } - return await overlay.exec(commands) - } finally { - await overlay.destroy() - } + return Overlay.with(effects, image, options.mounts || [], (overlay) => + overlay.exec(commands), + ) } function nullifyProperties(value: T.SdkPropertiesReturn): T.PropertiesReturn { return Object.fromEntries( diff --git a/sdk/lib/backup/setupBackups.ts b/sdk/lib/backup/setupBackups.ts index 40be01829..c12f1d2ed 100644 --- a/sdk/lib/backup/setupBackups.ts +++ b/sdk/lib/backup/setupBackups.ts @@ -8,6 +8,7 @@ export type SetupBackupsParams = Array< > export function setupBackups( + manifest: M, ...args: _> ) { const backups = Array>() @@ -36,6 +37,7 @@ export function setupBackups( for (const backup of backups) { await backup.build(options.pathMaker).restoreBackup(options) } + await options.effects.setDataVersion({ version: manifest.version }) }) as T.ExpectedExports.restoreBackup }, } diff --git a/sdk/lib/exver/index.ts b/sdk/lib/exver/index.ts index 012cb532e..331271c1a 100644 --- a/sdk/lib/exver/index.ts +++ b/sdk/lib/exver/index.ts @@ -3,7 +3,7 @@ import * as P from "./exver" // prettier-ignore export type ValidateVersion = T extends `-${infer A}` ? never : -T extends `${infer A}-${infer B}` ? ValidateVersion & ValidateVersion : +T extends `${infer A}-${string}` ? ValidateVersion : T extends `${bigint}` ? unknown : T extends `${bigint}.${infer A}` ? ValidateVersion : never @@ -16,9 +16,9 @@ export type ValidateExVer = // prettier-ignore export type ValidateExVers = - T extends [] ? unknown : + T extends [] ? unknown[] : T extends [infer A, ...infer B] ? ValidateExVer & ValidateExVers : - never + never[] type Anchor = { type: "Anchor" @@ -426,6 +426,7 @@ function tests() { testTypeVersion("12.34.56") testTypeVersion("1.2-3") testTypeVersion("1-3") + testTypeVersion("1-alpha") // @ts-expect-error testTypeVersion("-3") // @ts-expect-error diff --git a/sdk/lib/inits/migrations/Migration.ts b/sdk/lib/inits/migrations/Migration.ts deleted file mode 100644 index 16be93dbd..000000000 --- a/sdk/lib/inits/migrations/Migration.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ValidateExVer } from "../../exver" -import * as T from "../../types" - -export class Migration< - Manifest extends T.Manifest, - Store, - Version extends string, -> { - constructor( - readonly options: { - version: Version & ValidateExVer - up: (opts: { effects: T.Effects }) => Promise - down: (opts: { effects: T.Effects }) => Promise - }, - ) {} - static of< - Manifest extends T.Manifest, - Store, - Version extends string, - >(options: { - version: Version & ValidateExVer - up: (opts: { effects: T.Effects }) => Promise - down: (opts: { effects: T.Effects }) => Promise - }) { - return new Migration(options) - } - - async up(opts: { effects: T.Effects }) { - this.up(opts) - } - - async down(opts: { effects: T.Effects }) { - this.down(opts) - } -} diff --git a/sdk/lib/inits/migrations/setupMigrations.ts b/sdk/lib/inits/migrations/setupMigrations.ts deleted file mode 100644 index 6d690b239..000000000 --- a/sdk/lib/inits/migrations/setupMigrations.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { ExtendedVersion } from "../../exver" - -import * as T from "../../types" -import { once } from "../../util/once" -import { Migration } from "./Migration" - -export class Migrations { - private constructor( - readonly manifest: T.Manifest, - readonly migrations: Array>, - ) {} - private sortedMigrations = once(() => { - const migrationsAsVersions = ( - this.migrations as Array> - ) - .map((x) => [ExtendedVersion.parse(x.options.version), x] as const) - .filter(([v, _]) => v.flavor === this.currentVersion().flavor) - migrationsAsVersions.sort((a, b) => a[0].compareForSort(b[0])) - return migrationsAsVersions - }) - private currentVersion = once(() => - ExtendedVersion.parse(this.manifest.version), - ) - static of< - Manifest extends T.Manifest, - Store, - Migrations extends Array>, - >(manifest: T.Manifest, ...migrations: EnsureUniqueId) { - return new Migrations( - manifest, - migrations as Array>, - ) - } - async init({ - effects, - previousVersion, - }: Parameters[0]) { - if (!!previousVersion) { - const previousVersionExVer = ExtendedVersion.parse(previousVersion) - for (const [_, migration] of this.sortedMigrations() - .filter((x) => x[0].greaterThan(previousVersionExVer)) - .filter((x) => x[0].lessThanOrEqual(this.currentVersion()))) { - await migration.up({ effects }) - } - } - } - async uninit({ - effects, - nextVersion, - }: Parameters[0]) { - if (!!nextVersion) { - const nextVersionExVer = ExtendedVersion.parse(nextVersion) - const reversed = [...this.sortedMigrations()].reverse() - for (const [_, migration] of reversed - .filter((x) => x[0].greaterThan(nextVersionExVer)) - .filter((x) => x[0].lessThanOrEqual(this.currentVersion()))) { - await migration.down({ effects }) - } - } - } -} - -export function setupMigrations< - Manifest extends T.Manifest, - Store, - Migrations extends Array>, ->(manifest: T.Manifest, ...migrations: EnsureUniqueId) { - return Migrations.of(manifest, ...migrations) -} - -// prettier-ignore -export type EnsureUniqueId = - B extends [] ? A : - B extends [Migration, ...infer Rest] ? ( - id extends ids ? "One of the ids are not unique"[] : - EnsureUniqueId - ) : "There exists a migration that is not a Migration"[] diff --git a/sdk/lib/inits/setupInit.ts b/sdk/lib/inits/setupInit.ts index 5718caa58..35971d3d3 100644 --- a/sdk/lib/inits/setupInit.ts +++ b/sdk/lib/inits/setupInit.ts @@ -1,14 +1,15 @@ import { DependenciesReceipt } from "../config/setupConfig" +import { ExtendedVersion, VersionRange } from "../exver" import { SetInterfaces } from "../interfaces/setupInterfaces" import { ExposedStorePaths } from "../store/setupExposeStore" import * as T from "../types" -import { Migrations } from "./migrations/setupMigrations" +import { VersionGraph } from "../versionInfo/setupVersionGraph" import { Install } from "./setupInstall" import { Uninstall } from "./setupUninstall" export function setupInit( - migrations: Migrations, + versions: VersionGraph, install: Install, uninstall: Uninstall, setInterfaces: SetInterfaces, @@ -23,8 +24,19 @@ export function setupInit( } { return { init: async (opts) => { - await migrations.init(opts) - await install.init(opts) + const prev = await opts.effects.getDataVersion() + if (prev) { + await versions.migrate({ + effects: opts.effects, + from: ExtendedVersion.parse(prev), + to: versions.currentVersion(), + }) + } else { + await install.install(opts) + await opts.effects.setDataVersion({ + version: versions.current.options.version, + }) + } await setInterfaces({ ...opts, input: null, @@ -33,8 +45,18 @@ export function setupInit( await setDependencies({ effects: opts.effects, input: null }) }, uninit: async (opts) => { - await migrations.uninit(opts) - await uninstall.uninit(opts) + if (opts.nextVersion) { + const prev = await opts.effects.getDataVersion() + if (prev) { + await versions.migrate({ + effects: opts.effects, + from: ExtendedVersion.parse(prev), + to: ExtendedVersion.parse(opts.nextVersion), + }) + } + } else { + await uninstall.uninstall(opts) + } }, } } diff --git a/sdk/lib/inits/setupInstall.ts b/sdk/lib/inits/setupInstall.ts index 7b51a22ea..ab21380a0 100644 --- a/sdk/lib/inits/setupInstall.ts +++ b/sdk/lib/inits/setupInstall.ts @@ -11,14 +11,10 @@ export class Install { return new Install(fn) } - async init({ - effects, - previousVersion, - }: Parameters[0]) { - if (!previousVersion) - await this.fn({ - effects, - }) + async install({ effects }: Parameters[0]) { + await this.fn({ + effects, + }) } } diff --git a/sdk/lib/inits/setupUninstall.ts b/sdk/lib/inits/setupUninstall.ts index c8c3e490f..918f417e5 100644 --- a/sdk/lib/inits/setupUninstall.ts +++ b/sdk/lib/inits/setupUninstall.ts @@ -11,7 +11,7 @@ export class Uninstall { return new Uninstall(fn) } - async uninit({ + async uninstall({ effects, nextVersion, }: Parameters[0]) { diff --git a/sdk/lib/manifest/ManifestTypes.ts b/sdk/lib/manifest/ManifestTypes.ts index ea349710f..cc564de2d 100644 --- a/sdk/lib/manifest/ManifestTypes.ts +++ b/sdk/lib/manifest/ManifestTypes.ts @@ -7,22 +7,11 @@ import { ImageSource, } from "../types" -export type SDKManifest< - Version extends string, - Satisfies extends string[] = [], -> = { +export type SDKManifest = { /** The package identifier used by the OS. This must be unique amongst all other known packages */ readonly id: string /** A human readable service title */ readonly title: string - /** Service version - accepts up to four digits, where the last confirms to revisions necessary for StartOs - * - see documentation: https://github.com/Start9Labs/emver-rs. This value will change with each release of - * the service - */ - readonly version: Version & ValidateExVer - readonly satisfies?: Satisfies & ValidateExVers - /** Release notes for the update - can be a string, paragraph or URL */ - readonly releaseNotes: string /** The type of license for the project. Include the LICENSE in the root of the project directory. A license is required for a Start9 package.*/ readonly license: string // name of license /** The Start9 wrapper repository URL for the package. This repo contains the manifest file (this), diff --git a/sdk/lib/manifest/setupManifest.ts b/sdk/lib/manifest/setupManifest.ts index a0d0e18b3..2f836d05e 100644 --- a/sdk/lib/manifest/setupManifest.ts +++ b/sdk/lib/manifest/setupManifest.ts @@ -2,6 +2,7 @@ import * as T from "../types" import { ImageConfig, ImageId, VolumeId } from "../osBindings" import { SDKManifest, SDKImageConfig } from "./ManifestTypes" import { SDKVersion } from "../StartSdk" +import { VersionGraph } from "../versionInfo/setupVersionGraph" /** * This is an example of a function that takes a manifest and returns a new manifest with additional properties @@ -21,10 +22,12 @@ export function setupManifest< assets: AssetTypes[] images: Record volumes: VolumesTypes[] - version: Version }, Satisfies extends string[] = [], ->(manifest: SDKManifest & Manifest): Manifest & T.Manifest { +>( + manifest: SDKManifest & Manifest, + versions: VersionGraph, +): Manifest & T.Manifest { const images = Object.entries(manifest.images).reduce( (images, [k, v]) => { v.arch = v.arch || ["aarch64", "x86_64"] @@ -39,7 +42,11 @@ export function setupManifest< ...manifest, gitHash: null, osVersion: SDKVersion, - satisfies: manifest.satisfies || [], + version: versions.current.options.version, + releaseNotes: versions.current.options.releaseNotes, + satisfies: versions.current.options.satisfies || [], + canMigrateTo: versions.canMigrateTo().toString(), + canMigrateFrom: versions.canMigrateFrom().toString(), images, alerts: { install: manifest.alerts?.install || null, diff --git a/sdk/lib/osBindings/HardwareRequirements.ts b/sdk/lib/osBindings/HardwareRequirements.ts index 3579e9524..e17568eec 100644 --- a/sdk/lib/osBindings/HardwareRequirements.ts +++ b/sdk/lib/osBindings/HardwareRequirements.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type HardwareRequirements = { - device: { device?: string; processor?: string } + device: { display?: string; processor?: string } ram: number | null arch: string[] | null } diff --git a/sdk/lib/osBindings/Manifest.ts b/sdk/lib/osBindings/Manifest.ts index 51f14935a..d40223236 100644 --- a/sdk/lib/osBindings/Manifest.ts +++ b/sdk/lib/osBindings/Manifest.ts @@ -15,6 +15,8 @@ export type Manifest = { version: Version satisfies: Array releaseNotes: string + canMigrateTo: string + canMigrateFrom: string license: string wrapperRepo: string upstreamRepo: string diff --git a/sdk/lib/osBindings/PackageDataEntry.ts b/sdk/lib/osBindings/PackageDataEntry.ts index ef805741b..41bd98bba 100644 --- a/sdk/lib/osBindings/PackageDataEntry.ts +++ b/sdk/lib/osBindings/PackageDataEntry.ts @@ -8,9 +8,11 @@ import type { PackageState } from "./PackageState" import type { ServiceInterface } from "./ServiceInterface" import type { ServiceInterfaceId } from "./ServiceInterfaceId" import type { Status } from "./Status" +import type { Version } from "./Version" export type PackageDataEntry = { stateInfo: PackageState + dataVersion: Version | null status: Status registry: string | null developerKey: string diff --git a/sdk/lib/osBindings/SetDataVersionParams.ts b/sdk/lib/osBindings/SetDataVersionParams.ts new file mode 100644 index 000000000..3b577d2b1 --- /dev/null +++ b/sdk/lib/osBindings/SetDataVersionParams.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SetDataVersionParams = { version: string } diff --git a/sdk/lib/osBindings/index.ts b/sdk/lib/osBindings/index.ts index 74baabfd9..59cbb7b13 100644 --- a/sdk/lib/osBindings/index.ts +++ b/sdk/lib/osBindings/index.ts @@ -132,6 +132,7 @@ export { SessionList } from "./SessionList" export { Sessions } from "./Sessions" export { Session } from "./Session" export { SetConfigured } from "./SetConfigured" +export { SetDataVersionParams } from "./SetDataVersionParams" export { SetDependenciesParams } from "./SetDependenciesParams" export { SetHealth } from "./SetHealth" export { SetMainStatusStatus } from "./SetMainStatusStatus" diff --git a/sdk/lib/test/configBuilder.test.ts b/sdk/lib/test/configBuilder.test.ts index a413d76b8..bd0ddeab1 100644 --- a/sdk/lib/test/configBuilder.test.ts +++ b/sdk/lib/test/configBuilder.test.ts @@ -6,6 +6,8 @@ import { Variants } from "../config/builder/variants" import { ValueSpec } from "../config/configTypes" import { setupManifest } from "../manifest/setupManifest" import { StartSdk } from "../StartSdk" +import { VersionGraph } from "../versionInfo/setupVersionGraph" +import { VersionInfo } from "../versionInfo/VersionInfo" describe("builder tests", () => { test("text", async () => { @@ -366,42 +368,48 @@ describe("values", () => { test("datetime", async () => { const sdk = StartSdk.of() .withManifest( - setupManifest({ - id: "testOutput", - title: "", - version: "1.0.0:0", - releaseNotes: "", - license: "", - replaces: [], - wrapperRepo: "", - upstreamRepo: "", - supportSite: "", - marketingSite: "", - donationUrl: null, - description: { - short: "", - long: "", - }, - containers: {}, - images: {}, - volumes: [], - assets: [], - alerts: { - install: null, - update: null, - uninstall: null, - restore: null, - start: null, - stop: null, - }, - dependencies: { - "remote-test": { - description: "", - optional: true, - s9pk: "https://example.com/remote-test.s9pk", + setupManifest( + { + id: "testOutput", + title: "", + license: "", + wrapperRepo: "", + upstreamRepo: "", + supportSite: "", + marketingSite: "", + donationUrl: null, + description: { + short: "", + long: "", + }, + containers: {}, + images: {}, + volumes: [], + assets: [], + alerts: { + install: null, + update: null, + uninstall: null, + restore: null, + start: null, + stop: null, + }, + dependencies: { + "remote-test": { + description: "", + optional: true, + s9pk: "https://example.com/remote-test.s9pk", + }, }, }, - }), + VersionGraph.of( + VersionInfo.of({ + version: "1.0.0:0", + releaseNotes: "", + migrations: {}, + }), + ), + ), ) .withStore<{ test: "a" }>() .build(true) diff --git a/sdk/lib/test/graph.test.ts b/sdk/lib/test/graph.test.ts new file mode 100644 index 000000000..7f02adc2e --- /dev/null +++ b/sdk/lib/test/graph.test.ts @@ -0,0 +1,148 @@ +import { Graph } from "../util/graph" + +describe("graph", () => { + { + { + test("findVertex", () => { + const graph = new Graph() + const foo = graph.addVertex("foo", [], []) + const bar = graph.addVertex( + "bar", + [{ from: foo, metadata: "foo-bar" }], + [], + ) + const baz = graph.addVertex( + "baz", + [{ from: bar, metadata: "bar-baz" }], + [], + ) + const qux = graph.addVertex( + "qux", + [{ from: baz, metadata: "baz-qux" }], + [], + ) + const match = Array.from(graph.findVertex((v) => v.metadata === "qux")) + expect(match).toHaveLength(1) + expect(match[0]).toBe(qux) + }) + test("shortestPathA", () => { + const graph = new Graph() + const foo = graph.addVertex("foo", [], []) + const bar = graph.addVertex( + "bar", + [{ from: foo, metadata: "foo-bar" }], + [], + ) + const baz = graph.addVertex( + "baz", + [{ from: bar, metadata: "bar-baz" }], + [], + ) + const qux = graph.addVertex( + "qux", + [{ from: baz, metadata: "baz-qux" }], + [], + ) + graph.addEdge("foo-qux", foo, qux) + expect(graph.shortestPath(foo, qux) || []).toHaveLength(1) + }) + test("shortestPathB", () => { + const graph = new Graph() + const foo = graph.addVertex("foo", [], []) + const bar = graph.addVertex( + "bar", + [{ from: foo, metadata: "foo-bar" }], + [], + ) + const baz = graph.addVertex( + "baz", + [{ from: bar, metadata: "bar-baz" }], + [], + ) + const qux = graph.addVertex( + "qux", + [{ from: baz, metadata: "baz-qux" }], + [], + ) + graph.addEdge("bar-qux", bar, qux) + expect(graph.shortestPath(foo, qux) || []).toHaveLength(2) + }) + test("shortestPathC", () => { + const graph = new Graph() + const foo = graph.addVertex("foo", [], []) + const bar = graph.addVertex( + "bar", + [{ from: foo, metadata: "foo-bar" }], + [], + ) + const baz = graph.addVertex( + "baz", + [{ from: bar, metadata: "bar-baz" }], + [], + ) + const qux = graph.addVertex( + "qux", + [{ from: baz, metadata: "baz-qux" }], + [{ to: foo, metadata: "qux-foo" }], + ) + expect(graph.shortestPath(foo, qux) || []).toHaveLength(3) + }) + test("bfs", () => { + const graph = new Graph() + const foo = graph.addVertex("foo", [], []) + const bar = graph.addVertex( + "bar", + [{ from: foo, metadata: "foo-bar" }], + [], + ) + const baz = graph.addVertex( + "baz", + [{ from: bar, metadata: "bar-baz" }], + [], + ) + const qux = graph.addVertex( + "qux", + [ + { from: foo, metadata: "foo-qux" }, + { from: baz, metadata: "baz-qux" }, + ], + [], + ) + const bfs = Array.from(graph.breadthFirstSearch(foo)) + expect(bfs).toHaveLength(4) + expect(bfs[0]).toBe(foo) + expect(bfs[1]).toBe(bar) + expect(bfs[2]).toBe(qux) + expect(bfs[3]).toBe(baz) + }) + test("reverseBfs", () => { + const graph = new Graph() + const foo = graph.addVertex("foo", [], []) + const bar = graph.addVertex( + "bar", + [{ from: foo, metadata: "foo-bar" }], + [], + ) + const baz = graph.addVertex( + "baz", + [{ from: bar, metadata: "bar-baz" }], + [], + ) + const qux = graph.addVertex( + "qux", + [ + { from: foo, metadata: "foo-qux" }, + { from: baz, metadata: "baz-qux" }, + ], + [], + ) + const bfs = Array.from(graph.reverseBreadthFirstSearch(qux)) + expect(bfs).toHaveLength(4) + expect(bfs[0]).toBe(qux) + expect(bfs[1]).toBe(foo) + expect(bfs[2]).toBe(baz) + expect(bfs[3]).toBe(bar) + }) + } + } +}) diff --git a/sdk/lib/test/output.sdk.ts b/sdk/lib/test/output.sdk.ts index c56e05e60..3d8058bfa 100644 --- a/sdk/lib/test/output.sdk.ts +++ b/sdk/lib/test/output.sdk.ts @@ -1,45 +1,56 @@ import { StartSdk } from "../StartSdk" import { setupManifest } from "../manifest/setupManifest" +import { VersionInfo } from "../versionInfo/VersionInfo" +import { VersionGraph } from "../versionInfo/setupVersionGraph" export type Manifest = any export const sdk = StartSdk.of() .withManifest( - setupManifest({ - id: "testOutput", - title: "", - version: "1.0:0", - releaseNotes: "", - license: "", - replaces: [], - wrapperRepo: "", - upstreamRepo: "", - supportSite: "", - marketingSite: "", - donationUrl: null, - description: { - short: "", - long: "", - }, - containers: {}, - images: {}, - volumes: [], - assets: [], - alerts: { - install: null, - update: null, - uninstall: null, - restore: null, - start: null, - stop: null, - }, - dependencies: { - "remote-test": { - description: "", - optional: false, - s9pk: "https://example.com/remote-test.s9pk", + setupManifest( + { + id: "testOutput", + title: "", + license: "", + replaces: [], + wrapperRepo: "", + upstreamRepo: "", + supportSite: "", + marketingSite: "", + donationUrl: null, + description: { + short: "", + long: "", + }, + containers: {}, + images: {}, + volumes: [], + assets: [], + alerts: { + install: null, + update: null, + uninstall: null, + restore: null, + start: null, + stop: null, + }, + dependencies: { + "remote-test": { + description: "", + optional: false, + s9pk: "https://example.com/remote-test.s9pk", + }, }, }, - }), + VersionGraph.of( + VersionInfo.of({ + version: "1.0.0:0", + releaseNotes: "", + migrations: {}, + }) + .satisfies("#other:1.0.0:0") + .satisfies("#other:2.0.0:0"), + ), + ), ) .withStore<{ storeRoot: { storeLeaf: "value" } }>() .build(true) diff --git a/sdk/lib/test/startosTypeValidation.test.ts b/sdk/lib/test/startosTypeValidation.test.ts index 846967c31..661f21079 100644 --- a/sdk/lib/test/startosTypeValidation.test.ts +++ b/sdk/lib/test/startosTypeValidation.test.ts @@ -3,6 +3,7 @@ import { CheckDependenciesParam, ExecuteAction, GetConfiguredParams, + SetDataVersionParams, SetMainStatus, } from ".././osBindings" import { CreateOverlayedImageParams } from ".././osBindings" @@ -46,6 +47,8 @@ describe("startosTypeValidation ", () => { restart: undefined, shutdown: undefined, setConfigured: {} as SetConfigured, + setDataVersion: {} as SetDataVersionParams, + getDataVersion: undefined, setHealth: {} as SetHealth, exposeForDependents: {} as ExposeForDependentsParams, getSslCertificate: {} as WithCallback, diff --git a/sdk/lib/types.ts b/sdk/lib/types.ts index 6c5ed0ab8..2611a0b84 100644 --- a/sdk/lib/types.ts +++ b/sdk/lib/types.ts @@ -102,10 +102,7 @@ export namespace ExpectedExports { * Every time a package completes an install, this function is called before the main. * Can be used to do migration like things. */ - export type init = (options: { - effects: Effects - previousVersion: null | string - }) => Promise + export type init = (options: { effects: Effects }) => Promise /** This will be ran during any time a package is uninstalled, for example during a update * this will be called. */ @@ -437,6 +434,10 @@ export type Effects = { value: ExtractStore }): Promise } + /** sets the version that this service's data has been migrated to */ + setDataVersion(options: { version: string }): Promise + /** returns the version that this service's data has been migrated to */ + getDataVersion(): Promise // system diff --git a/sdk/lib/util/Overlay.ts b/sdk/lib/util/Overlay.ts index 526c489e0..14908b90f 100644 --- a/sdk/lib/util/Overlay.ts +++ b/sdk/lib/util/Overlay.ts @@ -72,6 +72,23 @@ export class Overlay implements ExecSpawnable { return new Overlay(effects, id, rootfs, guid) } + static async with( + effects: T.Effects, + image: { id: T.ImageId; sharedRun?: boolean }, + mounts: { options: MountOptions; path: string }[], + fn: (overlay: Overlay) => Promise, + ): Promise { + const overlay = await Overlay.of(effects, image) + try { + for (let mount of mounts) { + await overlay.mount(mount.options, mount.path) + } + return await fn(overlay) + } finally { + await overlay.destroy() + } + } + async mount(options: MountOptions, path: string): Promise { path = path.startsWith("/") ? `${this.rootfs}${path}` diff --git a/sdk/lib/util/graph.ts b/sdk/lib/util/graph.ts new file mode 100644 index 000000000..5ad71a04d --- /dev/null +++ b/sdk/lib/util/graph.ts @@ -0,0 +1,244 @@ +import { boolean } from "ts-matches" + +export type Vertex = { + metadata: VMetadata + edges: Array> +} + +export type Edge = { + metadata: EMetadata + from: Vertex + to: Vertex +} + +export class Graph { + private readonly vertices: Array> = [] + constructor() {} + addVertex( + metadata: VMetadata, + fromEdges: Array, "to">>, + toEdges: Array, "from">>, + ): Vertex { + const vertex: Vertex = { + metadata, + edges: [], + } + for (let edge of fromEdges) { + const vEdge = { + metadata: edge.metadata, + from: edge.from, + to: vertex, + } + edge.from.edges.push(vEdge) + vertex.edges.push(vEdge) + } + for (let edge of toEdges) { + const vEdge = { + metadata: edge.metadata, + from: vertex, + to: edge.to, + } + edge.to.edges.push(vEdge) + vertex.edges.push(vEdge) + } + this.vertices.push(vertex) + return vertex + } + findVertex( + predicate: (vertex: Vertex) => boolean, + ): Generator, void> { + const veritces = this.vertices + function* gen() { + for (let vertex of veritces) { + if (predicate(vertex)) { + yield vertex + } + } + } + return gen() + } + addEdge( + metadata: EMetadata, + from: Vertex, + to: Vertex, + ): Edge { + const edge = { + metadata, + from, + to, + } + edge.from.edges.push(edge) + edge.to.edges.push(edge) + return edge + } + breadthFirstSearch( + from: + | Vertex + | ((vertex: Vertex) => boolean), + ): Generator, void> { + const visited: Array> = [] + function* rec( + vertex: Vertex, + ): Generator, void> { + if (visited.includes(vertex)) { + return + } + visited.push(vertex) + yield vertex + let generators = vertex.edges + .filter((e) => e.from === vertex) + .map((e) => rec(e.to)) + while (generators.length) { + let prev = generators + generators = [] + for (let gen of prev) { + const next = gen.next() + if (!next.done) { + generators.push(gen) + yield next.value + } + } + } + } + + if (from instanceof Function) { + let generators = this.vertices.filter(from).map(rec) + return (function* () { + while (generators.length) { + let prev = generators + generators = [] + for (let gen of prev) { + const next = gen.next() + if (!next.done) { + generators.push(gen) + yield next.value + } + } + } + })() + } else { + return rec(from) + } + } + reverseBreadthFirstSearch( + to: + | Vertex + | ((vertex: Vertex) => boolean), + ): Generator, void> { + const visited: Array> = [] + function* rec( + vertex: Vertex, + ): Generator, void> { + if (visited.includes(vertex)) { + return + } + visited.push(vertex) + yield vertex + let generators = vertex.edges + .filter((e) => e.to === vertex) + .map((e) => rec(e.from)) + while (generators.length) { + let prev = generators + generators = [] + for (let gen of prev) { + const next = gen.next() + if (!next.done) { + generators.push(gen) + yield next.value + } + } + } + } + + if (to instanceof Function) { + let generators = this.vertices.filter(to).map(rec) + return (function* () { + while (generators.length) { + let prev = generators + generators = [] + for (let gen of prev) { + const next = gen.next() + if (!next.done) { + generators.push(gen) + yield next.value + } + } + } + })() + } else { + return rec(to) + } + } + shortestPath( + from: + | Vertex + | ((vertex: Vertex) => boolean), + to: + | Vertex + | ((vertex: Vertex) => boolean), + ): Array> | void { + const isDone = + to instanceof Function + ? to + : (v: Vertex) => v === to + const path: Array> = [] + const visited: Array> = [] + function* check( + vertex: Vertex, + path: Array>, + ): Generator> | undefined> { + if (isDone(vertex)) { + return path + } + if (visited.includes(vertex)) { + return + } + visited.push(vertex) + yield + let generators = vertex.edges + .filter((e) => e.from === vertex) + .map((e) => check(e.to, [...path, e])) + while (generators.length) { + let prev = generators + generators = [] + for (let gen of prev) { + const next = gen.next() + if (next.done === true) { + if (next.value) { + return next.value + } + } else { + generators.push(gen) + yield + } + } + } + } + + if (from instanceof Function) { + let generators = this.vertices.filter(from).map((v) => check(v, [])) + while (generators.length) { + let prev = generators + generators = [] + for (let gen of prev) { + const next = gen.next() + if (next.done === true) { + if (next.value) { + return next.value + } + } else { + generators.push(gen) + } + } + } + } else { + const gen = check(from, []) + while (true) { + const next = gen.next() + if (next.done) { + return next.value + } + } + } + } +} diff --git a/sdk/lib/versionInfo/VersionInfo.ts b/sdk/lib/versionInfo/VersionInfo.ts new file mode 100644 index 000000000..beea16019 --- /dev/null +++ b/sdk/lib/versionInfo/VersionInfo.ts @@ -0,0 +1,78 @@ +import { ValidateExVer } from "../exver" +import * as T from "../types" + +export const IMPOSSIBLE = Symbol("IMPOSSIBLE") + +export type VersionOptions = { + /** The version being described */ + version: Version & ValidateExVer + /** The release notes for this version */ + releaseNotes: string + /** Data migrations for this version */ + migrations: { + /** + * A migration from the previous version + * Leave blank to indicate no migration is necessary + * Set to `IMPOSSIBLE` to indicate migrating from the previous version is not possible + */ + up?: ((opts: { effects: T.Effects }) => Promise) | typeof IMPOSSIBLE + /** + * A migration to the previous version + * Leave blank to indicate no migration is necessary + * Set to `IMPOSSIBLE` to indicate downgrades are prohibited + */ + down?: ((opts: { effects: T.Effects }) => Promise) | typeof IMPOSSIBLE + /** + * Additional migrations, such as fast-forward migrations, or migrations from other flavors + */ + other?: Record Promise> + } +} + +export class VersionInfo { + private _version: null | Version = null + private constructor( + readonly options: VersionOptions & { satisfies: string[] }, + ) {} + static of(options: VersionOptions) { + return new VersionInfo({ ...options, satisfies: [] }) + } + /** Specify a version that this version is 100% backwards compatible to */ + satisfies( + version: V & ValidateExVer, + ): VersionInfo { + return new VersionInfo({ + ...this.options, + satisfies: [...this.options.satisfies, version], + }) + } +} + +function __type_tests() { + const version: VersionInfo<"1.0.0:0"> = VersionInfo.of({ + version: "1.0.0:0", + releaseNotes: "", + migrations: {}, + }) + .satisfies("#other:1.0.0:0") + .satisfies("#other:2.0.0:0") + // @ts-expect-error + .satisfies("#other:2.f.0:0") + + let a: VersionInfo<"1.0.0:0"> = version + // @ts-expect-error + let b: VersionInfo<"1.0.0:3"> = version + + VersionInfo.of({ + // @ts-expect-error + version: "test", + releaseNotes: "", + migrations: {}, + }) + VersionInfo.of({ + // @ts-expect-error + version: "test" as string, + releaseNotes: "", + migrations: {}, + }) +} diff --git a/sdk/lib/versionInfo/setupVersionGraph.ts b/sdk/lib/versionInfo/setupVersionGraph.ts new file mode 100644 index 000000000..5f89a7e35 --- /dev/null +++ b/sdk/lib/versionInfo/setupVersionGraph.ts @@ -0,0 +1,210 @@ +import { ExtendedVersion, VersionRange } from "../exver" + +import * as T from "../types" +import { Graph, Vertex } from "../util/graph" +import { once } from "../util/once" +import { IMPOSSIBLE, VersionInfo } from "./VersionInfo" + +export class VersionGraph { + private readonly graph: () => Graph< + ExtendedVersion | VersionRange, + ((opts: { effects: T.Effects }) => Promise) | undefined + > + private constructor( + readonly current: VersionInfo, + versions: Array>, + ) { + this.graph = once(() => { + const graph = new Graph< + ExtendedVersion | VersionRange, + ((opts: { effects: T.Effects }) => Promise) | undefined + >() + const flavorMap: Record< + string, + [ + ExtendedVersion, + VersionInfo, + Vertex< + ExtendedVersion | VersionRange, + ((opts: { effects: T.Effects }) => Promise) | undefined + >, + ][] + > = {} + for (let version of [current, ...versions]) { + const v = ExtendedVersion.parse(version.options.version) + const vertex = graph.addVertex(v, [], []) + const flavor = v.flavor || "" + if (!flavorMap[flavor]) { + flavorMap[flavor] = [] + } + flavorMap[flavor].push([v, version, vertex]) + } + for (let flavor in flavorMap) { + flavorMap[flavor].sort((a, b) => a[0].compareForSort(b[0])) + let prev: + | [ + ExtendedVersion, + VersionInfo, + Vertex< + ExtendedVersion | VersionRange, + (opts: { effects: T.Effects }) => Promise + >, + ] + | undefined = undefined + for (let [v, version, vertex] of flavorMap[flavor]) { + if (version.options.migrations.up !== IMPOSSIBLE) { + let range + if (prev) { + graph.addEdge(version.options.migrations.up, prev[2], vertex) + range = VersionRange.anchor(">=", prev[0]).and( + VersionRange.anchor("<", v), + ) + } else { + range = VersionRange.anchor("<", v) + } + const vRange = graph.addVertex(range, [], []) + graph.addEdge(version.options.migrations.up, vRange, vertex) + } + + if (version.options.migrations.down !== IMPOSSIBLE) { + let range + if (prev) { + graph.addEdge(version.options.migrations.down, vertex, prev[2]) + range = VersionRange.anchor(">=", prev[0]).and( + VersionRange.anchor("<", v), + ) + } else { + range = VersionRange.anchor("<", v) + } + const vRange = graph.addVertex(range, [], []) + graph.addEdge(version.options.migrations.down, vertex, vRange) + } + + if (version.options.migrations.other) { + for (let rangeStr in version.options.migrations.other) { + const range = VersionRange.parse(rangeStr) + const vRange = graph.addVertex(range, [], []) + graph.addEdge( + version.options.migrations.other[rangeStr], + vRange, + vertex, + ) + for (let matching of graph.findVertex( + (v) => + v.metadata instanceof ExtendedVersion && + v.metadata.satisfies(range), + )) { + graph.addEdge( + version.options.migrations.other[rangeStr], + matching, + vertex, + ) + } + } + } + } + } + return graph + }) + } + currentVersion = once(() => + ExtendedVersion.parse(this.current.options.version), + ) + static of< + CurrentVersion extends string, + OtherVersions extends Array>, + >( + currentVersion: VersionInfo, + ...other: EnsureUniqueId + ) { + return new VersionGraph(currentVersion, other as Array>) + } + async migrate({ + effects, + from, + to, + }: { + effects: T.Effects + from: ExtendedVersion + to: ExtendedVersion + }) { + const graph = this.graph() + if (from && to) { + const path = graph.shortestPath( + (v) => + (v.metadata instanceof VersionRange && + v.metadata.satisfiedBy(from)) || + (v.metadata instanceof ExtendedVersion && v.metadata.equals(from)), + (v) => + (v.metadata instanceof VersionRange && v.metadata.satisfiedBy(to)) || + (v.metadata instanceof ExtendedVersion && v.metadata.equals(to)), + ) + if (path) { + for (let edge of path) { + if (edge.metadata) { + await edge.metadata({ effects }) + } + await effects.setDataVersion({ version: edge.to.metadata.toString() }) + } + return + } + } + throw new Error() + } + canMigrateFrom = once(() => + Array.from( + this.graph().reverseBreadthFirstSearch( + (v) => + (v.metadata instanceof VersionRange && + v.metadata.satisfiedBy(this.currentVersion())) || + (v.metadata instanceof ExtendedVersion && + v.metadata.equals(this.currentVersion())), + ), + ).reduce( + (acc, x) => + acc.or( + x.metadata instanceof VersionRange + ? x.metadata + : VersionRange.anchor("=", x.metadata), + ), + VersionRange.none(), + ), + ) + canMigrateTo = once(() => + Array.from( + this.graph().breadthFirstSearch( + (v) => + (v.metadata instanceof VersionRange && + v.metadata.satisfiedBy(this.currentVersion())) || + (v.metadata instanceof ExtendedVersion && + v.metadata.equals(this.currentVersion())), + ), + ).reduce( + (acc, x) => + acc.or( + x.metadata instanceof VersionRange + ? x.metadata + : VersionRange.anchor("=", x.metadata), + ), + VersionRange.none(), + ), + ) +} + +export function setupVersionGraph< + CurrentVersion extends string, + OtherVersions extends Array>, +>( + current: VersionInfo, + ...other: EnsureUniqueId +) { + return VersionGraph.of(current, ...other) +} + +// prettier-ignore +export type EnsureUniqueId = + B extends [] ? A : + B extends [VersionInfo, ...infer Rest] ? ( + Version extends OtherVersions ? "One or more versions are not unique"[] : + EnsureUniqueId + ) : "There exists a migration that is not a Migration"[] diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index a7ae06193..b093ad29e 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -88,6 +88,8 @@ export module Mock { title: 'Bitcoin Core', version: '0.21.0:0', satisfies: [], + canMigrateTo: '!', + canMigrateFrom: '*', gitHash: 'abcdefgh', description: { short: 'A Bitcoin full node by Bitcoin Core.', @@ -132,6 +134,8 @@ export module Mock { title: 'Lightning Network Daemon', version: '0.11.1:0', satisfies: [], + canMigrateTo: '!', + canMigrateFrom: '*', gitHash: 'abcdefgh', description: { short: 'A bolt spec compliant client.', @@ -188,6 +192,8 @@ export module Mock { title: 'Bitcoin Proxy', version: '0.2.2:0', satisfies: [], + canMigrateTo: '!', + canMigrateFrom: '*', gitHash: 'lmnopqrx', description: { short: 'A super charger for your Bitcoin node.', @@ -1684,6 +1690,7 @@ export module Mock { state: 'installed', manifest: MockManifestBitcoind, }, + dataVersion: MockManifestBitcoind.version, icon: '/assets/img/service-icons/bitcoind.svg', lastBackup: null, status: { @@ -1860,6 +1867,7 @@ export module Mock { state: 'installed', manifest: MockManifestBitcoinProxy, }, + dataVersion: MockManifestBitcoinProxy.version, icon: '/assets/img/service-icons/btc-rpc-proxy.png', lastBackup: null, status: { @@ -1908,6 +1916,7 @@ export module Mock { state: 'installed', manifest: MockManifestLnd, }, + dataVersion: MockManifestLnd.version, icon: '/assets/img/service-icons/lnd.png', lastBackup: null, status: { diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 5a6c7b815..ba2966b7f 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -91,6 +91,7 @@ export const mockPatchData: DataModel = { version: '0.20.0:0', }, }, + dataVersion: '0.20.0:0', icon: '/assets/img/service-icons/bitcoind.svg', lastBackup: null, status: { @@ -295,6 +296,7 @@ export const mockPatchData: DataModel = { version: '0.11.0:0.0.1', }, }, + dataVersion: '0.11.0:0.0.1', icon: '/assets/img/service-icons/lnd.png', lastBackup: null, status: {