diff --git a/container-runtime/src/Adapters/EffectCreator.ts b/container-runtime/src/Adapters/EffectCreator.ts index 022d697ab..db176536f 100644 --- a/container-runtime/src/Adapters/EffectCreator.ts +++ b/container-runtime/src/Adapters/EffectCreator.ts @@ -173,7 +173,7 @@ export function makeEffects(context: EffectContext): Effects { T.Effects["subcontainer"]["createFs"] > }, - destroyFs(options: { guid: string }): Promise { + destroyFs(options: { guid: string }): Promise { return rpcRound("subcontainer.destroy-fs", options) as ReturnType< T.Effects["subcontainer"]["destroyFs"] > @@ -284,7 +284,7 @@ export function makeEffects(context: EffectContext): Effects { > }, - setMainStatus(o: { status: "running" | "stopped" }): Promise { + setMainStatus(o: { status: "running" | "stopped" }): Promise { return rpcRound("set-main-status", o) as ReturnType< T.Effects["setHealth"] > diff --git a/container-runtime/src/Adapters/Systems/SystemForStartOs.ts b/container-runtime/src/Adapters/Systems/SystemForStartOs.ts index 43096f0f0..c000b391b 100644 --- a/container-runtime/src/Adapters/Systems/SystemForStartOs.ts +++ b/container-runtime/src/Adapters/Systems/SystemForStartOs.ts @@ -92,6 +92,7 @@ export class SystemForStartOs implements System { const started = async (onTerm: () => Promise) => { await effects.setMainStatus({ status: "running" }) mainOnTerm = onTerm + return null } const daemons = await ( await this.abi.main({ diff --git a/core/models/src/errors.rs b/core/models/src/errors.rs index ee6b0ae12..708b57861 100644 --- a/core/models/src/errors.rs +++ b/core/models/src/errors.rs @@ -351,6 +351,14 @@ impl Debug for ErrorData { } } impl std::error::Error for ErrorData {} +impl From for ErrorData { + fn from(value: Error) -> Self { + Self { + details: value.to_string(), + debug: format!("{:?}", value), + } + } +} impl From<&RpcError> for ErrorData { fn from(value: &RpcError) -> Self { Self { diff --git a/core/startos/src/disk/mount/util.rs b/core/startos/src/disk/mount/util.rs index 674f33304..61368e67a 100644 --- a/core/startos/src/disk/mount/util.rs +++ b/core/startos/src/disk/mount/util.rs @@ -5,6 +5,16 @@ use tracing::instrument; use crate::util::Invoke; use crate::Error; +pub async fn is_mountpoint(path: impl AsRef) -> Result { + let is_mountpoint = tokio::process::Command::new("mountpoint") + .arg(path.as_ref()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await?; + Ok(is_mountpoint.success()) +} + #[instrument(skip_all)] pub async fn bind, P1: AsRef>( src: P0, @@ -16,13 +26,7 @@ pub async fn bind, P1: AsRef>( src.as_ref().display(), dst.as_ref().display() ); - let is_mountpoint = tokio::process::Command::new("mountpoint") - .arg(dst.as_ref()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await?; - if is_mountpoint.success() { + if is_mountpoint(&dst).await? { unmount(dst.as_ref(), true).await?; } tokio::fs::create_dir_all(&src).await?; diff --git a/core/startos/src/lib.rs b/core/startos/src/lib.rs index 8d4f831bf..997a7199f 100644 --- a/core/startos/src/lib.rs +++ b/core/startos/src/lib.rs @@ -292,6 +292,13 @@ pub fn package() -> ParentHandler { .no_display() .with_call_remote::(), ) + .subcommand( + "rebuild", + from_fn_async(service::rebuild) + .with_metadata("sync_db", Value::Bool(true)) + .no_display() + .with_call_remote::(), + ) .subcommand("logs", logs::package_logs()) .subcommand( "logs", diff --git a/core/startos/src/lxc/mod.rs b/core/startos/src/lxc/mod.rs index ee08801df..3979263f0 100644 --- a/core/startos/src/lxc/mod.rs +++ b/core/startos/src/lxc/mod.rs @@ -126,7 +126,8 @@ impl LxcManager { Path::new(LXC_CONTAINER_DIR).join(container).join("rootfs"), true, ) - .await?; + .await + .log_err(); if tokio_stream::wrappers::ReadDirStream::new( tokio::fs::read_dir(&rootfs_path).await?, ) diff --git a/core/startos/src/service/mod.rs b/core/startos/src/service/mod.rs index 1e61b1191..37579c03b 100644 --- a/core/startos/src/service/mod.rs +++ b/core/startos/src/service/mod.rs @@ -589,6 +589,15 @@ impl ServiceActorSeed { } } +#[derive(Deserialize, Serialize, Parser, TS)] +pub struct RebuildParams { + pub id: PackageId, +} +pub async fn rebuild(ctx: RpcContext, RebuildParams { id }: RebuildParams) -> Result<(), Error> { + ctx.services.load(&ctx, &id, LoadDisposition::Retry).await?; + Ok(()) +} + #[derive(Deserialize, Serialize, Parser, TS)] pub struct ConnectParams { pub id: PackageId, diff --git a/core/startos/src/service/service_map.rs b/core/startos/src/service/service_map.rs index ebe45fcc5..68a5f2a5f 100644 --- a/core/startos/src/service/service_map.rs +++ b/core/startos/src/service/service_map.rs @@ -7,6 +7,7 @@ use futures::{Future, FutureExt}; use helpers::NonDetachingJoinHandle; use imbl::OrdMap; use imbl_value::InternedString; +use models::ErrorData; use tokio::sync::{Mutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock}; use tracing::instrument; @@ -22,6 +23,7 @@ use crate::progress::{FullProgressTracker, PhaseProgressTrackerHandle, ProgressT use crate::s9pk::manifest::PackageId; use crate::s9pk::merkle_archive::source::FileSource; use crate::s9pk::S9pk; +use crate::service::start_stop::StartStop; use crate::service::{LoadDisposition, Service, ServiceRef}; use crate::status::MainStatus; use crate::util::serde::Pem; @@ -87,8 +89,30 @@ impl ServiceMap { if let Some(service) = service.take() { shutdown_err = service.shutdown().await; } - // TODO: retry on error? - *service = Service::load(ctx, id, disposition).await?.map(From::from); + match Service::load(ctx, id, disposition).await { + Ok(s) => *service = s.into(), + Err(e) => { + let e = ErrorData::from(e); + ctx.db + .mutate(|db| { + if let Some(pde) = db.as_public_mut().as_package_data_mut().as_idx_mut(id) { + pde.as_status_mut().map_mutate(|s| { + Ok(MainStatus::Error { + on_rebuild: if s.running() { + StartStop::Start + } else { + StartStop::Stop + }, + message: e.details, + debug: Some(e.debug), + }) + })?; + } + Ok(()) + }) + .await?; + } + } shutdown_err?; Ok(()) } diff --git a/core/startos/src/status/mod.rs b/core/startos/src/status/mod.rs index e45085826..14442bce6 100644 --- a/core/startos/src/status/mod.rs +++ b/core/startos/src/status/mod.rs @@ -17,6 +17,11 @@ pub mod health_check; #[serde(rename_all = "camelCase")] #[serde(rename_all_fields = "camelCase")] pub enum MainStatus { + Error { + on_rebuild: StartStop, + message: String, + debug: Option, + }, Stopped, Restarting, Restoring, @@ -43,12 +48,20 @@ impl MainStatus { | MainStatus::Restarting | MainStatus::BackingUp { on_complete: StartStop::Start, + } + | MainStatus::Error { + on_rebuild: StartStop::Start, + .. } => true, MainStatus::Stopped | MainStatus::Restoring | MainStatus::Stopping { .. } | MainStatus::BackingUp { on_complete: StartStop::Stop, + } + | MainStatus::Error { + on_rebuild: StartStop::Stop, + .. } => false, } } @@ -70,7 +83,8 @@ impl MainStatus { | MainStatus::Stopped | MainStatus::Restoring | MainStatus::Stopping { .. } - | MainStatus::Restarting => None, + | MainStatus::Restarting + | MainStatus::Error { .. } => None, } } } diff --git a/sdk/base/lib/Effects.ts b/sdk/base/lib/Effects.ts index 3dab037c0..16d82464d 100644 --- a/sdk/base/lib/Effects.ts +++ b/sdk/base/lib/Effects.ts @@ -31,14 +31,14 @@ export type Effects = { constRetry: () => void clearCallbacks: ( options: { only: number[] } | { except: number[] }, - ) => Promise + ) => Promise // action action: { /** Define an action that can be invoked by a user or service */ - export(options: { id: ActionId; metadata: ActionMetadata }): Promise + export(options: { id: ActionId; metadata: ActionMetadata }): Promise /** Remove all exported actions */ - clear(options: { except: ActionId[] }): Promise + clear(options: { except: ActionId[] }): Promise getInput(options: { packageId?: PackageId actionId: ActionId @@ -50,23 +50,23 @@ export type Effects = { }): Promise request>( options: RequestActionParams, - ): Promise + ): Promise clearRequests( options: { only: ActionId[] } | { except: ActionId[] }, - ): Promise + ): Promise } // control /** restart this service's main function */ - restart(): Promise + restart(): Promise /** stop this service's main function */ - shutdown(): Promise + shutdown(): Promise /** indicate to the host os what runstate the service is in */ - setMainStatus(options: SetMainStatus): Promise + setMainStatus(options: SetMainStatus): Promise // dependency /** Set the dependencies of what the service needs, usually run during the inputSpec action as a best practice */ - setDependencies(options: { dependencies: Dependencies }): Promise + setDependencies(options: { dependencies: Dependencies }): Promise /** Get the list of the dependencies, both the dynamic set by the effect of setDependencies and the end result any required in the manifest */ getDependencies(): Promise /** Test whether current dependency requirements are satisfied */ @@ -86,11 +86,11 @@ export type Effects = { /** Returns a list of the ids of all installed packages */ getInstalledPackages(): Promise /** grants access to certain paths in the store to dependents */ - exposeForDependents(options: { paths: string[] }): Promise + exposeForDependents(options: { paths: string[] }): Promise // health /** sets the result of a health check */ - setHealth(o: SetHealth): Promise + setHealth(o: SetHealth): Promise // subcontainer subcontainer: { @@ -100,13 +100,13 @@ export type Effects = { name: string | null }): Promise<[string, string]> /** A low level api used by SubContainer */ - destroyFs(options: { guid: string }): Promise + destroyFs(options: { guid: string }): Promise } // net // bind /** Creates a host connected to the specified port with the provided options */ - bind(options: BindParams): Promise + bind(options: BindParams): Promise /** Get the port address for a service */ getServicePortForward(options: { packageId?: PackageId @@ -116,7 +116,7 @@ export type Effects = { /** Removes all network bindings, called in the setupInputSpec */ clearBindings(options: { except: { id: HostId; internalPort: number }[] - }): Promise + }): Promise // host /** Returns information about the specified host, if it exists */ getHostInfo(options: { @@ -134,7 +134,7 @@ export type Effects = { getContainerIp(): Promise // interface /** Creates an interface bound to a specific host and port to show to the user */ - exportServiceInterface(options: ExportServiceInterfaceParams): Promise + exportServiceInterface(options: ExportServiceInterfaceParams): Promise /** Returns an exported service interface */ getServiceInterface(options: { packageId?: PackageId @@ -149,7 +149,7 @@ export type Effects = { /** Removes all service interfaces */ clearServiceInterfaces(options: { except: ServiceInterfaceId[] - }): Promise + }): Promise // ssl /** Returns a PEM encoded fullchain for the hostnames specified */ getSslCertificate: (options: { @@ -178,10 +178,10 @@ export type Effects = { /** Sets the value for the wrapper at the path, it will override, using the [JsonPath](https://jsonpath.com/) */ path: StorePath value: ExtractStore - }): Promise + }): Promise } /** sets the version that this service's data has been migrated to */ - setDataVersion(options: { version: string }): Promise + setDataVersion(options: { version: string }): Promise /** returns the version that this service's data has been migrated to */ getDataVersion(): Promise diff --git a/sdk/base/lib/actions/setupActions.ts b/sdk/base/lib/actions/setupActions.ts index f78da1f0a..8c6b7734a 100644 --- a/sdk/base/lib/actions/setupActions.ts +++ b/sdk/base/lib/actions/setupActions.ts @@ -1,6 +1,7 @@ import { InputSpec } from "./input/builder" import { ExtractInputSpecType } from "./input/builder/inputSpec" import * as T from "../types" +import { once } from "../util" export type Run< A extends @@ -130,21 +131,19 @@ export class Actions< ): Actions { return new Actions({ ...this.actions, [action.id]: action }) } - update(options: { effects: T.Effects }): Promise { - const updater = async (options: { effects: T.Effects }) => { - for (let action of Object.values(this.actions)) { - await action.exportMetadata(options) - } - await options.effects.action.clear({ except: Object.keys(this.actions) }) + async update(options: { effects: T.Effects }): Promise { + options.effects = { + ...options.effects, + constRetry: once(() => { + this.update(options) // yes, this reuses the options object, but the const retry function will be overwritten each time, so the once-ness is not a problem + }), } - const updaterCtx = { options } - updaterCtx.options = { - effects: { - ...options.effects, - constRetry: () => updater(updaterCtx.options), - }, + for (let action of Object.values(this.actions)) { + await action.exportMetadata(options) } - return updater(updaterCtx.options) + await options.effects.action.clear({ except: Object.keys(this.actions) }) + + return null } get(actionId: Id): AllActions[Id] { return this.actions[actionId] diff --git a/sdk/base/lib/dependencies/dependencies.ts b/sdk/base/lib/dependencies/dependencies.ts index fd3c6bb69..20049b5e8 100644 --- a/sdk/base/lib/dependencies/dependencies.ts +++ b/sdk/base/lib/dependencies/dependencies.ts @@ -13,15 +13,15 @@ export type CheckDependencies = { ) => boolean satisfied: () => boolean - throwIfInstalledNotSatisfied: (packageId: DependencyId) => void - throwIfInstalledVersionNotSatisfied: (packageId: DependencyId) => void - throwIfRunningNotSatisfied: (packageId: DependencyId) => void - throwIfActionsNotSatisfied: (packageId: DependencyId) => void + throwIfInstalledNotSatisfied: (packageId: DependencyId) => null + throwIfInstalledVersionNotSatisfied: (packageId: DependencyId) => null + throwIfRunningNotSatisfied: (packageId: DependencyId) => null + throwIfActionsNotSatisfied: (packageId: DependencyId) => null throwIfHealthNotSatisfied: ( packageId: DependencyId, healthCheckId?: HealthCheckId, - ) => void - throwIfNotSatisfied: (packageId?: DependencyId) => void + ) => null + throwIfNotSatisfied: (packageId?: DependencyId) => null } export async function checkDependencies< DependencyId extends PackageId = PackageId, @@ -100,6 +100,7 @@ export async function checkDependencies< if (!dep.result.installedVersion) { throw new Error(`${dep.result.title || packageId} is not installed`) } + return null } const throwIfInstalledVersionNotSatisfied = (packageId: DependencyId) => { const dep = find(packageId) @@ -117,12 +118,14 @@ export async function checkDependencies< `Installed version ${dep.result.installedVersion} of ${dep.result.title || packageId} does not match expected version range ${dep.requirement.versionRange}`, ) } + return null } 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`) } + return null } const throwIfActionsNotSatisfied = (packageId: DependencyId) => { const dep = find(packageId) @@ -132,6 +135,7 @@ export async function checkDependencies< `The following action requests have not been fulfilled: ${reqs.join(", ")}`, ) } + return null } const throwIfHealthNotSatisfied = ( packageId: DependencyId, @@ -158,6 +162,7 @@ export async function checkDependencies< .join("; "), ) } + return null } const throwIfPkgNotSatisfied = (packageId: DependencyId) => { throwIfInstalledNotSatisfied(packageId) @@ -165,6 +170,7 @@ export async function checkDependencies< throwIfRunningNotSatisfied(packageId) throwIfActionsNotSatisfied(packageId) throwIfHealthNotSatisfied(packageId) + return null } const throwIfNotSatisfied = (packageId?: DependencyId) => packageId @@ -182,6 +188,7 @@ export async function checkDependencies< if (err.length) { throw new Error(err.join("; ")) } + return null })() return { diff --git a/sdk/base/lib/dependencies/setupDependencies.ts b/sdk/base/lib/dependencies/setupDependencies.ts index 0f9bd6da8..9d613ff74 100644 --- a/sdk/base/lib/dependencies/setupDependencies.ts +++ b/sdk/base/lib/dependencies/setupDependencies.ts @@ -1,4 +1,5 @@ import * as T from "../types" +import { once } from "../util" import { Dependency } from "./Dependency" type DependencyType = { @@ -17,40 +18,38 @@ type DependencyType = { export function setupDependencies( fn: (options: { effects: T.Effects }) => Promise>, -): (options: { effects: T.Effects }) => Promise { - return (options: { effects: T.Effects }) => { - const updater = async (options: { effects: T.Effects }) => { - const dependencyType = await fn(options) - return await options.effects.setDependencies({ - dependencies: Object.entries(dependencyType).map( - ([ - id, - { - data: { versionRange, ...x }, - }, - ]) => ({ - id, - ...x, - ...(x.type === "running" - ? { - kind: "running", - healthChecks: x.healthChecks, - } - : { - kind: "exists", - }), - versionRange: versionRange.toString(), - }), - ), - }) +): (options: { effects: T.Effects }) => Promise { + const cell = { updater: async (_: { effects: T.Effects }) => null } + cell.updater = async (options: { effects: T.Effects }) => { + options.effects = { + ...options.effects, + constRetry: once(() => { + cell.updater(options) + }), } - const updaterCtx = { options } - updaterCtx.options = { - effects: { - ...options.effects, - constRetry: () => updater(updaterCtx.options), - }, - } - return updater(updaterCtx.options) + const dependencyType = await fn(options) + return await options.effects.setDependencies({ + dependencies: Object.entries(dependencyType).map( + ([ + id, + { + data: { versionRange, ...x }, + }, + ]) => ({ + id, + ...x, + ...(x.type === "running" + ? { + kind: "running", + healthChecks: x.healthChecks, + } + : { + kind: "exists", + }), + versionRange: versionRange.toString(), + }), + ), + }) } + return cell.updater } diff --git a/sdk/base/lib/interfaces/setupInterfaces.ts b/sdk/base/lib/interfaces/setupInterfaces.ts index 474841aa3..ba284bcb3 100644 --- a/sdk/base/lib/interfaces/setupInterfaces.ts +++ b/sdk/base/lib/interfaces/setupInterfaces.ts @@ -1,4 +1,5 @@ import * as T from "../types" +import { once } from "../util" import { AddressReceipt } from "./AddressReceipt" declare const UpdateServiceInterfacesProof: unique symbol @@ -21,34 +22,36 @@ export const setupServiceInterfaces: SetupServiceInterfaces = < Output extends ServiceInterfacesReceipt, >( fn: SetServiceInterfaces, -) => - ((options: { effects: T.Effects }) => { - const updater = async (options: { effects: T.Effects }) => { - const bindings: T.BindId[] = [] - const interfaces: T.ServiceInterfaceId[] = [] - const res = await fn({ - effects: { - ...options.effects, - bind: (params: T.BindParams) => { - bindings.push({ id: params.id, internalPort: params.internalPort }) - return options.effects.bind(params) - }, - exportServiceInterface: (params: T.ExportServiceInterfaceParams) => { - interfaces.push(params.id) - return options.effects.exportServiceInterface(params) - }, - }, - }) - await options.effects.clearBindings({ except: bindings }) - await options.effects.clearServiceInterfaces({ except: interfaces }) - return res +) => { + const cell = { + updater: (async (options: { effects: T.Effects }) => + [] as any as Output) as UpdateServiceInterfaces, + } + cell.updater = (async (options: { effects: T.Effects }) => { + options.effects = { + ...options.effects, + constRetry: once(() => { + cell.updater(options) + }), } - const updaterCtx = { options } - updaterCtx.options = { + const bindings: T.BindId[] = [] + const interfaces: T.ServiceInterfaceId[] = [] + const res = await fn({ effects: { ...options.effects, - constRetry: () => updater(updaterCtx.options), + bind: (params: T.BindParams) => { + bindings.push({ id: params.id, internalPort: params.internalPort }) + return options.effects.bind(params) + }, + exportServiceInterface: (params: T.ExportServiceInterfaceParams) => { + interfaces.push(params.id) + return options.effects.exportServiceInterface(params) + }, }, - } - return updater(updaterCtx.options) + }) + await options.effects.clearBindings({ except: bindings }) + await options.effects.clearServiceInterfaces({ except: interfaces }) + return res }) as UpdateServiceInterfaces + return cell.updater +} diff --git a/sdk/base/lib/osBindings/MainStatus.ts b/sdk/base/lib/osBindings/MainStatus.ts index dbd9a8fcc..64e081ab9 100644 --- a/sdk/base/lib/osBindings/MainStatus.ts +++ b/sdk/base/lib/osBindings/MainStatus.ts @@ -4,6 +4,12 @@ import type { NamedHealthCheckResult } from "./NamedHealthCheckResult" import type { StartStop } from "./StartStop" export type MainStatus = + | { + main: "error" + onRebuild: StartStop + message: string + debug: string | null + } | { main: "stopped" } | { main: "restarting" } | { main: "restoring" } diff --git a/sdk/base/lib/s9pk/index.ts b/sdk/base/lib/s9pk/index.ts index eba8ed3f8..84a1ec644 100644 --- a/sdk/base/lib/s9pk/index.ts +++ b/sdk/base/lib/s9pk/index.ts @@ -1,6 +1,6 @@ import { DataUrl, Manifest, MerkleArchiveCommitment } from "../osBindings" import { ArrayBufferReader, MerkleArchive } from "./merkleArchive" -import mime from "mime" +import mime from "mime-types" const magicAndVersion = new Uint8Array([59, 59, 2]) @@ -52,13 +52,14 @@ export class S9pk { async icon(): Promise { const iconName = Object.keys(this.archive.contents.contents).find( (name) => - name.startsWith("icon.") && mime.getType(name)?.startsWith("image/"), + name.startsWith("icon.") && + (mime.contentType(name) || null)?.startsWith("image/"), ) if (!iconName) { throw new Error("no icon found in archive") } return ( - `data:${mime.getType(iconName)};base64,` + + `data:${mime.contentType(iconName)};base64,` + Buffer.from( await this.archive.contents.getPath([iconName])!.verifiedFileContents(), ).toString("base64") diff --git a/sdk/base/lib/types.ts b/sdk/base/lib/types.ts index 56e310efe..071647961 100644 --- a/sdk/base/lib/types.ts +++ b/sdk/base/lib/types.ts @@ -54,7 +54,7 @@ export namespace ExpectedExports { */ export type main = (options: { effects: Effects - started(onTerm: () => PromiseLike): PromiseLike + started(onTerm: () => PromiseLike): PromiseLike }) => Promise /** @@ -118,7 +118,7 @@ export type DaemonReceipt = { } export type Daemon = { wait(): Promise - term(): Promise + term(): Promise [DaemonProof]: never } @@ -135,7 +135,7 @@ export type CommandType = string | [string, ...string[]] export type DaemonReturned = { wait(): Promise - term(options?: { signal?: Signals; timeout?: number }): Promise + term(options?: { signal?: Signals; timeout?: number }): Promise } export declare const hostName: unique symbol diff --git a/sdk/base/lib/util/graph.ts b/sdk/base/lib/util/graph.ts index 5ad71a04d..682ccf63e 100644 --- a/sdk/base/lib/util/graph.ts +++ b/sdk/base/lib/util/graph.ts @@ -1,17 +1,17 @@ import { boolean } from "ts-matches" -export type Vertex = { +export type Vertex = { metadata: VMetadata edges: Array> } -export type Edge = { +export type Edge = { metadata: EMetadata from: Vertex to: Vertex } -export class Graph { +export class Graph { private readonly vertices: Array> = [] constructor() {} addVertex( @@ -46,7 +46,7 @@ export class Graph { } findVertex( predicate: (vertex: Vertex) => boolean, - ): Generator, void> { + ): Generator, null> { const veritces = this.vertices function* gen() { for (let vertex of veritces) { @@ -54,6 +54,7 @@ export class Graph { yield vertex } } + return null } return gen() } @@ -75,13 +76,13 @@ export class Graph { from: | Vertex | ((vertex: Vertex) => boolean), - ): Generator, void> { + ): Generator, null> { const visited: Array> = [] function* rec( vertex: Vertex, - ): Generator, void> { + ): Generator, null> { if (visited.includes(vertex)) { - return + return null } visited.push(vertex) yield vertex @@ -99,6 +100,7 @@ export class Graph { } } } + return null } if (from instanceof Function) { @@ -115,6 +117,7 @@ export class Graph { } } } + return null })() } else { return rec(from) @@ -124,13 +127,13 @@ export class Graph { to: | Vertex | ((vertex: Vertex) => boolean), - ): Generator, void> { + ): Generator, null> { const visited: Array> = [] function* rec( vertex: Vertex, - ): Generator, void> { + ): Generator, null> { if (visited.includes(vertex)) { - return + return null } visited.push(vertex) yield vertex @@ -148,6 +151,7 @@ export class Graph { } } } + return null } if (to instanceof Function) { @@ -164,6 +168,7 @@ export class Graph { } } } + return null })() } else { return rec(to) @@ -176,7 +181,7 @@ export class Graph { to: | Vertex | ((vertex: Vertex) => boolean), - ): Array> | void { + ): Array> | null { const isDone = to instanceof Function ? to @@ -186,12 +191,12 @@ export class Graph { function* check( vertex: Vertex, path: Array>, - ): Generator> | undefined> { + ): Generator> | null> { if (isDone(vertex)) { return path } if (visited.includes(vertex)) { - return + return null } visited.push(vertex) yield @@ -213,6 +218,7 @@ export class Graph { } } } + return null } if (from instanceof Function) { @@ -240,5 +246,6 @@ export class Graph { } } } + return null } } diff --git a/sdk/base/package-lock.json b/sdk/base/package-lock.json index 3a8e40c0c..91c470676 100644 --- a/sdk/base/package-lock.json +++ b/sdk/base/package-lock.json @@ -13,13 +13,14 @@ "@noble/hashes": "^1.4.0", "isomorphic-fetch": "^3.0.0", "lodash.merge": "^4.6.2", - "mime": "^4.0.3", + "mime-types": "^2.1.35", "ts-matches": "^5.5.1", "yaml": "^2.2.2" }, "devDependencies": { "@types/jest": "^29.4.0", "@types/lodash.merge": "^4.6.2", + "@types/mime-types": "^2.1.4", "jest": "^29.4.3", "peggy": "^3.0.2", "prettier": "^3.2.5", @@ -1249,6 +1250,13 @@ "@types/lodash": "*" } }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "18.15.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.10.tgz", @@ -3106,18 +3114,25 @@ "node": ">=8.6" } }, - "node_modules/mime": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.3.tgz", - "integrity": "sha512-KgUb15Oorc0NEKPbvfa0wRU+PItIEZmiv+pyAO2i0oTIVTJhlzMclU7w4RXWQrSOVH5ax/p/CkIO7KI4OyFJTQ==", - "funding": [ - "https://github.com/sponsors/broofa" - ], - "bin": { - "mime": "bin/cli.js" + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" }, "engines": { - "node": ">=16" + "node": ">= 0.6" } }, "node_modules/mimic-fn": { diff --git a/sdk/base/package.json b/sdk/base/package.json index a290d2a75..3d1d4b62e 100644 --- a/sdk/base/package.json +++ b/sdk/base/package.json @@ -21,14 +21,14 @@ }, "homepage": "https://github.com/Start9Labs/start-sdk#readme", "dependencies": { + "@iarna/toml": "^2.2.5", + "@noble/curves": "^1.4.0", + "@noble/hashes": "^1.4.0", "isomorphic-fetch": "^3.0.0", "lodash.merge": "^4.6.2", - "mime": "^4.0.3", + "mime-types": "^2.1.35", "ts-matches": "^5.5.1", - "yaml": "^2.2.2", - "@iarna/toml": "^2.2.5", - "@noble/curves": "^1.4.0", - "@noble/hashes": "^1.4.0" + "yaml": "^2.2.2" }, "prettier": { "trailingComma": "all", @@ -39,6 +39,7 @@ "devDependencies": { "@types/jest": "^29.4.0", "@types/lodash.merge": "^4.6.2", + "@types/mime-types": "^2.1.4", "jest": "^29.4.3", "peggy": "^3.0.2", "prettier": "^3.2.5", diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 05cc003cb..412dafe52 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -563,7 +563,7 @@ export class StartSdk { setupMain: ( fn: (o: { effects: Effects - started(onTerm: () => PromiseLike): PromiseLike + started(onTerm: () => PromiseLike): PromiseLike }) => Promise>, ) => setupMain(fn), /** @@ -657,12 +657,12 @@ export class StartSdk { ) => InputSpec.of(spec), }, Daemons: { - of(inputSpec: { + of(options: { effects: Effects - started: (onTerm: () => PromiseLike) => PromiseLike + started: (onTerm: () => PromiseLike) => PromiseLike healthReceipts: HealthReceipt[] }) { - return Daemons.of(inputSpec) + return Daemons.of(options) }, }, List: { diff --git a/sdk/package/lib/inits/setupInit.ts b/sdk/package/lib/inits/setupInit.ts index c9ab4f171..af2503dad 100644 --- a/sdk/package/lib/inits/setupInit.ts +++ b/sdk/package/lib/inits/setupInit.ts @@ -12,7 +12,7 @@ export function setupInit( install: Install, uninstall: Uninstall, setServiceInterfaces: UpdateServiceInterfaces, - setDependencies: (options: { effects: T.Effects }) => Promise, + setDependencies: (options: { effects: T.Effects }) => Promise, actions: Actions, exposedStore: ExposedStorePaths, ): { diff --git a/sdk/package/lib/inits/setupInstall.ts b/sdk/package/lib/inits/setupInstall.ts index d9f694021..9b5e92afe 100644 --- a/sdk/package/lib/inits/setupInstall.ts +++ b/sdk/package/lib/inits/setupInstall.ts @@ -2,7 +2,7 @@ import * as T from "../../../base/lib/types" export type InstallFn = (opts: { effects: T.Effects -}) => Promise +}) => Promise export class Install { private constructor(readonly fn: InstallFn) {} static of( diff --git a/sdk/package/lib/inits/setupUninstall.ts b/sdk/package/lib/inits/setupUninstall.ts index c863e1ad7..c169457ec 100644 --- a/sdk/package/lib/inits/setupUninstall.ts +++ b/sdk/package/lib/inits/setupUninstall.ts @@ -2,7 +2,7 @@ import * as T from "../../../base/lib/types" export type UninstallFn = (opts: { effects: T.Effects -}) => Promise +}) => Promise export class Uninstall { private constructor(readonly fn: UninstallFn) {} static of( diff --git a/sdk/package/lib/mainFn/CommandController.ts b/sdk/package/lib/mainFn/CommandController.ts index 78d888b95..601b759e6 100644 --- a/sdk/package/lib/mainFn/CommandController.ts +++ b/sdk/package/lib/mainFn/CommandController.ts @@ -43,8 +43,8 @@ export class CommandController { | undefined cwd?: string | undefined user?: string | undefined - onStdout?: (x: Buffer) => void - onStderr?: (x: Buffer) => void + onStdout?: (x: Buffer) => null + onStderr?: (x: Buffer) => null }, ) => { const commands = splitCommand(command) diff --git a/sdk/package/lib/mainFn/Daemon.ts b/sdk/package/lib/mainFn/Daemon.ts index 45f252dac..bde4654ee 100644 --- a/sdk/package/lib/mainFn/Daemon.ts +++ b/sdk/package/lib/mainFn/Daemon.ts @@ -37,8 +37,8 @@ export class Daemon { | undefined cwd?: string | undefined user?: string | undefined - onStdout?: (x: Buffer) => void - onStderr?: (x: Buffer) => void + onStdout?: (x: Buffer) => null + onStderr?: (x: Buffer) => null sigtermTimeout?: number }, ) => { diff --git a/sdk/package/lib/mainFn/Daemons.ts b/sdk/package/lib/mainFn/Daemons.ts index 0e05214f7..4c2506d1d 100644 --- a/sdk/package/lib/mainFn/Daemons.ts +++ b/sdk/package/lib/mainFn/Daemons.ts @@ -74,7 +74,7 @@ export class Daemons { private constructor( readonly effects: T.Effects, - readonly started: (onTerm: () => PromiseLike) => PromiseLike, + readonly started: (onTerm: () => PromiseLike) => PromiseLike, readonly daemons: Promise[], readonly ids: Ids[], readonly healthDaemons: HealthDaemon[], @@ -86,17 +86,17 @@ export class Daemons * * Daemons run in the order they are defined, with latter daemons being capable of * depending on prior daemons - * @param inputSpec + * @param options * @returns */ - static of(inputSpec: { + static of(options: { effects: T.Effects - started: (onTerm: () => PromiseLike) => PromiseLike + started: (onTerm: () => PromiseLike) => PromiseLike healthReceipts: HealthReceipt[] }) { return new Daemons( - inputSpec.effects, - inputSpec.started, + options.effects, + options.started, [], [], [], diff --git a/sdk/package/lib/mainFn/HealthDaemon.ts b/sdk/package/lib/mainFn/HealthDaemon.ts index 71ba83b3f..b66e3e406 100644 --- a/sdk/package/lib/mainFn/HealthDaemon.ts +++ b/sdk/package/lib/mainFn/HealthDaemon.ts @@ -81,7 +81,7 @@ export class HealthDaemon { } } - private healthCheckCleanup: (() => void) | null = null + private healthCheckCleanup: (() => null) | null = null private turnOffHealthCheck() { this.healthCheckCleanup?.() } @@ -125,6 +125,7 @@ export class HealthDaemon { this.healthCheckCleanup = () => { setStatus({ done: true }) this.healthCheckCleanup = null + return null } } diff --git a/sdk/package/lib/mainFn/index.ts b/sdk/package/lib/mainFn/index.ts index ee0481630..f1373c44e 100644 --- a/sdk/package/lib/mainFn/index.ts +++ b/sdk/package/lib/mainFn/index.ts @@ -17,7 +17,7 @@ export const DEFAULT_SIGTERM_TIMEOUT = 30_000 export const setupMain = ( fn: (o: { effects: T.Effects - started(onTerm: () => PromiseLike): PromiseLike + started(onTerm: () => PromiseLike): PromiseLike }) => Promise>, ): T.ExpectedExports.main => { return async (options) => { diff --git a/sdk/package/lib/util/SubContainer.ts b/sdk/package/lib/util/SubContainer.ts index b6a7492a7..16476f564 100644 --- a/sdk/package/lib/util/SubContainer.ts +++ b/sdk/package/lib/util/SubContainer.ts @@ -27,7 +27,7 @@ const TIMES_TO_WAIT_FOR_PROC = 100 * case where the subcontainer isn't owned by the process, the subcontainer shouldn't be destroyed. */ export interface ExecSpawnable { - get destroy(): undefined | (() => Promise) + get destroy(): undefined | (() => Promise) exec( command: string[], options?: CommandOptions & ExecOptions, @@ -47,7 +47,7 @@ export interface ExecSpawnable { export class SubContainer implements ExecSpawnable { private leader: cp.ChildProcess private leaderExited: boolean = false - private waitProc: () => Promise + private waitProc: () => Promise private constructor( readonly effects: T.Effects, readonly imageId: T.ImageId, @@ -79,7 +79,7 @@ export class SubContainer implements ExecSpawnable { } await wait(1) } - resolve() + resolve(null) }), ) } @@ -180,12 +180,12 @@ export class SubContainer implements ExecSpawnable { if (this.leaderExited) { return } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { try { let timeout = setTimeout(() => this.leader.kill("SIGKILL"), 30000) this.leader.on("exit", () => { clearTimeout(timeout) - resolve() + resolve(null) }) if (!this.leader.kill("SIGTERM")) { reject(new Error("kill(2) failed")) @@ -201,6 +201,7 @@ export class SubContainer implements ExecSpawnable { const guid = this.guid await this.killLeader() await this.effects.subcontainer.destroyFs({ guid }) + return null } } @@ -245,16 +246,16 @@ export class SubContainer implements ExecSpawnable { options || {}, ) if (options?.input) { - await new Promise((resolve, reject) => + await new Promise((resolve, reject) => child.stdin.write(options.input, (e) => { if (e) { reject(e) } else { - resolve() + resolve(null) } }), ) - await new Promise((resolve) => child.stdin.end(resolve)) + await new Promise((resolve) => child.stdin.end(resolve)) } const pid = child.pid const stdout = { data: "" as string | Buffer } diff --git a/web/package-lock.json b/web/package-lock.json index 2f05ddab3..1cc096b38 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -57,6 +57,7 @@ "ng-qrcode": "^7.0.0", "node-jose": "^2.2.0", "patch-db-client": "file:../patch-db/client", + "path-browserify": "^1.0.1", "pbkdf2": "^3.1.2", "rxjs": "^7.8.1", "swiper": "^8.2.4", @@ -123,13 +124,14 @@ "@noble/hashes": "^1.4.0", "isomorphic-fetch": "^3.0.0", "lodash.merge": "^4.6.2", - "mime": "^4.0.3", + "mime-types": "^2.1.35", "ts-matches": "^5.5.1", "yaml": "^2.2.2" }, "devDependencies": { "@types/jest": "^29.4.0", "@types/lodash.merge": "^4.6.2", + "@types/mime-types": "^2.1.4", "jest": "^29.4.3", "peggy": "^3.0.2", "prettier": "^3.2.5", @@ -11731,7 +11733,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", - "optional": true + "license": "MIT" }, "node_modules/path-exists": { "version": "4.0.0", diff --git a/web/package.json b/web/package.json index c73d93859..5d241c2f3 100644 --- a/web/package.json +++ b/web/package.json @@ -80,6 +80,7 @@ "ng-qrcode": "^7.0.0", "node-jose": "^2.2.0", "patch-db-client": "file:../patch-db/client", + "path-browserify": "^1.0.1", "pbkdf2": "^3.1.2", "rxjs": "^7.8.1", "swiper": "^8.2.4", diff --git a/web/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html b/web/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html index 6b72c38a8..c9ecc8ed5 100644 --- a/web/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html +++ b/web/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html @@ -11,10 +11,19 @@ Standard Actions + , private readonly actionService: ActionService, + private readonly standardActionsService: StandardActionsService, ) {} async handleAction( @@ -55,51 +49,12 @@ export class AppActionsPage { ) } - async tryUninstall(manifest: T.Manifest): Promise { - let message = - manifest.alerts.uninstall || - `Uninstalling ${manifest.title} will permanently delete its data` - - if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patch))) { - message = `${message}. Services that depend on ${manifest.title} will no longer work properly and may crash` - } - - const alert = await this.alertCtrl.create({ - header: 'Warning', - message, - buttons: [ - { - text: 'Cancel', - role: 'cancel', - }, - { - text: 'Uninstall', - handler: () => { - this.uninstall() - }, - cssClass: 'enter-click', - }, - ], - cssClass: 'alert-warning-message', - }) - - await alert.present() + async rebuild(id: string) { + return this.standardActionsService.rebuild(id) } - private async uninstall() { - const loader = this.loader.open(`Beginning uninstall...`).subscribe() - - try { - await this.api.uninstallPackage({ id: this.pkgId }) - this.api - .setDbValue(['ackInstructions', this.pkgId], false) - .catch(e => console.error('Failed to mark instructions as unseen', e)) - this.navCtrl.navigateRoot('/services') - } catch (e: any) { - this.errorService.handleError(e) - } finally { - loader.unsubscribe() - } + async tryUninstall(manifest: T.Manifest) { + return this.standardActionsService.tryUninstall(manifest) } } diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts index a3c6cc584..7d51a6fc8 100644 --- a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts @@ -18,6 +18,7 @@ import { AppShowDependenciesComponent } from './components/app-show-dependencies import { AppShowMenuComponent } from './components/app-show-menu/app-show-menu.component' import { AppShowHealthChecksComponent } from './components/app-show-health-checks/app-show-health-checks.component' import { AppShowAdditionalComponent } from './components/app-show-additional/app-show-additional.component' +import { AppShowErrorComponent } from './components/app-show-error/app-show-error.component' import { HealthColorPipe } from './pipes/health-color.pipe' import { ToHealthChecksPipe } from './pipes/to-health-checks.pipe' import { ToButtonsPipe } from './pipes/to-buttons.pipe' @@ -43,6 +44,7 @@ const routes: Routes = [ AppShowMenuComponent, AppShowHealthChecksComponent, AppShowAdditionalComponent, + AppShowErrorComponent, ], imports: [ CommonModule, diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html index 7ea0b86fd..ec59b1042 100644 --- a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html @@ -16,9 +16,9 @@ - + + diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts index 1743a86ec..c1574e476 100644 --- a/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts @@ -45,7 +45,7 @@ export interface DependencyInfo { changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppShowPage { - private readonly pkgId = getPkgId(this.route) + readonly pkgId = getPkgId(this.route) readonly pkgPlus$ = combineLatest([ this.patch.watch$('packageData'), @@ -58,9 +58,11 @@ export class AppShowPage { }), map(([allPkgs, depErrors]) => { const pkg = allPkgs[this.pkgId] + const manifest = getManifest(pkg) return { pkg, - dependencies: this.getDepInfo(pkg, allPkgs, depErrors), + manifest, + dependencies: this.getDepInfo(pkg, manifest, allPkgs, depErrors), status: renderPkgStatus(pkg, depErrors), } }), @@ -84,11 +86,10 @@ export class AppShowPage { private getDepInfo( pkg: PackageDataEntry, + manifest: T.Manifest, allPkgs: AllPackageData, depErrors: PkgDependencyErrors, ): DependencyInfo[] { - const manifest = getManifest(pkg) - return Object.keys(pkg.currentDependencies).map(id => this.getDepValues(pkg, allPkgs, manifest, id, depErrors), ) diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.html b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.html new file mode 100644 index 000000000..c056f2977 --- /dev/null +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.html @@ -0,0 +1,31 @@ +Message +
+ + {{ error.message }} + +
+ +Actions +
+

+ Rebuild Container + is harmless action that and only takes a few seconds to complete. It will + likely resolve this issue. + Uninstall Service + is a dangerous action that will remove the service from StartOS and wipe all + its data. +

+ + Rebuild Container + + + Uninstall Service + +
+ + + Full Stack Trace +
+ {{ error.message }} +
+
diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.ts b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.ts new file mode 100644 index 000000000..ef689f178 --- /dev/null +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-error/app-show-error.component.ts @@ -0,0 +1,45 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { ToastController } from '@ionic/angular' +import { copyToClipboard } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' +import { StandardActionsService } from 'src/app/services/standard-actions.service' + +@Component({ + selector: 'app-show-error', + templateUrl: 'app-show-error.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AppShowErrorComponent { + @Input() + manifest!: T.Manifest + + @Input() + error!: T.MainStatus & { main: 'error' } + + constructor( + private readonly toastCtrl: ToastController, + private readonly standardActionsService: StandardActionsService, + ) {} + + async copy(text: string): Promise { + const success = await copyToClipboard(text) + const message = success + ? 'Copied to clipboard!' + : 'Failed to copy to clipboard.' + + const toast = await this.toastCtrl.create({ + header: message, + position: 'bottom', + duration: 1000, + }) + await toast.present() + } + + async rebuild() { + return this.standardActionsService.rebuild(this.manifest.id) + } + + async tryUninstall() { + return this.standardActionsService.tryUninstall(this.manifest) + } +} diff --git a/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html index 5dc5faf67..e31d4cd24 100644 --- a/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html +++ b/web/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html @@ -11,7 +11,14 @@ - + diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index e428456f6..65997c49d 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -257,6 +257,9 @@ export module RR { export type StopPackageReq = { id: string } // package.stop export type StopPackageRes = null + export type RebuildPackageReq = { id: string } // package.rebuild + export type RebuildPackageRes = null + export type UninstallPackageReq = { id: string } // package.uninstall export type UninstallPackageRes = null diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index 1a5c1c57c..49571d97f 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -249,6 +249,10 @@ export abstract class ApiService { abstract stopPackage(params: RR.StopPackageReq): Promise + abstract rebuildPackage( + params: RR.RebuildPackageReq, + ): Promise + abstract uninstallPackage( params: RR.UninstallPackageReq, ): Promise diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 742076ec8..73f3e67c6 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -498,6 +498,12 @@ export class LiveApiService extends ApiService { return this.rpcRequest({ method: 'package.stop', params }) } + async rebuildPackage( + params: RR.RebuildPackageReq, + ): Promise { + return this.rpcRequest({ method: 'package.rebuild', params }) + } + async uninstallPackage( params: RR.UninstallPackageReq, ): Promise { diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 6c55bb9db..c4834c99e 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -2,10 +2,12 @@ import { Injectable } from '@angular/core' import { Log, RPCErrorDetails, RPCOptions, pauseFor } from '@start9labs/shared' import { ApiService } from './embassy-api.service' import { + AddOperation, Operation, PatchOp, pathFromArray, RemoveOperation, + ReplaceOperation, Revision, } from 'patch-db-client' import { @@ -636,14 +638,14 @@ export class MockApiService extends ApiService { async createBackup(params: RR.CreateBackupReq): Promise { await pauseFor(2000) - const path = '/serverInfo/statusInfo/backupProgress' + const serverPath = '/serverInfo/statusInfo/backupProgress' const ids = params.packageIds setTimeout(async () => { for (let i = 0; i < ids.length; i++) { const id = ids[i] - const appPath = `/packageData/${id}/status/main/status` - const appPatch = [ + const appPath = `/packageData/${id}/status/main/` + const appPatch: ReplaceOperation[] = [ { op: PatchOp.REPLACE, path: appPath, @@ -660,40 +662,43 @@ export class MockApiService extends ApiService { value: 'stopped', }, ]) - this.mockRevision([ + + const serverPatch: ReplaceOperation[] = [ { op: PatchOp.REPLACE, - path: `${path}/${id}/complete`, + path: `${serverPath}/${id}/complete`, value: true, }, - ]) + ] + this.mockRevision(serverPatch) } await pauseFor(1000) - // set server back to running - const lastPatch = [ + // remove backupProgress + const lastPatch: ReplaceOperation[] = [ { op: PatchOp.REPLACE, - path, + path: serverPath, value: null, }, ] this.mockRevision(lastPatch) }, 500) - const originalPatch = [ - { - op: PatchOp.REPLACE, - path, - value: ids.reduce((acc, val) => { - return { - ...acc, - [val]: { complete: false }, - } - }, {}), - }, - ] + const originalPatch: ReplaceOperation[] = + [ + { + op: PatchOp.REPLACE, + path: serverPath, + value: ids.reduce((acc, val) => { + return { + ...acc, + [val]: { complete: false }, + } + }, {}), + }, + ] this.mockRevision(originalPatch) @@ -750,7 +755,7 @@ export class MockApiService extends ApiService { this.installProgress(params.id) }, 1000) - const patch: Operation< + const patch: AddOperation< PackageDataEntry >[] = [ { @@ -799,7 +804,7 @@ export class MockApiService extends ApiService { params: RR.RestorePackagesReq, ): Promise { await pauseFor(2000) - const patch: Operation[] = params.ids.map(id => { + const patch: AddOperation[] = params.ids.map(id => { setTimeout(async () => { this.installProgress(id) }, 2000) @@ -826,76 +831,61 @@ export class MockApiService extends ApiService { } async startPackage(params: RR.StartPackageReq): Promise { - const path = `/packageData/${params.id}/status/main` + const path = `/packageData/${params.id}/status` await pauseFor(2000) setTimeout(async () => { - const patch2 = [ - { - op: PatchOp.REPLACE, - path: path + '/status', - value: 'running', - }, - { - op: PatchOp.REPLACE, - path: path + '/started', - value: new Date().toISOString(), - }, - ] - this.mockRevision(patch2) - - const patch3 = [ - { - op: PatchOp.REPLACE, - path: path + '/health', - value: { - 'ephemeral-health-check': { - result: 'starting', - }, - 'unnecessary-health-check': { - result: 'disabled', - }, - }, - }, - ] - this.mockRevision(patch3) - - await pauseFor(2000) - - const patch4 = [ + const patch2: ReplaceOperation[] = [ { op: PatchOp.REPLACE, - path: path + '/health', + path, value: { - 'ephemeral-health-check': { - result: 'starting', - }, - 'unnecessary-health-check': { - result: 'disabled', - }, - 'chain-state': { - result: 'loading', - message: 'Bitcoin is syncing from genesis', - }, - 'p2p-interface': { - result: 'success', - }, - 'rpc-interface': { - result: 'failure', - error: 'RPC interface unreachable.', + main: 'running', + started: new Date().toISOString(), + health: { + 'ephemeral-health-check': { + name: 'Ephemeral Health Check', + result: 'starting', + message: null, + }, + 'unnecessary-health-check': { + name: 'Unnecessary Health Check', + result: 'disabled', + message: 'Custom disabled message', + }, + 'chain-state': { + name: 'Chain State', + result: 'loading', + message: 'Bitcoin is syncing from genesis', + }, + 'p2p-interface': { + name: 'P2P Interface', + result: 'success', + message: null, + }, + 'rpc-interface': { + name: 'RPC Interface', + result: 'failure', + message: 'Custom failure message', + }, }, }, }, ] - this.mockRevision(patch4) + this.mockRevision(patch2) }, 2000) - const originalPatch = [ + const originalPatch: ReplaceOperation< + T.MainStatus & { main: 'starting' } + >[] = [ { op: PatchOp.REPLACE, - path: path + '/status', - value: 'starting', + path, + value: { + main: 'starting', + health: {}, + }, }, ] @@ -907,74 +897,57 @@ export class MockApiService extends ApiService { async restartPackage( params: RR.RestartPackageReq, ): Promise { - // first enact stop await pauseFor(2000) - const path = `/packageData/${params.id}/status/main` + const path = `/packageData/${params.id}/status` setTimeout(async () => { - const patch2: Operation[] = [ + const patch2: ReplaceOperation[] = [ { op: PatchOp.REPLACE, - path: path + '/status', - value: 'starting', - }, - { - op: PatchOp.ADD, - path: path + '/restarting', - value: true, - }, - ] - this.mockRevision(patch2) - - await pauseFor(2000) - - const patch3: Operation[] = [ - { - op: PatchOp.REPLACE, - path: path + '/status', - value: 'running', - }, - { - op: PatchOp.REMOVE, - path: path + '/restarting', - }, - { - op: PatchOp.REPLACE, - path: path + '/health', + path, value: { - 'ephemeral-health-check': { - result: 'starting', - }, - 'unnecessary-health-check': { - result: 'disabled', - }, - 'chain-state': { - result: 'loading', - message: 'Bitcoin is syncing from genesis', - }, - 'p2p-interface': { - result: 'success', - }, - 'rpc-interface': { - result: 'failure', - error: 'RPC interface unreachable.', + main: 'running', + started: new Date().toISOString(), + health: { + 'ephemeral-health-check': { + name: 'Ephemeral Health Check', + result: 'starting', + message: null, + }, + 'unnecessary-health-check': { + name: 'Unnecessary Health Check', + result: 'disabled', + message: 'Custom disabled message', + }, + 'chain-state': { + name: 'Chain State', + result: 'loading', + message: 'Bitcoin is syncing from genesis', + }, + 'p2p-interface': { + name: 'P2P Interface', + result: 'success', + message: null, + }, + 'rpc-interface': { + name: 'RPC Interface', + result: 'failure', + message: 'Custom failure message', + }, }, }, - } as any, + }, ] - this.mockRevision(patch3) + this.mockRevision(patch2) }, this.revertTime) - const patch = [ - { - op: PatchOp.REPLACE, - path: path + '/status', - value: 'restarting', - }, + const patch: ReplaceOperation[] = [ { op: PatchOp.REPLACE, - path: path + '/health', - value: {}, + path, + value: { + main: 'restarting', + }, }, ] @@ -985,29 +958,24 @@ export class MockApiService extends ApiService { async stopPackage(params: RR.StopPackageReq): Promise { await pauseFor(2000) - const path = `/packageData/${params.id}/status/main` + const path = `/packageData/${params.id}/status` setTimeout(() => { - const patch2 = [ + const patch2: ReplaceOperation[] = [ { op: PatchOp.REPLACE, path: path, - value: { - status: 'stopped', - }, + value: { main: 'stopped' }, }, ] this.mockRevision(patch2) }, this.revertTime) - const patch = [ + const patch: ReplaceOperation[] = [ { op: PatchOp.REPLACE, path: path, - value: { - status: 'stopping', - timeout: '35s', - }, + value: { main: 'stopping' }, }, ] @@ -1016,6 +984,12 @@ export class MockApiService extends ApiService { return null } + async rebuildPackage( + params: RR.RebuildPackageReq, + ): Promise { + return this.restartPackage(params) + } + async uninstallPackage( params: RR.UninstallPackageReq, ): Promise { @@ -1031,7 +1005,7 @@ export class MockApiService extends ApiService { this.mockRevision(patch2) }, this.revertTime) - const patch = [ + const patch: ReplaceOperation[] = [ { op: PatchOp.REPLACE, path: `/packageData/${params.id}/stateInfo/state`, 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 cdc8f7173..d89b5e87f 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -96,36 +96,14 @@ export const mockPatchData: DataModel = { icon: '/assets/img/service-icons/bitcoind.svg', lastBackup: null, status: { - main: 'running', - started: '2021-06-14T20:49:17.774Z', - health: { - 'ephemeral-health-check': { - name: 'Ephemeral Health Check', - result: 'starting', - message: null, - }, - 'chain-state': { - name: 'Chain State', - result: 'loading', - message: 'Bitcoin is syncing from genesis', - }, - 'p2p-interface': { - name: 'P2P', - result: 'success', - message: 'Health check successful', - }, - 'rpc-interface': { - name: 'RPC', - result: 'failure', - message: 'RPC interface unreachable.', - }, - 'unnecessary-health-check': { - name: 'Unnecessary Health Check', - result: 'disabled', - message: null, - }, - }, + main: 'stopped', }, + // status: { + // main: 'error', + // message: 'Bitcoin is erroring out', + // debug: 'This is a complete stack trace for bitcoin', + // onRebuild: 'start', + // }, actions: { config: { name: 'Bitcoin Config', diff --git a/web/projects/ui/src/app/services/pkg-status-rendering.service.ts b/web/projects/ui/src/app/services/pkg-status-rendering.service.ts index f987f6b2c..3f2a7b917 100644 --- a/web/projects/ui/src/app/services/pkg-status-rendering.service.ts +++ b/web/projects/ui/src/app/services/pkg-status-rendering.service.ts @@ -80,6 +80,7 @@ export type PrimaryStatus = | 'stopped' | 'backingUp' | 'needsConfig' + | 'error' export type DependencyStatus = 'warning' | 'satisfied' @@ -139,6 +140,11 @@ export const PrimaryRendering: Record = { color: 'warning', showDots: false, }, + error: { + display: 'Service Launch Error', + color: 'danger', + showDots: false, + }, } export const DependencyRendering: Record = { diff --git a/web/projects/ui/src/app/services/standard-actions.service.ts b/web/projects/ui/src/app/services/standard-actions.service.ts new file mode 100644 index 000000000..664db822c --- /dev/null +++ b/web/projects/ui/src/app/services/standard-actions.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core' +import { T } from '@start9labs/start-sdk' +import { hasCurrentDeps } from '../util/has-deps' +import { getAllPackages } from '../util/get-package-data' +import { PatchDB } from 'patch-db-client' +import { DataModel } from './patch-db/data-model' +import { AlertController, NavController } from '@ionic/angular' +import { ApiService } from './api/embassy-api.service' +import { ErrorService, LoadingService } from '@start9labs/shared' + +@Injectable({ + providedIn: 'root', +}) +export class StandardActionsService { + constructor( + private readonly patch: PatchDB, + private readonly api: ApiService, + private readonly alertCtrl: AlertController, + private readonly errorService: ErrorService, + private readonly loader: LoadingService, + private readonly navCtrl: NavController, + ) {} + + async rebuild(id: string) { + const loader = this.loader.open(`Rebuilding Container...`).subscribe() + + try { + await this.api.rebuildPackage({ id }) + this.navCtrl.navigateBack('/services/' + id) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } + + async tryUninstall(manifest: T.Manifest): Promise { + const { id, title, alerts } = manifest + + let message = + alerts.uninstall || + `Uninstalling ${title} will permanently delete its data` + + if (hasCurrentDeps(id, await getAllPackages(this.patch))) { + message = `${message}. Services that depend on ${title} will no longer work properly and may crash` + } + + const alert = await this.alertCtrl.create({ + header: 'Warning', + message, + buttons: [ + { + text: 'Cancel', + role: 'cancel', + }, + { + text: 'Uninstall', + handler: () => { + this.uninstall(id) + }, + cssClass: 'enter-click', + }, + ], + cssClass: 'alert-warning-message', + }) + + await alert.present() + } + + private async uninstall(id: string) { + const loader = this.loader.open(`Beginning uninstall...`).subscribe() + + try { + await this.api.uninstallPackage({ id }) + this.api + .setDbValue(['ackInstructions', id], false) + .catch(e => console.error('Failed to mark instructions as unseen', e)) + this.navCtrl.navigateRoot('/services') + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } +} diff --git a/web/projects/ui/src/styles.scss b/web/projects/ui/src/styles.scss index fa4a6598e..34c76848b 100644 --- a/web/projects/ui/src/styles.scss +++ b/web/projects/ui/src/styles.scss @@ -110,6 +110,12 @@ $subheader-height: 48px; } } +.code-block { + background-color: rgb(69, 69, 69); + padding: 12px; + margin-bottom: 32px; +} + .center { display: block; margin: auto; diff --git a/web/tsconfig.json b/web/tsconfig.json index 89f8e9548..6663ac431 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -22,7 +22,8 @@ "paths": { /* These paths are relative to each app base folder */ "@start9labs/marketplace": ["../marketplace/src/public-api"], - "@start9labs/shared": ["../shared/src/public-api"] + "@start9labs/shared": ["../shared/src/public-api"], + "path": ["../../node_modules/path-browserify"] }, "typeRoots": ["node_modules/@types"], "types": ["node"]