add error status (#2746)

* add error status

* update types

* ṗ̶̰̙̓͒̈́ͅü̵̢̙̫̣ŗ̷̪̺̺͛g̴̲͉͎̬̒̇e̵̪̎̅͌ ̶̡̜̘͐͛t̶͎͍̣̿̍̐h̴͕̩͗̈́̎̑e̵͚͒̂͝ ̸̛͙̦͈͝v̶̱͙̬̽̔ọ̶̧̡̒̓i̸̬̲͍̋̈́d̴͉̀

* fix some extra voids

* add `package.rebuild`

* introduce error status and pkg rebuild and fix mocks

* minor fixes

* fix build

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Aiden McClelland
2024-09-26 20:19:06 -06:00
committed by GitHub
parent db0695126f
commit e7fa94c3d3
49 changed files with 642 additions and 413 deletions

View File

@@ -173,7 +173,7 @@ export function makeEffects(context: EffectContext): Effects {
T.Effects["subcontainer"]["createFs"] T.Effects["subcontainer"]["createFs"]
> >
}, },
destroyFs(options: { guid: string }): Promise<void> { destroyFs(options: { guid: string }): Promise<null> {
return rpcRound("subcontainer.destroy-fs", options) as ReturnType< return rpcRound("subcontainer.destroy-fs", options) as ReturnType<
T.Effects["subcontainer"]["destroyFs"] T.Effects["subcontainer"]["destroyFs"]
> >
@@ -284,7 +284,7 @@ export function makeEffects(context: EffectContext): Effects {
> >
}, },
setMainStatus(o: { status: "running" | "stopped" }): Promise<void> { setMainStatus(o: { status: "running" | "stopped" }): Promise<null> {
return rpcRound("set-main-status", o) as ReturnType< return rpcRound("set-main-status", o) as ReturnType<
T.Effects["setHealth"] T.Effects["setHealth"]
> >

View File

@@ -92,6 +92,7 @@ export class SystemForStartOs implements System {
const started = async (onTerm: () => Promise<void>) => { const started = async (onTerm: () => Promise<void>) => {
await effects.setMainStatus({ status: "running" }) await effects.setMainStatus({ status: "running" })
mainOnTerm = onTerm mainOnTerm = onTerm
return null
} }
const daemons = await ( const daemons = await (
await this.abi.main({ await this.abi.main({

View File

@@ -351,6 +351,14 @@ impl Debug for ErrorData {
} }
} }
impl std::error::Error for ErrorData {} impl std::error::Error for ErrorData {}
impl From<Error> for ErrorData {
fn from(value: Error) -> Self {
Self {
details: value.to_string(),
debug: format!("{:?}", value),
}
}
}
impl From<&RpcError> for ErrorData { impl From<&RpcError> for ErrorData {
fn from(value: &RpcError) -> Self { fn from(value: &RpcError) -> Self {
Self { Self {

View File

@@ -5,6 +5,16 @@ use tracing::instrument;
use crate::util::Invoke; use crate::util::Invoke;
use crate::Error; use crate::Error;
pub async fn is_mountpoint(path: impl AsRef<Path>) -> Result<bool, Error> {
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)] #[instrument(skip_all)]
pub async fn bind<P0: AsRef<Path>, P1: AsRef<Path>>( pub async fn bind<P0: AsRef<Path>, P1: AsRef<Path>>(
src: P0, src: P0,
@@ -16,13 +26,7 @@ pub async fn bind<P0: AsRef<Path>, P1: AsRef<Path>>(
src.as_ref().display(), src.as_ref().display(),
dst.as_ref().display() dst.as_ref().display()
); );
let is_mountpoint = tokio::process::Command::new("mountpoint") if is_mountpoint(&dst).await? {
.arg(dst.as_ref())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await?;
if is_mountpoint.success() {
unmount(dst.as_ref(), true).await?; unmount(dst.as_ref(), true).await?;
} }
tokio::fs::create_dir_all(&src).await?; tokio::fs::create_dir_all(&src).await?;

View File

@@ -292,6 +292,13 @@ pub fn package<C: Context>() -> ParentHandler<C> {
.no_display() .no_display()
.with_call_remote::<CliContext>(), .with_call_remote::<CliContext>(),
) )
.subcommand(
"rebuild",
from_fn_async(service::rebuild)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_call_remote::<CliContext>(),
)
.subcommand("logs", logs::package_logs()) .subcommand("logs", logs::package_logs())
.subcommand( .subcommand(
"logs", "logs",

View File

@@ -126,7 +126,8 @@ impl LxcManager {
Path::new(LXC_CONTAINER_DIR).join(container).join("rootfs"), Path::new(LXC_CONTAINER_DIR).join(container).join("rootfs"),
true, true,
) )
.await?; .await
.log_err();
if tokio_stream::wrappers::ReadDirStream::new( if tokio_stream::wrappers::ReadDirStream::new(
tokio::fs::read_dir(&rootfs_path).await?, tokio::fs::read_dir(&rootfs_path).await?,
) )

View File

@@ -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)] #[derive(Deserialize, Serialize, Parser, TS)]
pub struct ConnectParams { pub struct ConnectParams {
pub id: PackageId, pub id: PackageId,

View File

@@ -7,6 +7,7 @@ use futures::{Future, FutureExt};
use helpers::NonDetachingJoinHandle; use helpers::NonDetachingJoinHandle;
use imbl::OrdMap; use imbl::OrdMap;
use imbl_value::InternedString; use imbl_value::InternedString;
use models::ErrorData;
use tokio::sync::{Mutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock}; use tokio::sync::{Mutex, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
use tracing::instrument; use tracing::instrument;
@@ -22,6 +23,7 @@ use crate::progress::{FullProgressTracker, PhaseProgressTrackerHandle, ProgressT
use crate::s9pk::manifest::PackageId; use crate::s9pk::manifest::PackageId;
use crate::s9pk::merkle_archive::source::FileSource; use crate::s9pk::merkle_archive::source::FileSource;
use crate::s9pk::S9pk; use crate::s9pk::S9pk;
use crate::service::start_stop::StartStop;
use crate::service::{LoadDisposition, Service, ServiceRef}; use crate::service::{LoadDisposition, Service, ServiceRef};
use crate::status::MainStatus; use crate::status::MainStatus;
use crate::util::serde::Pem; use crate::util::serde::Pem;
@@ -87,8 +89,30 @@ impl ServiceMap {
if let Some(service) = service.take() { if let Some(service) = service.take() {
shutdown_err = service.shutdown().await; shutdown_err = service.shutdown().await;
} }
// TODO: retry on error? match Service::load(ctx, id, disposition).await {
*service = Service::load(ctx, id, disposition).await?.map(From::from); 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?; shutdown_err?;
Ok(()) Ok(())
} }

View File

@@ -17,6 +17,11 @@ pub mod health_check;
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(rename_all_fields = "camelCase")] #[serde(rename_all_fields = "camelCase")]
pub enum MainStatus { pub enum MainStatus {
Error {
on_rebuild: StartStop,
message: String,
debug: Option<String>,
},
Stopped, Stopped,
Restarting, Restarting,
Restoring, Restoring,
@@ -43,12 +48,20 @@ impl MainStatus {
| MainStatus::Restarting | MainStatus::Restarting
| MainStatus::BackingUp { | MainStatus::BackingUp {
on_complete: StartStop::Start, on_complete: StartStop::Start,
}
| MainStatus::Error {
on_rebuild: StartStop::Start,
..
} => true, } => true,
MainStatus::Stopped MainStatus::Stopped
| MainStatus::Restoring | MainStatus::Restoring
| MainStatus::Stopping { .. } | MainStatus::Stopping { .. }
| MainStatus::BackingUp { | MainStatus::BackingUp {
on_complete: StartStop::Stop, on_complete: StartStop::Stop,
}
| MainStatus::Error {
on_rebuild: StartStop::Stop,
..
} => false, } => false,
} }
} }
@@ -70,7 +83,8 @@ impl MainStatus {
| MainStatus::Stopped | MainStatus::Stopped
| MainStatus::Restoring | MainStatus::Restoring
| MainStatus::Stopping { .. } | MainStatus::Stopping { .. }
| MainStatus::Restarting => None, | MainStatus::Restarting
| MainStatus::Error { .. } => None,
} }
} }
} }

View File

@@ -31,14 +31,14 @@ export type Effects = {
constRetry: () => void constRetry: () => void
clearCallbacks: ( clearCallbacks: (
options: { only: number[] } | { except: number[] }, options: { only: number[] } | { except: number[] },
) => Promise<void> ) => Promise<null>
// action // action
action: { action: {
/** Define an action that can be invoked by a user or service */ /** Define an action that can be invoked by a user or service */
export(options: { id: ActionId; metadata: ActionMetadata }): Promise<void> export(options: { id: ActionId; metadata: ActionMetadata }): Promise<null>
/** Remove all exported actions */ /** Remove all exported actions */
clear(options: { except: ActionId[] }): Promise<void> clear(options: { except: ActionId[] }): Promise<null>
getInput(options: { getInput(options: {
packageId?: PackageId packageId?: PackageId
actionId: ActionId actionId: ActionId
@@ -50,23 +50,23 @@ export type Effects = {
}): Promise<ActionResult | null> }): Promise<ActionResult | null>
request<Input extends Record<string, unknown>>( request<Input extends Record<string, unknown>>(
options: RequestActionParams, options: RequestActionParams,
): Promise<void> ): Promise<null>
clearRequests( clearRequests(
options: { only: ActionId[] } | { except: ActionId[] }, options: { only: ActionId[] } | { except: ActionId[] },
): Promise<void> ): Promise<null>
} }
// control // control
/** restart this service's main function */ /** restart this service's main function */
restart(): Promise<void> restart(): Promise<null>
/** stop this service's main function */ /** stop this service's main function */
shutdown(): Promise<void> shutdown(): Promise<null>
/** indicate to the host os what runstate the service is in */ /** indicate to the host os what runstate the service is in */
setMainStatus(options: SetMainStatus): Promise<void> setMainStatus(options: SetMainStatus): Promise<null>
// dependency // dependency
/** Set the dependencies of what the service needs, usually run during the inputSpec action as a best practice */ /** Set the dependencies of what the service needs, usually run during the inputSpec action as a best practice */
setDependencies(options: { dependencies: Dependencies }): Promise<void> setDependencies(options: { dependencies: Dependencies }): Promise<null>
/** Get the list of the dependencies, both the dynamic set by the effect of setDependencies and the end result any required in the manifest */ /** 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<DependencyRequirement[]> getDependencies(): Promise<DependencyRequirement[]>
/** Test whether current dependency requirements are satisfied */ /** Test whether current dependency requirements are satisfied */
@@ -86,11 +86,11 @@ export type Effects = {
/** Returns a list of the ids of all installed packages */ /** Returns a list of the ids of all installed packages */
getInstalledPackages(): Promise<string[]> getInstalledPackages(): Promise<string[]>
/** grants access to certain paths in the store to dependents */ /** grants access to certain paths in the store to dependents */
exposeForDependents(options: { paths: string[] }): Promise<void> exposeForDependents(options: { paths: string[] }): Promise<null>
// health // health
/** sets the result of a health check */ /** sets the result of a health check */
setHealth(o: SetHealth): Promise<void> setHealth(o: SetHealth): Promise<null>
// subcontainer // subcontainer
subcontainer: { subcontainer: {
@@ -100,13 +100,13 @@ export type Effects = {
name: string | null name: string | null
}): Promise<[string, string]> }): Promise<[string, string]>
/** A low level api used by SubContainer */ /** A low level api used by SubContainer */
destroyFs(options: { guid: string }): Promise<void> destroyFs(options: { guid: string }): Promise<null>
} }
// net // net
// bind // bind
/** Creates a host connected to the specified port with the provided options */ /** Creates a host connected to the specified port with the provided options */
bind(options: BindParams): Promise<void> bind(options: BindParams): Promise<null>
/** Get the port address for a service */ /** Get the port address for a service */
getServicePortForward(options: { getServicePortForward(options: {
packageId?: PackageId packageId?: PackageId
@@ -116,7 +116,7 @@ export type Effects = {
/** Removes all network bindings, called in the setupInputSpec */ /** Removes all network bindings, called in the setupInputSpec */
clearBindings(options: { clearBindings(options: {
except: { id: HostId; internalPort: number }[] except: { id: HostId; internalPort: number }[]
}): Promise<void> }): Promise<null>
// host // host
/** Returns information about the specified host, if it exists */ /** Returns information about the specified host, if it exists */
getHostInfo(options: { getHostInfo(options: {
@@ -134,7 +134,7 @@ export type Effects = {
getContainerIp(): Promise<string> getContainerIp(): Promise<string>
// interface // interface
/** Creates an interface bound to a specific host and port to show to the user */ /** Creates an interface bound to a specific host and port to show to the user */
exportServiceInterface(options: ExportServiceInterfaceParams): Promise<void> exportServiceInterface(options: ExportServiceInterfaceParams): Promise<null>
/** Returns an exported service interface */ /** Returns an exported service interface */
getServiceInterface(options: { getServiceInterface(options: {
packageId?: PackageId packageId?: PackageId
@@ -149,7 +149,7 @@ export type Effects = {
/** Removes all service interfaces */ /** Removes all service interfaces */
clearServiceInterfaces(options: { clearServiceInterfaces(options: {
except: ServiceInterfaceId[] except: ServiceInterfaceId[]
}): Promise<void> }): Promise<null>
// ssl // ssl
/** Returns a PEM encoded fullchain for the hostnames specified */ /** Returns a PEM encoded fullchain for the hostnames specified */
getSslCertificate: (options: { 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/) */ /** Sets the value for the wrapper at the path, it will override, using the [JsonPath](https://jsonpath.com/) */
path: StorePath path: StorePath
value: ExtractStore value: ExtractStore
}): Promise<void> }): Promise<null>
} }
/** sets the version that this service's data has been migrated to */ /** sets the version that this service's data has been migrated to */
setDataVersion(options: { version: string }): Promise<void> setDataVersion(options: { version: string }): Promise<null>
/** returns the version that this service's data has been migrated to */ /** returns the version that this service's data has been migrated to */
getDataVersion(): Promise<string | null> getDataVersion(): Promise<string | null>

View File

@@ -1,6 +1,7 @@
import { InputSpec } from "./input/builder" import { InputSpec } from "./input/builder"
import { ExtractInputSpecType } from "./input/builder/inputSpec" import { ExtractInputSpecType } from "./input/builder/inputSpec"
import * as T from "../types" import * as T from "../types"
import { once } from "../util"
export type Run< export type Run<
A extends A extends
@@ -130,21 +131,19 @@ export class Actions<
): Actions<Store, AllActions & { [id in A["id"]]: A }> { ): Actions<Store, AllActions & { [id in A["id"]]: A }> {
return new Actions({ ...this.actions, [action.id]: action }) return new Actions({ ...this.actions, [action.id]: action })
} }
update(options: { effects: T.Effects }): Promise<void> { async update(options: { effects: T.Effects }): Promise<null> {
const updater = async (options: { effects: T.Effects }) => { options.effects = {
for (let action of Object.values(this.actions)) { ...options.effects,
await action.exportMetadata(options) 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
await options.effects.action.clear({ except: Object.keys(this.actions) }) }),
} }
const updaterCtx = { options } for (let action of Object.values(this.actions)) {
updaterCtx.options = { await action.exportMetadata(options)
effects: {
...options.effects,
constRetry: () => updater(updaterCtx.options),
},
} }
return updater(updaterCtx.options) await options.effects.action.clear({ except: Object.keys(this.actions) })
return null
} }
get<Id extends T.ActionId>(actionId: Id): AllActions[Id] { get<Id extends T.ActionId>(actionId: Id): AllActions[Id] {
return this.actions[actionId] return this.actions[actionId]

View File

@@ -13,15 +13,15 @@ export type CheckDependencies<DependencyId extends PackageId = PackageId> = {
) => boolean ) => boolean
satisfied: () => boolean satisfied: () => boolean
throwIfInstalledNotSatisfied: (packageId: DependencyId) => void throwIfInstalledNotSatisfied: (packageId: DependencyId) => null
throwIfInstalledVersionNotSatisfied: (packageId: DependencyId) => void throwIfInstalledVersionNotSatisfied: (packageId: DependencyId) => null
throwIfRunningNotSatisfied: (packageId: DependencyId) => void throwIfRunningNotSatisfied: (packageId: DependencyId) => null
throwIfActionsNotSatisfied: (packageId: DependencyId) => void throwIfActionsNotSatisfied: (packageId: DependencyId) => null
throwIfHealthNotSatisfied: ( throwIfHealthNotSatisfied: (
packageId: DependencyId, packageId: DependencyId,
healthCheckId?: HealthCheckId, healthCheckId?: HealthCheckId,
) => void ) => null
throwIfNotSatisfied: (packageId?: DependencyId) => void throwIfNotSatisfied: (packageId?: DependencyId) => null
} }
export async function checkDependencies< export async function checkDependencies<
DependencyId extends PackageId = PackageId, DependencyId extends PackageId = PackageId,
@@ -100,6 +100,7 @@ export async function checkDependencies<
if (!dep.result.installedVersion) { if (!dep.result.installedVersion) {
throw new Error(`${dep.result.title || packageId} is not installed`) throw new Error(`${dep.result.title || packageId} is not installed`)
} }
return null
} }
const throwIfInstalledVersionNotSatisfied = (packageId: DependencyId) => { const throwIfInstalledVersionNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId) 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}`, `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 throwIfRunningNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId) const dep = find(packageId)
if (dep.requirement.kind === "running" && !dep.result.isRunning) { if (dep.requirement.kind === "running" && !dep.result.isRunning) {
throw new Error(`${dep.result.title || packageId} is not running`) throw new Error(`${dep.result.title || packageId} is not running`)
} }
return null
} }
const throwIfActionsNotSatisfied = (packageId: DependencyId) => { const throwIfActionsNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId) const dep = find(packageId)
@@ -132,6 +135,7 @@ export async function checkDependencies<
`The following action requests have not been fulfilled: ${reqs.join(", ")}`, `The following action requests have not been fulfilled: ${reqs.join(", ")}`,
) )
} }
return null
} }
const throwIfHealthNotSatisfied = ( const throwIfHealthNotSatisfied = (
packageId: DependencyId, packageId: DependencyId,
@@ -158,6 +162,7 @@ export async function checkDependencies<
.join("; "), .join("; "),
) )
} }
return null
} }
const throwIfPkgNotSatisfied = (packageId: DependencyId) => { const throwIfPkgNotSatisfied = (packageId: DependencyId) => {
throwIfInstalledNotSatisfied(packageId) throwIfInstalledNotSatisfied(packageId)
@@ -165,6 +170,7 @@ export async function checkDependencies<
throwIfRunningNotSatisfied(packageId) throwIfRunningNotSatisfied(packageId)
throwIfActionsNotSatisfied(packageId) throwIfActionsNotSatisfied(packageId)
throwIfHealthNotSatisfied(packageId) throwIfHealthNotSatisfied(packageId)
return null
} }
const throwIfNotSatisfied = (packageId?: DependencyId) => const throwIfNotSatisfied = (packageId?: DependencyId) =>
packageId packageId
@@ -182,6 +188,7 @@ export async function checkDependencies<
if (err.length) { if (err.length) {
throw new Error(err.join("; ")) throw new Error(err.join("; "))
} }
return null
})() })()
return { return {

View File

@@ -1,4 +1,5 @@
import * as T from "../types" import * as T from "../types"
import { once } from "../util"
import { Dependency } from "./Dependency" import { Dependency } from "./Dependency"
type DependencyType<Manifest extends T.Manifest> = { type DependencyType<Manifest extends T.Manifest> = {
@@ -17,40 +18,38 @@ type DependencyType<Manifest extends T.Manifest> = {
export function setupDependencies<Manifest extends T.Manifest>( export function setupDependencies<Manifest extends T.Manifest>(
fn: (options: { effects: T.Effects }) => Promise<DependencyType<Manifest>>, fn: (options: { effects: T.Effects }) => Promise<DependencyType<Manifest>>,
): (options: { effects: T.Effects }) => Promise<void> { ): (options: { effects: T.Effects }) => Promise<null> {
return (options: { effects: T.Effects }) => { const cell = { updater: async (_: { effects: T.Effects }) => null }
const updater = async (options: { effects: T.Effects }) => { cell.updater = async (options: { effects: T.Effects }) => {
const dependencyType = await fn(options) options.effects = {
return await options.effects.setDependencies({ ...options.effects,
dependencies: Object.entries(dependencyType).map( constRetry: once(() => {
([ cell.updater(options)
id, }),
{
data: { versionRange, ...x },
},
]) => ({
id,
...x,
...(x.type === "running"
? {
kind: "running",
healthChecks: x.healthChecks,
}
: {
kind: "exists",
}),
versionRange: versionRange.toString(),
}),
),
})
} }
const updaterCtx = { options } const dependencyType = await fn(options)
updaterCtx.options = { return await options.effects.setDependencies({
effects: { dependencies: Object.entries(dependencyType).map(
...options.effects, ([
constRetry: () => updater(updaterCtx.options), id,
}, {
} data: { versionRange, ...x },
return updater(updaterCtx.options) },
]) => ({
id,
...x,
...(x.type === "running"
? {
kind: "running",
healthChecks: x.healthChecks,
}
: {
kind: "exists",
}),
versionRange: versionRange.toString(),
}),
),
})
} }
return cell.updater
} }

View File

@@ -1,4 +1,5 @@
import * as T from "../types" import * as T from "../types"
import { once } from "../util"
import { AddressReceipt } from "./AddressReceipt" import { AddressReceipt } from "./AddressReceipt"
declare const UpdateServiceInterfacesProof: unique symbol declare const UpdateServiceInterfacesProof: unique symbol
@@ -21,34 +22,36 @@ export const setupServiceInterfaces: SetupServiceInterfaces = <
Output extends ServiceInterfacesReceipt, Output extends ServiceInterfacesReceipt,
>( >(
fn: SetServiceInterfaces<Output>, fn: SetServiceInterfaces<Output>,
) => ) => {
((options: { effects: T.Effects }) => { const cell = {
const updater = async (options: { effects: T.Effects }) => { updater: (async (options: { effects: T.Effects }) =>
const bindings: T.BindId[] = [] [] as any as Output) as UpdateServiceInterfaces<Output>,
const interfaces: T.ServiceInterfaceId[] = [] }
const res = await fn({ cell.updater = (async (options: { effects: T.Effects }) => {
effects: { options.effects = {
...options.effects, ...options.effects,
bind: (params: T.BindParams) => { constRetry: once(() => {
bindings.push({ id: params.id, internalPort: params.internalPort }) cell.updater(options)
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 updaterCtx = { options } const bindings: T.BindId[] = []
updaterCtx.options = { const interfaces: T.ServiceInterfaceId[] = []
const res = await fn({
effects: { effects: {
...options.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<Output> }) as UpdateServiceInterfaces<Output>
return cell.updater
}

View File

@@ -4,6 +4,12 @@ import type { NamedHealthCheckResult } from "./NamedHealthCheckResult"
import type { StartStop } from "./StartStop" import type { StartStop } from "./StartStop"
export type MainStatus = export type MainStatus =
| {
main: "error"
onRebuild: StartStop
message: string
debug: string | null
}
| { main: "stopped" } | { main: "stopped" }
| { main: "restarting" } | { main: "restarting" }
| { main: "restoring" } | { main: "restoring" }

View File

@@ -1,6 +1,6 @@
import { DataUrl, Manifest, MerkleArchiveCommitment } from "../osBindings" import { DataUrl, Manifest, MerkleArchiveCommitment } from "../osBindings"
import { ArrayBufferReader, MerkleArchive } from "./merkleArchive" import { ArrayBufferReader, MerkleArchive } from "./merkleArchive"
import mime from "mime" import mime from "mime-types"
const magicAndVersion = new Uint8Array([59, 59, 2]) const magicAndVersion = new Uint8Array([59, 59, 2])
@@ -52,13 +52,14 @@ export class S9pk {
async icon(): Promise<DataUrl> { async icon(): Promise<DataUrl> {
const iconName = Object.keys(this.archive.contents.contents).find( const iconName = Object.keys(this.archive.contents.contents).find(
(name) => (name) =>
name.startsWith("icon.") && mime.getType(name)?.startsWith("image/"), name.startsWith("icon.") &&
(mime.contentType(name) || null)?.startsWith("image/"),
) )
if (!iconName) { if (!iconName) {
throw new Error("no icon found in archive") throw new Error("no icon found in archive")
} }
return ( return (
`data:${mime.getType(iconName)};base64,` + `data:${mime.contentType(iconName)};base64,` +
Buffer.from( Buffer.from(
await this.archive.contents.getPath([iconName])!.verifiedFileContents(), await this.archive.contents.getPath([iconName])!.verifiedFileContents(),
).toString("base64") ).toString("base64")

View File

@@ -54,7 +54,7 @@ export namespace ExpectedExports {
*/ */
export type main = (options: { export type main = (options: {
effects: Effects effects: Effects
started(onTerm: () => PromiseLike<void>): PromiseLike<void> started(onTerm: () => PromiseLike<void>): PromiseLike<null>
}) => Promise<DaemonBuildable> }) => Promise<DaemonBuildable>
/** /**
@@ -118,7 +118,7 @@ export type DaemonReceipt = {
} }
export type Daemon = { export type Daemon = {
wait(): Promise<string> wait(): Promise<string>
term(): Promise<void> term(): Promise<null>
[DaemonProof]: never [DaemonProof]: never
} }
@@ -135,7 +135,7 @@ export type CommandType = string | [string, ...string[]]
export type DaemonReturned = { export type DaemonReturned = {
wait(): Promise<unknown> wait(): Promise<unknown>
term(options?: { signal?: Signals; timeout?: number }): Promise<void> term(options?: { signal?: Signals; timeout?: number }): Promise<null>
} }
export declare const hostName: unique symbol export declare const hostName: unique symbol

View File

@@ -1,17 +1,17 @@
import { boolean } from "ts-matches" import { boolean } from "ts-matches"
export type Vertex<VMetadata = void, EMetadata = void> = { export type Vertex<VMetadata = null, EMetadata = null> = {
metadata: VMetadata metadata: VMetadata
edges: Array<Edge<EMetadata, VMetadata>> edges: Array<Edge<EMetadata, VMetadata>>
} }
export type Edge<EMetadata = void, VMetadata = void> = { export type Edge<EMetadata = null, VMetadata = null> = {
metadata: EMetadata metadata: EMetadata
from: Vertex<VMetadata, EMetadata> from: Vertex<VMetadata, EMetadata>
to: Vertex<VMetadata, EMetadata> to: Vertex<VMetadata, EMetadata>
} }
export class Graph<VMetadata = void, EMetadata = void> { export class Graph<VMetadata = null, EMetadata = null> {
private readonly vertices: Array<Vertex<VMetadata, EMetadata>> = [] private readonly vertices: Array<Vertex<VMetadata, EMetadata>> = []
constructor() {} constructor() {}
addVertex( addVertex(
@@ -46,7 +46,7 @@ export class Graph<VMetadata = void, EMetadata = void> {
} }
findVertex( findVertex(
predicate: (vertex: Vertex<VMetadata, EMetadata>) => boolean, predicate: (vertex: Vertex<VMetadata, EMetadata>) => boolean,
): Generator<Vertex<VMetadata, EMetadata>, void> { ): Generator<Vertex<VMetadata, EMetadata>, null> {
const veritces = this.vertices const veritces = this.vertices
function* gen() { function* gen() {
for (let vertex of veritces) { for (let vertex of veritces) {
@@ -54,6 +54,7 @@ export class Graph<VMetadata = void, EMetadata = void> {
yield vertex yield vertex
} }
} }
return null
} }
return gen() return gen()
} }
@@ -75,13 +76,13 @@ export class Graph<VMetadata = void, EMetadata = void> {
from: from:
| Vertex<VMetadata, EMetadata> | Vertex<VMetadata, EMetadata>
| ((vertex: Vertex<VMetadata, EMetadata>) => boolean), | ((vertex: Vertex<VMetadata, EMetadata>) => boolean),
): Generator<Vertex<VMetadata, EMetadata>, void> { ): Generator<Vertex<VMetadata, EMetadata>, null> {
const visited: Array<Vertex<VMetadata, EMetadata>> = [] const visited: Array<Vertex<VMetadata, EMetadata>> = []
function* rec( function* rec(
vertex: Vertex<VMetadata, EMetadata>, vertex: Vertex<VMetadata, EMetadata>,
): Generator<Vertex<VMetadata, EMetadata>, void> { ): Generator<Vertex<VMetadata, EMetadata>, null> {
if (visited.includes(vertex)) { if (visited.includes(vertex)) {
return return null
} }
visited.push(vertex) visited.push(vertex)
yield vertex yield vertex
@@ -99,6 +100,7 @@ export class Graph<VMetadata = void, EMetadata = void> {
} }
} }
} }
return null
} }
if (from instanceof Function) { if (from instanceof Function) {
@@ -115,6 +117,7 @@ export class Graph<VMetadata = void, EMetadata = void> {
} }
} }
} }
return null
})() })()
} else { } else {
return rec(from) return rec(from)
@@ -124,13 +127,13 @@ export class Graph<VMetadata = void, EMetadata = void> {
to: to:
| Vertex<VMetadata, EMetadata> | Vertex<VMetadata, EMetadata>
| ((vertex: Vertex<VMetadata, EMetadata>) => boolean), | ((vertex: Vertex<VMetadata, EMetadata>) => boolean),
): Generator<Vertex<VMetadata, EMetadata>, void> { ): Generator<Vertex<VMetadata, EMetadata>, null> {
const visited: Array<Vertex<VMetadata, EMetadata>> = [] const visited: Array<Vertex<VMetadata, EMetadata>> = []
function* rec( function* rec(
vertex: Vertex<VMetadata, EMetadata>, vertex: Vertex<VMetadata, EMetadata>,
): Generator<Vertex<VMetadata, EMetadata>, void> { ): Generator<Vertex<VMetadata, EMetadata>, null> {
if (visited.includes(vertex)) { if (visited.includes(vertex)) {
return return null
} }
visited.push(vertex) visited.push(vertex)
yield vertex yield vertex
@@ -148,6 +151,7 @@ export class Graph<VMetadata = void, EMetadata = void> {
} }
} }
} }
return null
} }
if (to instanceof Function) { if (to instanceof Function) {
@@ -164,6 +168,7 @@ export class Graph<VMetadata = void, EMetadata = void> {
} }
} }
} }
return null
})() })()
} else { } else {
return rec(to) return rec(to)
@@ -176,7 +181,7 @@ export class Graph<VMetadata = void, EMetadata = void> {
to: to:
| Vertex<VMetadata, EMetadata> | Vertex<VMetadata, EMetadata>
| ((vertex: Vertex<VMetadata, EMetadata>) => boolean), | ((vertex: Vertex<VMetadata, EMetadata>) => boolean),
): Array<Edge<EMetadata, VMetadata>> | void { ): Array<Edge<EMetadata, VMetadata>> | null {
const isDone = const isDone =
to instanceof Function to instanceof Function
? to ? to
@@ -186,12 +191,12 @@ export class Graph<VMetadata = void, EMetadata = void> {
function* check( function* check(
vertex: Vertex<VMetadata, EMetadata>, vertex: Vertex<VMetadata, EMetadata>,
path: Array<Edge<EMetadata, VMetadata>>, path: Array<Edge<EMetadata, VMetadata>>,
): Generator<undefined, Array<Edge<EMetadata, VMetadata>> | undefined> { ): Generator<undefined, Array<Edge<EMetadata, VMetadata>> | null> {
if (isDone(vertex)) { if (isDone(vertex)) {
return path return path
} }
if (visited.includes(vertex)) { if (visited.includes(vertex)) {
return return null
} }
visited.push(vertex) visited.push(vertex)
yield yield
@@ -213,6 +218,7 @@ export class Graph<VMetadata = void, EMetadata = void> {
} }
} }
} }
return null
} }
if (from instanceof Function) { if (from instanceof Function) {
@@ -240,5 +246,6 @@ export class Graph<VMetadata = void, EMetadata = void> {
} }
} }
} }
return null
} }
} }

View File

@@ -13,13 +13,14 @@
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"isomorphic-fetch": "^3.0.0", "isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"mime": "^4.0.3", "mime-types": "^2.1.35",
"ts-matches": "^5.5.1", "ts-matches": "^5.5.1",
"yaml": "^2.2.2" "yaml": "^2.2.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.4.0", "@types/jest": "^29.4.0",
"@types/lodash.merge": "^4.6.2", "@types/lodash.merge": "^4.6.2",
"@types/mime-types": "^2.1.4",
"jest": "^29.4.3", "jest": "^29.4.3",
"peggy": "^3.0.2", "peggy": "^3.0.2",
"prettier": "^3.2.5", "prettier": "^3.2.5",
@@ -1249,6 +1250,13 @@
"@types/lodash": "*" "@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": { "node_modules/@types/node": {
"version": "18.15.10", "version": "18.15.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.10.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.10.tgz",
@@ -3106,18 +3114,25 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime": { "node_modules/mime-db": {
"version": "4.0.3", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.3.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-KgUb15Oorc0NEKPbvfa0wRU+PItIEZmiv+pyAO2i0oTIVTJhlzMclU7w4RXWQrSOVH5ax/p/CkIO7KI4OyFJTQ==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"funding": [ "license": "MIT",
"https://github.com/sponsors/broofa" "engines": {
], "node": ">= 0.6"
"bin": { }
"mime": "bin/cli.js" },
"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": { "engines": {
"node": ">=16" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-fn": { "node_modules/mimic-fn": {

View File

@@ -21,14 +21,14 @@
}, },
"homepage": "https://github.com/Start9Labs/start-sdk#readme", "homepage": "https://github.com/Start9Labs/start-sdk#readme",
"dependencies": { "dependencies": {
"isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2",
"mime": "^4.0.3",
"ts-matches": "^5.5.1",
"yaml": "^2.2.2",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@noble/curves": "^1.4.0", "@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0" "@noble/hashes": "^1.4.0",
"isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2",
"mime-types": "^2.1.35",
"ts-matches": "^5.5.1",
"yaml": "^2.2.2"
}, },
"prettier": { "prettier": {
"trailingComma": "all", "trailingComma": "all",
@@ -39,6 +39,7 @@
"devDependencies": { "devDependencies": {
"@types/jest": "^29.4.0", "@types/jest": "^29.4.0",
"@types/lodash.merge": "^4.6.2", "@types/lodash.merge": "^4.6.2",
"@types/mime-types": "^2.1.4",
"jest": "^29.4.3", "jest": "^29.4.3",
"peggy": "^3.0.2", "peggy": "^3.0.2",
"prettier": "^3.2.5", "prettier": "^3.2.5",

View File

@@ -563,7 +563,7 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
setupMain: ( setupMain: (
fn: (o: { fn: (o: {
effects: Effects effects: Effects
started(onTerm: () => PromiseLike<void>): PromiseLike<void> started(onTerm: () => PromiseLike<void>): PromiseLike<null>
}) => Promise<Daemons<Manifest, any>>, }) => Promise<Daemons<Manifest, any>>,
) => setupMain<Manifest, Store>(fn), ) => setupMain<Manifest, Store>(fn),
/** /**
@@ -657,12 +657,12 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
) => InputSpec.of<Spec, Store>(spec), ) => InputSpec.of<Spec, Store>(spec),
}, },
Daemons: { Daemons: {
of(inputSpec: { of(options: {
effects: Effects effects: Effects
started: (onTerm: () => PromiseLike<void>) => PromiseLike<void> started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>
healthReceipts: HealthReceipt[] healthReceipts: HealthReceipt[]
}) { }) {
return Daemons.of<Manifest>(inputSpec) return Daemons.of<Manifest>(options)
}, },
}, },
List: { List: {

View File

@@ -12,7 +12,7 @@ export function setupInit<Manifest extends T.Manifest, Store>(
install: Install<Manifest, Store>, install: Install<Manifest, Store>,
uninstall: Uninstall<Manifest, Store>, uninstall: Uninstall<Manifest, Store>,
setServiceInterfaces: UpdateServiceInterfaces<any>, setServiceInterfaces: UpdateServiceInterfaces<any>,
setDependencies: (options: { effects: T.Effects }) => Promise<void>, setDependencies: (options: { effects: T.Effects }) => Promise<null>,
actions: Actions<Store, any>, actions: Actions<Store, any>,
exposedStore: ExposedStorePaths, exposedStore: ExposedStorePaths,
): { ): {

View File

@@ -2,7 +2,7 @@ import * as T from "../../../base/lib/types"
export type InstallFn<Manifest extends T.Manifest, Store> = (opts: { export type InstallFn<Manifest extends T.Manifest, Store> = (opts: {
effects: T.Effects effects: T.Effects
}) => Promise<void> }) => Promise<null>
export class Install<Manifest extends T.Manifest, Store> { export class Install<Manifest extends T.Manifest, Store> {
private constructor(readonly fn: InstallFn<Manifest, Store>) {} private constructor(readonly fn: InstallFn<Manifest, Store>) {}
static of<Manifest extends T.Manifest, Store>( static of<Manifest extends T.Manifest, Store>(

View File

@@ -2,7 +2,7 @@ import * as T from "../../../base/lib/types"
export type UninstallFn<Manifest extends T.Manifest, Store> = (opts: { export type UninstallFn<Manifest extends T.Manifest, Store> = (opts: {
effects: T.Effects effects: T.Effects
}) => Promise<void> }) => Promise<null>
export class Uninstall<Manifest extends T.Manifest, Store> { export class Uninstall<Manifest extends T.Manifest, Store> {
private constructor(readonly fn: UninstallFn<Manifest, Store>) {} private constructor(readonly fn: UninstallFn<Manifest, Store>) {}
static of<Manifest extends T.Manifest, Store>( static of<Manifest extends T.Manifest, Store>(

View File

@@ -43,8 +43,8 @@ export class CommandController {
| undefined | undefined
cwd?: string | undefined cwd?: string | undefined
user?: string | undefined user?: string | undefined
onStdout?: (x: Buffer) => void onStdout?: (x: Buffer) => null
onStderr?: (x: Buffer) => void onStderr?: (x: Buffer) => null
}, },
) => { ) => {
const commands = splitCommand(command) const commands = splitCommand(command)

View File

@@ -37,8 +37,8 @@ export class Daemon {
| undefined | undefined
cwd?: string | undefined cwd?: string | undefined
user?: string | undefined user?: string | undefined
onStdout?: (x: Buffer) => void onStdout?: (x: Buffer) => null
onStderr?: (x: Buffer) => void onStderr?: (x: Buffer) => null
sigtermTimeout?: number sigtermTimeout?: number
}, },
) => { ) => {

View File

@@ -74,7 +74,7 @@ export class Daemons<Manifest extends T.Manifest, Ids extends string>
{ {
private constructor( private constructor(
readonly effects: T.Effects, readonly effects: T.Effects,
readonly started: (onTerm: () => PromiseLike<void>) => PromiseLike<void>, readonly started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>,
readonly daemons: Promise<Daemon>[], readonly daemons: Promise<Daemon>[],
readonly ids: Ids[], readonly ids: Ids[],
readonly healthDaemons: HealthDaemon[], readonly healthDaemons: HealthDaemon[],
@@ -86,17 +86,17 @@ export class Daemons<Manifest extends T.Manifest, Ids extends string>
* *
* Daemons run in the order they are defined, with latter daemons being capable of * Daemons run in the order they are defined, with latter daemons being capable of
* depending on prior daemons * depending on prior daemons
* @param inputSpec * @param options
* @returns * @returns
*/ */
static of<Manifest extends T.Manifest>(inputSpec: { static of<Manifest extends T.Manifest>(options: {
effects: T.Effects effects: T.Effects
started: (onTerm: () => PromiseLike<void>) => PromiseLike<void> started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>
healthReceipts: HealthReceipt[] healthReceipts: HealthReceipt[]
}) { }) {
return new Daemons<Manifest, never>( return new Daemons<Manifest, never>(
inputSpec.effects, options.effects,
inputSpec.started, options.started,
[], [],
[], [],
[], [],

View File

@@ -81,7 +81,7 @@ export class HealthDaemon {
} }
} }
private healthCheckCleanup: (() => void) | null = null private healthCheckCleanup: (() => null) | null = null
private turnOffHealthCheck() { private turnOffHealthCheck() {
this.healthCheckCleanup?.() this.healthCheckCleanup?.()
} }
@@ -125,6 +125,7 @@ export class HealthDaemon {
this.healthCheckCleanup = () => { this.healthCheckCleanup = () => {
setStatus({ done: true }) setStatus({ done: true })
this.healthCheckCleanup = null this.healthCheckCleanup = null
return null
} }
} }

View File

@@ -17,7 +17,7 @@ export const DEFAULT_SIGTERM_TIMEOUT = 30_000
export const setupMain = <Manifest extends T.Manifest, Store>( export const setupMain = <Manifest extends T.Manifest, Store>(
fn: (o: { fn: (o: {
effects: T.Effects effects: T.Effects
started(onTerm: () => PromiseLike<void>): PromiseLike<void> started(onTerm: () => PromiseLike<void>): PromiseLike<null>
}) => Promise<Daemons<Manifest, any>>, }) => Promise<Daemons<Manifest, any>>,
): T.ExpectedExports.main => { ): T.ExpectedExports.main => {
return async (options) => { return async (options) => {

View File

@@ -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. * case where the subcontainer isn't owned by the process, the subcontainer shouldn't be destroyed.
*/ */
export interface ExecSpawnable { export interface ExecSpawnable {
get destroy(): undefined | (() => Promise<void>) get destroy(): undefined | (() => Promise<null>)
exec( exec(
command: string[], command: string[],
options?: CommandOptions & ExecOptions, options?: CommandOptions & ExecOptions,
@@ -47,7 +47,7 @@ export interface ExecSpawnable {
export class SubContainer implements ExecSpawnable { export class SubContainer implements ExecSpawnable {
private leader: cp.ChildProcess private leader: cp.ChildProcess
private leaderExited: boolean = false private leaderExited: boolean = false
private waitProc: () => Promise<void> private waitProc: () => Promise<null>
private constructor( private constructor(
readonly effects: T.Effects, readonly effects: T.Effects,
readonly imageId: T.ImageId, readonly imageId: T.ImageId,
@@ -79,7 +79,7 @@ export class SubContainer implements ExecSpawnable {
} }
await wait(1) await wait(1)
} }
resolve() resolve(null)
}), }),
) )
} }
@@ -180,12 +180,12 @@ export class SubContainer implements ExecSpawnable {
if (this.leaderExited) { if (this.leaderExited) {
return return
} }
return new Promise<void>((resolve, reject) => { return new Promise<null>((resolve, reject) => {
try { try {
let timeout = setTimeout(() => this.leader.kill("SIGKILL"), 30000) let timeout = setTimeout(() => this.leader.kill("SIGKILL"), 30000)
this.leader.on("exit", () => { this.leader.on("exit", () => {
clearTimeout(timeout) clearTimeout(timeout)
resolve() resolve(null)
}) })
if (!this.leader.kill("SIGTERM")) { if (!this.leader.kill("SIGTERM")) {
reject(new Error("kill(2) failed")) reject(new Error("kill(2) failed"))
@@ -201,6 +201,7 @@ export class SubContainer implements ExecSpawnable {
const guid = this.guid const guid = this.guid
await this.killLeader() await this.killLeader()
await this.effects.subcontainer.destroyFs({ guid }) await this.effects.subcontainer.destroyFs({ guid })
return null
} }
} }
@@ -245,16 +246,16 @@ export class SubContainer implements ExecSpawnable {
options || {}, options || {},
) )
if (options?.input) { if (options?.input) {
await new Promise<void>((resolve, reject) => await new Promise<null>((resolve, reject) =>
child.stdin.write(options.input, (e) => { child.stdin.write(options.input, (e) => {
if (e) { if (e) {
reject(e) reject(e)
} else { } else {
resolve() resolve(null)
} }
}), }),
) )
await new Promise<void>((resolve) => child.stdin.end(resolve)) await new Promise<null>((resolve) => child.stdin.end(resolve))
} }
const pid = child.pid const pid = child.pid
const stdout = { data: "" as string | Buffer } const stdout = { data: "" as string | Buffer }

6
web/package-lock.json generated
View File

@@ -57,6 +57,7 @@
"ng-qrcode": "^7.0.0", "ng-qrcode": "^7.0.0",
"node-jose": "^2.2.0", "node-jose": "^2.2.0",
"patch-db-client": "file:../patch-db/client", "patch-db-client": "file:../patch-db/client",
"path-browserify": "^1.0.1",
"pbkdf2": "^3.1.2", "pbkdf2": "^3.1.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swiper": "^8.2.4", "swiper": "^8.2.4",
@@ -123,13 +124,14 @@
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"isomorphic-fetch": "^3.0.0", "isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"mime": "^4.0.3", "mime-types": "^2.1.35",
"ts-matches": "^5.5.1", "ts-matches": "^5.5.1",
"yaml": "^2.2.2" "yaml": "^2.2.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.4.0", "@types/jest": "^29.4.0",
"@types/lodash.merge": "^4.6.2", "@types/lodash.merge": "^4.6.2",
"@types/mime-types": "^2.1.4",
"jest": "^29.4.3", "jest": "^29.4.3",
"peggy": "^3.0.2", "peggy": "^3.0.2",
"prettier": "^3.2.5", "prettier": "^3.2.5",
@@ -11731,7 +11733,7 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"optional": true "license": "MIT"
}, },
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",

View File

@@ -80,6 +80,7 @@
"ng-qrcode": "^7.0.0", "ng-qrcode": "^7.0.0",
"node-jose": "^2.2.0", "node-jose": "^2.2.0",
"patch-db-client": "file:../patch-db/client", "patch-db-client": "file:../patch-db/client",
"path-browserify": "^1.0.1",
"pbkdf2": "^3.1.2", "pbkdf2": "^3.1.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swiper": "^8.2.4", "swiper": "^8.2.4",

View File

@@ -11,10 +11,19 @@
<ion-item-group *ngIf="pkg$ | async as pkg"> <ion-item-group *ngIf="pkg$ | async as pkg">
<!-- ** standard actions ** --> <!-- ** standard actions ** -->
<ion-item-divider>Standard Actions</ion-item-divider> <ion-item-divider>Standard Actions</ion-item-divider>
<app-actions-item
[action]="{
name: 'Rebuild Service',
description: 'Rebuilds the service container. It is harmless and only takes a few seconds to complete, but it should only be necessary if a StartOS bug is preventing dependencies, interfaces, or actions from synchronizing.',
visibility: 'enabled'
}"
icon="construct-outline"
(click)="rebuild(pkg.manifest.id)"
></app-actions-item>
<app-actions-item <app-actions-item
[action]="{ [action]="{
name: 'Uninstall', name: 'Uninstall',
description: 'This will uninstall the service from StartOS and delete all data permanently.', description: 'Uninstalls this service from StartOS and delete all data permanently.',
visibility: 'enabled' visibility: 'enabled'
}" }"
icon="trash-outline" icon="trash-outline"

View File

@@ -1,14 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { AlertController, NavController } from '@ionic/angular' import { getPkgId } from '@start9labs/shared'
import { ErrorService, getPkgId, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ActionService } from 'src/app/services/action.service' import { ActionService } from 'src/app/services/action.service'
import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { getAllPackages, getManifest } from 'src/app/util/get-package-data' import { getManifest } from 'src/app/util/get-package-data'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { filter, map } from 'rxjs' import { filter, map } from 'rxjs'
@Component({ @Component({
@@ -35,13 +33,9 @@ export class AppActionsPage {
constructor( constructor(
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly api: ApiService,
private readonly alertCtrl: AlertController,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>, private readonly patch: PatchDB<DataModel>,
private readonly actionService: ActionService, private readonly actionService: ActionService,
private readonly standardActionsService: StandardActionsService,
) {} ) {}
async handleAction( async handleAction(
@@ -55,51 +49,12 @@ export class AppActionsPage {
) )
} }
async tryUninstall(manifest: T.Manifest): Promise<void> { async rebuild(id: string) {
let message = return this.standardActionsService.rebuild(id)
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()
} }
private async uninstall() { async tryUninstall(manifest: T.Manifest) {
const loader = this.loader.open(`Beginning uninstall...`).subscribe() return this.standardActionsService.tryUninstall(manifest)
try {
await this.api.uninstallPackage({ id: this.pkgId })
this.api
.setDbValue<boolean>(['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()
}
} }
} }

View File

@@ -18,6 +18,7 @@ import { AppShowDependenciesComponent } from './components/app-show-dependencies
import { AppShowMenuComponent } from './components/app-show-menu/app-show-menu.component' 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 { AppShowHealthChecksComponent } from './components/app-show-health-checks/app-show-health-checks.component'
import { AppShowAdditionalComponent } from './components/app-show-additional/app-show-additional.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 { HealthColorPipe } from './pipes/health-color.pipe'
import { ToHealthChecksPipe } from './pipes/to-health-checks.pipe' import { ToHealthChecksPipe } from './pipes/to-health-checks.pipe'
import { ToButtonsPipe } from './pipes/to-buttons.pipe' import { ToButtonsPipe } from './pipes/to-buttons.pipe'
@@ -43,6 +44,7 @@ const routes: Routes = [
AppShowMenuComponent, AppShowMenuComponent,
AppShowHealthChecksComponent, AppShowHealthChecksComponent,
AppShowAdditionalComponent, AppShowAdditionalComponent,
AppShowErrorComponent,
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@@ -16,9 +16,9 @@
<ion-item-group *ngIf="pkgPlus.status as status"> <ion-item-group *ngIf="pkgPlus.status as status">
<!-- ** status ** --> <!-- ** status ** -->
<app-show-status [pkg]="pkg" [status]="status"></app-show-status> <app-show-status [pkg]="pkg" [status]="status"></app-show-status>
<!-- ** installed && !backingUp ** --> <!-- ** installed && !backingUp && !error ** -->
<ng-container <ng-container
*ngIf="isInstalled(pkg) && status.primary !== 'backingUp'" *ngIf="isInstalled(pkg) && status.primary !== 'backingUp' && status.primary !== 'error'"
> >
<!-- ** health checks ** --> <!-- ** health checks ** -->
<app-show-health-checks <app-show-health-checks
@@ -35,6 +35,11 @@
<!-- ** additional ** --> <!-- ** additional ** -->
<app-show-additional [pkg]="pkg"></app-show-additional> <app-show-additional [pkg]="pkg"></app-show-additional>
</ng-container> </ng-container>
<app-show-error
*ngIf="pkg.status.main === 'error'"
[manifest]="pkgPlus.manifest"
[error]="pkg.status"
></app-show-error>
</ion-item-group> </ion-item-group>
</ng-template> </ng-template>
</ion-content> </ion-content>

View File

@@ -45,7 +45,7 @@ export interface DependencyInfo {
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppShowPage { export class AppShowPage {
private readonly pkgId = getPkgId(this.route) readonly pkgId = getPkgId(this.route)
readonly pkgPlus$ = combineLatest([ readonly pkgPlus$ = combineLatest([
this.patch.watch$('packageData'), this.patch.watch$('packageData'),
@@ -58,9 +58,11 @@ export class AppShowPage {
}), }),
map(([allPkgs, depErrors]) => { map(([allPkgs, depErrors]) => {
const pkg = allPkgs[this.pkgId] const pkg = allPkgs[this.pkgId]
const manifest = getManifest(pkg)
return { return {
pkg, pkg,
dependencies: this.getDepInfo(pkg, allPkgs, depErrors), manifest,
dependencies: this.getDepInfo(pkg, manifest, allPkgs, depErrors),
status: renderPkgStatus(pkg, depErrors), status: renderPkgStatus(pkg, depErrors),
} }
}), }),
@@ -84,11 +86,10 @@ export class AppShowPage {
private getDepInfo( private getDepInfo(
pkg: PackageDataEntry, pkg: PackageDataEntry,
manifest: T.Manifest,
allPkgs: AllPackageData, allPkgs: AllPackageData,
depErrors: PkgDependencyErrors, depErrors: PkgDependencyErrors,
): DependencyInfo[] { ): DependencyInfo[] {
const manifest = getManifest(pkg)
return Object.keys(pkg.currentDependencies).map(id => return Object.keys(pkg.currentDependencies).map(id =>
this.getDepValues(pkg, allPkgs, manifest, id, depErrors), this.getDepValues(pkg, allPkgs, manifest, id, depErrors),
) )

View File

@@ -0,0 +1,31 @@
<ion-item-divider>Message</ion-item-divider>
<div class="code-block ion-margin">
<code>
<ion-text color="warning">{{ error.message }}</ion-text>
</code>
</div>
<ion-item-divider>Actions</ion-item-divider>
<div class="ion-margin">
<p>
<b>Rebuild Container</b>
is harmless action that and only takes a few seconds to complete. It will
likely resolve this issue.
<b>Uninstall Service</b>
is a dangerous action that will remove the service from StartOS and wipe all
its data.
</p>
<ion-button class="ion-margin-end" (click)="rebuild()">
Rebuild Container
</ion-button>
<ion-button (click)="tryUninstall()" color="danger">
Uninstall Service
</ion-button>
</div>
<ng-container *ngIf="error.debug">
<ion-item-divider>Full Stack Trace</ion-item-divider>
<div class="code-block ion-margin">
<code>{{ error.message }}</code>
</div>
</ng-container>

View File

@@ -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<void> {
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)
}
}

View File

@@ -11,7 +11,14 @@
</ion-label> </ion-label>
</ion-item> </ion-item>
<ng-container *ngIf="isInstalled(pkg) && (connection$ | async)"> <ng-container
*ngIf="
isInstalled(pkg) &&
pkg.status.main !== 'backingUp' &&
pkg.status.main !== 'error' &&
(connection$ | async)
"
>
<ion-grid> <ion-grid>
<ion-row style="padding-left: 12px"> <ion-row style="padding-left: 12px">
<ion-col> <ion-col>

View File

@@ -257,6 +257,9 @@ export module RR {
export type StopPackageReq = { id: string } // package.stop export type StopPackageReq = { id: string } // package.stop
export type StopPackageRes = null export type StopPackageRes = null
export type RebuildPackageReq = { id: string } // package.rebuild
export type RebuildPackageRes = null
export type UninstallPackageReq = { id: string } // package.uninstall export type UninstallPackageReq = { id: string } // package.uninstall
export type UninstallPackageRes = null export type UninstallPackageRes = null

View File

@@ -249,6 +249,10 @@ export abstract class ApiService {
abstract stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes> abstract stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes>
abstract rebuildPackage(
params: RR.RebuildPackageReq,
): Promise<RR.RebuildPackageRes>
abstract uninstallPackage( abstract uninstallPackage(
params: RR.UninstallPackageReq, params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes> ): Promise<RR.UninstallPackageRes>

View File

@@ -498,6 +498,12 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.stop', params }) return this.rpcRequest({ method: 'package.stop', params })
} }
async rebuildPackage(
params: RR.RebuildPackageReq,
): Promise<RR.RebuildPackageRes> {
return this.rpcRequest({ method: 'package.rebuild', params })
}
async uninstallPackage( async uninstallPackage(
params: RR.UninstallPackageReq, params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes> { ): Promise<RR.UninstallPackageRes> {

View File

@@ -2,10 +2,12 @@ import { Injectable } from '@angular/core'
import { Log, RPCErrorDetails, RPCOptions, pauseFor } from '@start9labs/shared' import { Log, RPCErrorDetails, RPCOptions, pauseFor } from '@start9labs/shared'
import { ApiService } from './embassy-api.service' import { ApiService } from './embassy-api.service'
import { import {
AddOperation,
Operation, Operation,
PatchOp, PatchOp,
pathFromArray, pathFromArray,
RemoveOperation, RemoveOperation,
ReplaceOperation,
Revision, Revision,
} from 'patch-db-client' } from 'patch-db-client'
import { import {
@@ -636,14 +638,14 @@ export class MockApiService extends ApiService {
async createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes> { async createBackup(params: RR.CreateBackupReq): Promise<RR.CreateBackupRes> {
await pauseFor(2000) await pauseFor(2000)
const path = '/serverInfo/statusInfo/backupProgress' const serverPath = '/serverInfo/statusInfo/backupProgress'
const ids = params.packageIds const ids = params.packageIds
setTimeout(async () => { setTimeout(async () => {
for (let i = 0; i < ids.length; i++) { for (let i = 0; i < ids.length; i++) {
const id = ids[i] const id = ids[i]
const appPath = `/packageData/${id}/status/main/status` const appPath = `/packageData/${id}/status/main/`
const appPatch = [ const appPatch: ReplaceOperation<T.MainStatus['main']>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: appPath, path: appPath,
@@ -660,40 +662,43 @@ export class MockApiService extends ApiService {
value: 'stopped', value: 'stopped',
}, },
]) ])
this.mockRevision([
const serverPatch: ReplaceOperation<T.BackupProgress['complete']>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: `${path}/${id}/complete`, path: `${serverPath}/${id}/complete`,
value: true, value: true,
}, },
]) ]
this.mockRevision(serverPatch)
} }
await pauseFor(1000) await pauseFor(1000)
// set server back to running // remove backupProgress
const lastPatch = [ const lastPatch: ReplaceOperation<T.ServerStatus['backupProgress']>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path, path: serverPath,
value: null, value: null,
}, },
] ]
this.mockRevision(lastPatch) this.mockRevision(lastPatch)
}, 500) }, 500)
const originalPatch = [ const originalPatch: ReplaceOperation<T.ServerStatus['backupProgress']>[] =
{ [
op: PatchOp.REPLACE, {
path, op: PatchOp.REPLACE,
value: ids.reduce((acc, val) => { path: serverPath,
return { value: ids.reduce((acc, val) => {
...acc, return {
[val]: { complete: false }, ...acc,
} [val]: { complete: false },
}, {}), }
}, }, {}),
] },
]
this.mockRevision(originalPatch) this.mockRevision(originalPatch)
@@ -750,7 +755,7 @@ export class MockApiService extends ApiService {
this.installProgress(params.id) this.installProgress(params.id)
}, 1000) }, 1000)
const patch: Operation< const patch: AddOperation<
PackageDataEntry<InstallingState | UpdatingState> PackageDataEntry<InstallingState | UpdatingState>
>[] = [ >[] = [
{ {
@@ -799,7 +804,7 @@ export class MockApiService extends ApiService {
params: RR.RestorePackagesReq, params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes> { ): Promise<RR.RestorePackagesRes> {
await pauseFor(2000) await pauseFor(2000)
const patch: Operation<PackageDataEntry>[] = params.ids.map(id => { const patch: AddOperation<PackageDataEntry>[] = params.ids.map(id => {
setTimeout(async () => { setTimeout(async () => {
this.installProgress(id) this.installProgress(id)
}, 2000) }, 2000)
@@ -826,76 +831,61 @@ export class MockApiService extends ApiService {
} }
async startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes> { async startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
const path = `/packageData/${params.id}/status/main` const path = `/packageData/${params.id}/status`
await pauseFor(2000) await pauseFor(2000)
setTimeout(async () => { setTimeout(async () => {
const patch2 = [ const patch2: ReplaceOperation<T.MainStatus & { main: 'running' }>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: path + '/status', path,
value: 'running', value: {
}, main: 'running',
{ started: new Date().toISOString(),
op: PatchOp.REPLACE, health: {
path: path + '/started', 'ephemeral-health-check': {
value: new Date().toISOString(), 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(patch2) 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 = [
{
op: PatchOp.REPLACE,
path: path + '/health',
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.',
},
},
},
]
this.mockRevision(patch4)
}, 2000) }, 2000)
const originalPatch = [ const originalPatch: ReplaceOperation<
T.MainStatus & { main: 'starting' }
>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: path + '/status', path,
value: 'starting', value: {
main: 'starting',
health: {},
},
}, },
] ]
@@ -907,74 +897,57 @@ export class MockApiService extends ApiService {
async restartPackage( async restartPackage(
params: RR.RestartPackageReq, params: RR.RestartPackageReq,
): Promise<RR.RestartPackageRes> { ): Promise<RR.RestartPackageRes> {
// first enact stop
await pauseFor(2000) await pauseFor(2000)
const path = `/packageData/${params.id}/status/main` const path = `/packageData/${params.id}/status`
setTimeout(async () => { setTimeout(async () => {
const patch2: Operation<any>[] = [ const patch2: ReplaceOperation<T.MainStatus & { main: 'running' }>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: path + '/status', path,
value: 'starting', value: {
}, main: 'running',
{ started: new Date().toISOString(),
op: PatchOp.ADD, health: {
path: path + '/restarting', 'ephemeral-health-check': {
value: true, 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(patch2) this.mockRevision(patch2)
await pauseFor(2000)
const patch3: Operation<any>[] = [
{
op: PatchOp.REPLACE,
path: path + '/status',
value: 'running',
},
{
op: PatchOp.REMOVE,
path: path + '/restarting',
},
{
op: PatchOp.REPLACE,
path: path + '/health',
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.',
},
},
} as any,
]
this.mockRevision(patch3)
}, this.revertTime) }, this.revertTime)
const patch = [ const patch: ReplaceOperation<T.MainStatus & { main: 'restarting' }>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: path + '/status', path,
value: 'restarting', value: {
}, main: 'restarting',
{ },
op: PatchOp.REPLACE,
path: path + '/health',
value: {},
}, },
] ]
@@ -985,29 +958,24 @@ export class MockApiService extends ApiService {
async stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes> { async stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
await pauseFor(2000) await pauseFor(2000)
const path = `/packageData/${params.id}/status/main` const path = `/packageData/${params.id}/status`
setTimeout(() => { setTimeout(() => {
const patch2 = [ const patch2: ReplaceOperation<T.MainStatus & { main: 'stopped' }>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: path, path: path,
value: { value: { main: 'stopped' },
status: 'stopped',
},
}, },
] ]
this.mockRevision(patch2) this.mockRevision(patch2)
}, this.revertTime) }, this.revertTime)
const patch = [ const patch: ReplaceOperation<T.MainStatus & { main: 'stopping' }>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: path, path: path,
value: { value: { main: 'stopping' },
status: 'stopping',
timeout: '35s',
},
}, },
] ]
@@ -1016,6 +984,12 @@ export class MockApiService extends ApiService {
return null return null
} }
async rebuildPackage(
params: RR.RebuildPackageReq,
): Promise<RR.RebuildPackageRes> {
return this.restartPackage(params)
}
async uninstallPackage( async uninstallPackage(
params: RR.UninstallPackageReq, params: RR.UninstallPackageReq,
): Promise<RR.UninstallPackageRes> { ): Promise<RR.UninstallPackageRes> {
@@ -1031,7 +1005,7 @@ export class MockApiService extends ApiService {
this.mockRevision(patch2) this.mockRevision(patch2)
}, this.revertTime) }, this.revertTime)
const patch = [ const patch: ReplaceOperation<T.PackageState['state']>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: `/packageData/${params.id}/stateInfo/state`, path: `/packageData/${params.id}/stateInfo/state`,

View File

@@ -96,36 +96,14 @@ export const mockPatchData: DataModel = {
icon: '/assets/img/service-icons/bitcoind.svg', icon: '/assets/img/service-icons/bitcoind.svg',
lastBackup: null, lastBackup: null,
status: { status: {
main: 'running', main: 'stopped',
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,
},
},
}, },
// status: {
// main: 'error',
// message: 'Bitcoin is erroring out',
// debug: 'This is a complete stack trace for bitcoin',
// onRebuild: 'start',
// },
actions: { actions: {
config: { config: {
name: 'Bitcoin Config', name: 'Bitcoin Config',

View File

@@ -80,6 +80,7 @@ export type PrimaryStatus =
| 'stopped' | 'stopped'
| 'backingUp' | 'backingUp'
| 'needsConfig' | 'needsConfig'
| 'error'
export type DependencyStatus = 'warning' | 'satisfied' export type DependencyStatus = 'warning' | 'satisfied'
@@ -139,6 +140,11 @@ export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
color: 'warning', color: 'warning',
showDots: false, showDots: false,
}, },
error: {
display: 'Service Launch Error',
color: 'danger',
showDots: false,
},
} }
export const DependencyRendering: Record<DependencyStatus, StatusRendering> = { export const DependencyRendering: Record<DependencyStatus, StatusRendering> = {

View File

@@ -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<DataModel>,
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<void> {
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<boolean>(['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()
}
}
}

View File

@@ -110,6 +110,12 @@ $subheader-height: 48px;
} }
} }
.code-block {
background-color: rgb(69, 69, 69);
padding: 12px;
margin-bottom: 32px;
}
.center { .center {
display: block; display: block;
margin: auto; margin: auto;

View File

@@ -22,7 +22,8 @@
"paths": { "paths": {
/* These paths are relative to each app base folder */ /* These paths are relative to each app base folder */
"@start9labs/marketplace": ["../marketplace/src/public-api"], "@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"], "typeRoots": ["node_modules/@types"],
"types": ["node"] "types": ["node"]