Merge pull request #2703 from Start9Labs/bugfix/misc

Bugfix/misc
This commit is contained in:
Aiden McClelland
2024-08-13 21:42:50 +00:00
committed by GitHub
56 changed files with 567 additions and 481 deletions

View File

@@ -45,7 +45,7 @@ on:
- next/*
env:
NODEJS_VERSION: "18.15.0"
NODEJS_VERSION: "20.16.0"
ENVIRONMENT: '${{ fromJson(format(''["{0}", ""]'', github.event.inputs.environment || ''dev''))[github.event.inputs.environment == ''NONE''] }}'
jobs:
@@ -75,6 +75,11 @@ jobs:
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODEJS_VERSION }}
@@ -148,6 +153,11 @@ jobs:
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
sudo apt-get update

View File

@@ -88,15 +88,19 @@ export class DockerProcedureContainer {
return new DockerProcedureContainer(overlay)
}
async exec(commands: string[]) {
async exec(commands: string[], { destroy = true } = {}) {
try {
return await this.overlay.exec(commands)
} finally {
await this.overlay.destroy()
if (destroy) await this.overlay.destroy()
}
}
async execFail(commands: string[], timeoutMs: number | null) {
async execFail(
commands: string[],
timeoutMs: number | null,
{ destroy = true } = {},
) {
try {
const res = await this.overlay.exec(commands, {}, timeoutMs)
if (res.exitCode !== 0) {
@@ -110,7 +114,7 @@ export class DockerProcedureContainer {
}
return res
} finally {
await this.overlay.destroy()
if (destroy) await this.overlay.destroy()
}
}

View File

@@ -14,6 +14,10 @@ const EMBASSY_PROPERTIES_LOOP = 30 * 1000
* Also, this has an ability to clean itself up too if need be.
*/
export class MainLoop {
private _mainDockerContainer?: DockerProcedureContainer
get mainDockerContainer() {
return this._mainDockerContainer
}
private healthLoops?: {
name: string
interval: NodeJS.Timeout
@@ -54,6 +58,7 @@ export class MainLoop {
this.system.manifest.main,
this.system.manifest.volumes,
)
this._mainDockerContainer = dockerProcedureContainer
if (jsMain) {
throw new Error("Unreachable")
}
@@ -126,6 +131,7 @@ export class MainLoop {
await main?.daemon.stop().catch((e) => console.error(e))
this.effects.setMainStatus({ status: "stopped" })
if (healthLoops) healthLoops.forEach((x) => clearInterval(x.interval))
delete this._mainDockerContainer
}
private constructHealthLoops() {
@@ -138,17 +144,25 @@ export class MainLoop {
const actionProcedure = value
const timeChanged = Date.now() - start
if (actionProcedure.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
manifest.id,
actionProcedure,
manifest.volumes,
// prettier-ignore
const container =
actionProcedure.inject && this._mainDockerContainer ?
this._mainDockerContainer :
await DockerProcedureContainer.of(
effects,
manifest.id,
actionProcedure,
manifest.volumes,
)
const shouldDestroy = container !== this._mainDockerContainer
const executed = await container.exec(
[
actionProcedure.entrypoint,
...actionProcedure.args,
JSON.stringify(timeChanged),
],
{ destroy: shouldDestroy },
)
const executed = await container.exec([
actionProcedure.entrypoint,
...actionProcedure.args,
JSON.stringify(timeChanged),
])
if (executed.exitCode === 0) {
await effects.setHealth({
id: healthId,

View File

@@ -445,7 +445,6 @@ export class SystemForEmbassy implements System {
id: `${id}-${internal}`,
description: interfaceValue.description,
hasPrimary: false,
disabled: false,
type:
interfaceValue.ui &&
(origin.scheme === "http" || origin.sslScheme === "https")
@@ -799,12 +798,17 @@ export class SystemForEmbassy implements System {
const actionProcedure = this.manifest.actions?.[actionId]?.implementation
if (!actionProcedure) return { message: "Action not found", value: null }
if (actionProcedure.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
this.manifest.id,
actionProcedure,
this.manifest.volumes,
)
const container =
actionProcedure.inject && this.currentRunning?.mainDockerContainer
? this.currentRunning?.mainDockerContainer
: await DockerProcedureContainer.of(
effects,
this.manifest.id,
actionProcedure,
this.manifest.volumes,
)
const shouldDestroy =
container !== this.currentRunning?.mainDockerContainer
return JSON.parse(
(
await container.execFail(
@@ -814,6 +818,7 @@ export class SystemForEmbassy implements System {
JSON.stringify(formData),
],
timeoutMs,
{ destroy: shouldDestroy },
)
).stdout.toString(),
)

2
core/Cargo.lock generated
View File

@@ -4961,7 +4961,7 @@ dependencies = [
[[package]]
name = "start-os"
version = "0.3.6-alpha.3"
version = "0.3.6-alpha.4"
dependencies = [
"aes",
"async-compression",

View File

@@ -14,7 +14,7 @@ keywords = [
name = "start-os"
readme = "README.md"
repository = "https://github.com/Start9Labs/start-os"
version = "0.3.6-alpha.3"
version = "0.3.6-alpha.4"
license = "MIT"
[lib]

View File

@@ -67,7 +67,6 @@ pub struct ServiceInterface {
pub name: String,
pub description: String,
pub has_primary: bool,
pub disabled: bool,
pub masked: bool,
pub address_info: AddressInfo,
#[serde(rename = "type")]

View File

@@ -4,8 +4,10 @@ use std::str::FromStr;
use clap::builder::ValueParserFactory;
use exver::VersionRange;
use imbl::OrdMap;
use imbl_value::InternedString;
use itertools::Itertools;
use models::{HealthCheckId, PackageId, VolumeId};
use models::{HealthCheckId, PackageId, VersionString, VolumeId};
use patch_db::json_ptr::JsonPointer;
use tokio::process::Command;
@@ -17,7 +19,7 @@ use crate::disk::mount::filesystem::idmapped::IdMapped;
use crate::disk::mount::filesystem::{FileSystem, MountType};
use crate::rpc_continuations::Guid;
use crate::service::effects::prelude::*;
use crate::status::health_check::HealthCheckResult;
use crate::status::health_check::NamedHealthCheckResult;
use crate::util::clap::FromStrParser;
use crate::util::Invoke;
use crate::volume::data_dir;
@@ -316,12 +318,16 @@ pub struct CheckDependenciesParam {
#[ts(export)]
pub struct CheckDependenciesResult {
package_id: PackageId,
is_installed: bool,
#[ts(type = "string | null")]
title: Option<InternedString>,
#[ts(type = "string | null")]
installed_version: Option<exver::ExtendedVersion>,
#[ts(type = "string[]")]
satisfies: BTreeSet<VersionString>,
is_running: bool,
config_satisfied: bool,
health_checks: BTreeMap<HealthCheckId, HealthCheckResult>,
#[ts(type = "string | null")]
version: Option<exver::ExtendedVersion>,
#[ts(as = "BTreeMap::<HealthCheckId, NamedHealthCheckResult>")]
health_checks: OrdMap<HealthCheckId, NamedHealthCheckResult>,
}
pub async fn check_dependencies(
context: EffectContext,
@@ -347,36 +353,23 @@ pub async fn check_dependencies(
let mut results = Vec::with_capacity(package_ids.len());
for (package_id, dependency_info) in package_ids {
let title = dependency_info.title.clone();
let Some(package) = db.as_public().as_package_data().as_idx(&package_id) else {
results.push(CheckDependenciesResult {
package_id,
is_installed: false,
title,
installed_version: None,
satisfies: BTreeSet::new(),
is_running: false,
config_satisfied: false,
health_checks: Default::default(),
version: None,
});
continue;
};
let manifest = package.as_state_info().as_manifest(ManifestPreference::New);
let installed_version = manifest.as_version().de()?.into_version();
let satisfies = manifest.as_satisfies().de()?;
let version = Some(installed_version.clone());
if ![installed_version]
.into_iter()
.chain(satisfies.into_iter().map(|v| v.into_version()))
.any(|v| v.satisfies(&dependency_info.version_range))
{
results.push(CheckDependenciesResult {
package_id,
is_installed: false,
is_running: false,
config_satisfied: false,
health_checks: Default::default(),
version,
});
continue;
}
let installed_version = Some(installed_version.clone());
let is_installed = true;
let status = package.as_status().as_main().de()?;
let is_running = if is_installed {
@@ -384,25 +377,15 @@ pub async fn check_dependencies(
} else {
false
};
let health_checks =
if let CurrentDependencyKind::Running { health_checks } = &dependency_info.kind {
status
.health()
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|(id, _)| health_checks.contains(id))
.collect()
} else {
Default::default()
};
let health_checks = status.health().cloned().unwrap_or_default();
results.push(CheckDependenciesResult {
package_id,
is_installed,
title,
installed_version,
satisfies,
is_running,
config_satisfied: dependency_info.config_satisfied,
health_checks,
version,
});
}
Ok(results)

View File

@@ -1,7 +1,7 @@
use models::HealthCheckId;
use crate::service::effects::prelude::*;
use crate::status::health_check::HealthCheckResult;
use crate::status::health_check::NamedHealthCheckResult;
use crate::status::MainStatus;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
@@ -10,7 +10,7 @@ use crate::status::MainStatus;
pub struct SetHealth {
id: HealthCheckId,
#[serde(flatten)]
result: HealthCheckResult,
result: NamedHealthCheckResult,
}
pub async fn set_health(
context: EffectContext,

View File

@@ -16,7 +16,6 @@ pub struct ExportServiceInterfaceParams {
name: String,
description: String,
has_primary: bool,
disabled: bool,
masked: bool,
address_info: AddressInfo,
r#type: ServiceInterfaceType,
@@ -28,7 +27,6 @@ pub async fn export_service_interface(
name,
description,
has_primary,
disabled,
masked,
address_info,
r#type,
@@ -42,7 +40,6 @@ pub async fn export_service_interface(
name,
description,
has_primary,
disabled,
masked,
address_info,
interface_type: r#type,

View File

@@ -27,7 +27,7 @@ use crate::progress::{NamedProgress, Progress};
use crate::rpc_continuations::Guid;
use crate::s9pk::S9pk;
use crate::service::service_map::InstallProgressHandles;
use crate::status::health_check::HealthCheckResult;
use crate::status::health_check::NamedHealthCheckResult;
use crate::util::actor::concurrent::ConcurrentActor;
use crate::util::io::create_file;
use crate::util::serde::{NoOutput, Pem};
@@ -493,7 +493,7 @@ impl Service {
#[derive(Debug, Clone)]
pub struct RunningStatus {
health: OrdMap<HealthCheckId, HealthCheckResult>,
health: OrdMap<HealthCheckId, NamedHealthCheckResult>,
started: DateTime<Utc>,
}

View File

@@ -9,25 +9,25 @@ use crate::util::clap::FromStrParser;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, TS)]
#[serde(rename_all = "camelCase")]
pub struct HealthCheckResult {
pub struct NamedHealthCheckResult {
pub name: String,
#[serde(flatten)]
pub kind: HealthCheckResultKind,
pub kind: NamedHealthCheckResultKind,
}
// healthCheckName:kind:message OR healthCheckName:kind
impl FromStr for HealthCheckResult {
impl FromStr for NamedHealthCheckResult {
type Err = color_eyre::eyre::Report;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let from_parts = |name: &str, kind: &str, message: Option<&str>| {
let message = message.map(|x| x.to_string());
let kind = match kind {
"success" => HealthCheckResultKind::Success { message },
"disabled" => HealthCheckResultKind::Disabled { message },
"starting" => HealthCheckResultKind::Starting { message },
"loading" => HealthCheckResultKind::Loading {
"success" => NamedHealthCheckResultKind::Success { message },
"disabled" => NamedHealthCheckResultKind::Disabled { message },
"starting" => NamedHealthCheckResultKind::Starting { message },
"loading" => NamedHealthCheckResultKind::Loading {
message: message.unwrap_or_default(),
},
"failure" => HealthCheckResultKind::Failure {
"failure" => NamedHealthCheckResultKind::Failure {
message: message.unwrap_or_default(),
},
_ => return Err(color_eyre::eyre::eyre!("Invalid health check kind")),
@@ -47,7 +47,7 @@ impl FromStr for HealthCheckResult {
}
}
}
impl ValueParserFactory for HealthCheckResult {
impl ValueParserFactory for NamedHealthCheckResult {
type Parser = FromStrParser<Self>;
fn value_parser() -> Self::Parser {
FromStrParser::new()
@@ -57,40 +57,44 @@ impl ValueParserFactory for HealthCheckResult {
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, TS)]
#[serde(rename_all = "camelCase")]
#[serde(tag = "result")]
pub enum HealthCheckResultKind {
pub enum NamedHealthCheckResultKind {
Success { message: Option<String> },
Disabled { message: Option<String> },
Starting { message: Option<String> },
Loading { message: String },
Failure { message: String },
}
impl std::fmt::Display for HealthCheckResult {
impl std::fmt::Display for NamedHealthCheckResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = &self.name;
match &self.kind {
HealthCheckResultKind::Success { message } => {
NamedHealthCheckResultKind::Success { message } => {
if let Some(message) = message {
write!(f, "{name}: Succeeded ({message})")
} else {
write!(f, "{name}: Succeeded")
}
}
HealthCheckResultKind::Disabled { message } => {
NamedHealthCheckResultKind::Disabled { message } => {
if let Some(message) = message {
write!(f, "{name}: Disabled ({message})")
} else {
write!(f, "{name}: Disabled")
}
}
HealthCheckResultKind::Starting { message } => {
NamedHealthCheckResultKind::Starting { message } => {
if let Some(message) = message {
write!(f, "{name}: Starting ({message})")
} else {
write!(f, "{name}: Starting")
}
}
HealthCheckResultKind::Loading { message } => write!(f, "{name}: Loading ({message})"),
HealthCheckResultKind::Failure { message } => write!(f, "{name}: Failed ({message})"),
NamedHealthCheckResultKind::Loading { message } => {
write!(f, "{name}: Loading ({message})")
}
NamedHealthCheckResultKind::Failure { message } => {
write!(f, "{name}: Failed ({message})")
}
}
}
}

View File

@@ -1,4 +1,5 @@
use std::{collections::BTreeMap, sync::Arc};
use std::collections::BTreeMap;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use imbl::OrdMap;
@@ -6,8 +7,9 @@ use serde::{Deserialize, Serialize};
use ts_rs::TS;
use self::health_check::HealthCheckId;
use crate::status::health_check::HealthCheckResult;
use crate::{prelude::*, util::GeneralGuard};
use crate::prelude::*;
use crate::status::health_check::NamedHealthCheckResult;
use crate::util::GeneralGuard;
pub mod health_check;
#[derive(Clone, Debug, Deserialize, Serialize, HasModel, TS)]
@@ -32,15 +34,15 @@ pub enum MainStatus {
Running {
#[ts(type = "string")]
started: DateTime<Utc>,
#[ts(as = "BTreeMap<HealthCheckId, HealthCheckResult>")]
health: OrdMap<HealthCheckId, HealthCheckResult>,
#[ts(as = "BTreeMap<HealthCheckId, NamedHealthCheckResult>")]
health: OrdMap<HealthCheckId, NamedHealthCheckResult>,
},
#[serde(rename_all = "camelCase")]
BackingUp {
#[ts(type = "string | null")]
started: Option<DateTime<Utc>>,
#[ts(as = "BTreeMap<HealthCheckId, HealthCheckResult>")]
health: OrdMap<HealthCheckId, HealthCheckResult>,
#[ts(as = "BTreeMap<HealthCheckId, NamedHealthCheckResult>")]
health: OrdMap<HealthCheckId, NamedHealthCheckResult>,
},
}
impl MainStatus {
@@ -93,7 +95,7 @@ impl MainStatus {
MainStatus::BackingUp { started, health }
}
pub fn health(&self) -> Option<&OrdMap<HealthCheckId, HealthCheckResult>> {
pub fn health(&self) -> Option<&OrdMap<HealthCheckId, NamedHealthCheckResult>> {
match self {
MainStatus::Running { health, .. } => Some(health),
MainStatus::BackingUp { health, .. } => Some(health),

View File

@@ -22,7 +22,7 @@ mod v0_3_6_alpha_5;
mod v0_3_6_alpha_6;
mod v0_3_6_alpha_7;
pub type Current = v0_3_6_alpha_3::Version; // VERSION_BUMP
pub type Current = v0_3_6_alpha_4::Version; // VERSION_BUMP
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(untagged)]

View File

@@ -75,7 +75,10 @@ import * as T from "./types"
import { testTypeVersion, ValidateExVer } from "./exver"
import { ExposedStorePaths } from "./store/setupExposeStore"
import { PathBuilder, extractJsonPath, pathBuilder } from "./store/PathBuilder"
import { checkAllDependencies } from "./dependencies/dependencies"
import {
CheckDependencies,
checkDependencies,
} from "./dependencies/dependencies"
import { health } from "."
import { GetSslCertificate } from "./util/GetSslCertificate"
@@ -142,7 +145,13 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
}
return {
checkAllDependencies,
checkDependencies: checkDependencies as <
DependencyId extends keyof Manifest["dependencies"] &
PackageId = keyof Manifest["dependencies"] & PackageId,
>(
effects: Effects,
packageIds?: DependencyId[],
) => Promise<CheckDependencies<DependencyId>>,
serviceInterface: {
getOwn: <E extends Effects>(effects: E, id: ServiceInterfaceId) =>
removeCallbackTypes<E>(effects)(
@@ -247,7 +256,6 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
id: string
description: string
hasPrimary: boolean
disabled: boolean
type: ServiceInterfaceType
username: null | string
path: string
@@ -293,8 +301,8 @@ export class StartSdk<Manifest extends T.Manifest, Store> {
)
},
HealthCheck: {
of(o: HealthCheckParams<Manifest>) {
return healthCheck<Manifest>(o)
of(o: HealthCheckParams) {
return healthCheck(o)
},
},
Dependency: {

View File

@@ -1,131 +1,206 @@
import { ExtendedVersion, VersionRange } from "../exver"
import {
Effects,
PackageId,
DependencyRequirement,
SetHealth,
CheckDependenciesResult,
HealthCheckId,
} from "../types"
export type CheckAllDependencies = {
notInstalled: () => Promise<CheckDependenciesResult[]>
notRunning: () => Promise<CheckDependenciesResult[]>
configNotSatisfied: () => Promise<CheckDependenciesResult[]>
healthErrors: () => Promise<{ [id: string]: SetHealth[] }>
export type CheckDependencies<DependencyId extends PackageId = PackageId> = {
installedSatisfied: (packageId: DependencyId) => boolean
installedVersionSatisfied: (packageId: DependencyId) => boolean
runningSatisfied: (packageId: DependencyId) => boolean
configSatisfied: (packageId: DependencyId) => boolean
healthCheckSatisfied: (
packageId: DependencyId,
healthCheckId: HealthCheckId,
) => boolean
satisfied: () => boolean
isValid: () => Promise<boolean>
throwIfNotRunning: () => Promise<void>
throwIfNotInstalled: () => Promise<void>
throwIfConfigNotSatisfied: () => Promise<void>
throwIfHealthError: () => Promise<void>
throwIfNotValid: () => Promise<void>
throwIfInstalledNotSatisfied: (packageId: DependencyId) => void
throwIfInstalledVersionNotSatisfied: (packageId: DependencyId) => void
throwIfRunningNotSatisfied: (packageId: DependencyId) => void
throwIfConfigNotSatisfied: (packageId: DependencyId) => void
throwIfHealthNotSatisfied: (
packageId: DependencyId,
healthCheckId?: HealthCheckId,
) => void
throwIfNotSatisfied: (packageId?: DependencyId) => void
}
export function checkAllDependencies(effects: Effects): CheckAllDependencies {
const dependenciesPromise = effects.getDependencies()
const resultsPromise = dependenciesPromise.then((dependencies) =>
export async function checkDependencies<
DependencyId extends PackageId = PackageId,
>(
effects: Effects,
packageIds?: DependencyId[],
): Promise<CheckDependencies<DependencyId>> {
let [dependencies, results] = await Promise.all([
effects.getDependencies(),
effects.checkDependencies({
packageIds: dependencies.map((dep) => dep.id),
packageIds,
}),
)
const dependenciesByIdPromise = dependenciesPromise.then((d) =>
d.reduce(
(acc, dep) => {
acc[dep.id] = dep
return acc
},
{} as { [id: PackageId]: DependencyRequirement },
),
)
const healthErrors = async () => {
const results = await resultsPromise
const dependenciesById = await dependenciesByIdPromise
const answer: { [id: PackageId]: SetHealth[] } = {}
for (const result of results) {
const dependency = dependenciesById[result.packageId]
if (!dependency) continue
if (dependency.kind !== "running") continue
const healthChecks = Object.entries(result.healthChecks)
.map(([id, hc]) => ({ ...hc, id }))
.filter((x) => !!x.message)
if (healthChecks.length === 0) continue
answer[result.packageId] = healthChecks
}
return answer
}
const configNotSatisfied = () =>
resultsPromise.then((x) => x.filter((x) => !x.configSatisfied))
const notInstalled = () =>
resultsPromise.then((x) => x.filter((x) => !x.isInstalled))
const notRunning = async () => {
const results = await resultsPromise
const dependenciesById = await dependenciesByIdPromise
return results.filter((x) => {
const dependency = dependenciesById[x.packageId]
if (!dependency) return false
if (dependency.kind !== "running") return false
return !x.isRunning
})
}
const entries = <B>(x: { [k: string]: B }) => Object.entries(x)
const first = <A>(x: A[]): A | undefined => x[0]
const sinkVoid = <A>(x: A) => void 0
const throwIfHealthError = () =>
healthErrors()
.then(entries)
.then(first)
.then((x) => {
if (!x) return
const [id, healthChecks] = x
if (healthChecks.length > 0)
throw `Package ${id} has the following errors: ${healthChecks.map((x) => x.message).join(", ")}`
})
const throwIfConfigNotSatisfied = () =>
configNotSatisfied().then((results) => {
throw new Error(
`Package ${results[0].packageId} does not have a valid configuration`,
)
})
const throwIfNotRunning = () =>
notRunning().then((results) => {
if (results[0])
throw new Error(`Package ${results[0].packageId} is not running`)
})
const throwIfNotInstalled = () =>
notInstalled().then((results) => {
if (results[0])
throw new Error(`Package ${results[0].packageId} is not installed`)
})
const throwIfNotValid = async () =>
Promise.all([
throwIfNotRunning(),
throwIfNotInstalled(),
throwIfConfigNotSatisfied(),
throwIfHealthError(),
]).then(sinkVoid)
const isValid = () =>
throwIfNotValid().then(
() => true,
() => false,
])
if (packageIds) {
dependencies = dependencies.filter((d) =>
(packageIds as PackageId[]).includes(d.id),
)
}
const find = (packageId: DependencyId) => {
const dependencyRequirement = dependencies.find((d) => d.id === packageId)
const dependencyResult = results.find((d) => d.packageId === packageId)
if (!dependencyRequirement || !dependencyResult) {
throw new Error(`Unknown DependencyId ${packageId}`)
}
return { requirement: dependencyRequirement, result: dependencyResult }
}
const installedSatisfied = (packageId: DependencyId) =>
!!find(packageId).result.installedVersion
const installedVersionSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
return (
!!dep.result.installedVersion &&
ExtendedVersion.parse(dep.result.installedVersion).satisfies(
VersionRange.parse(dep.requirement.versionRange),
)
)
}
const runningSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
return dep.requirement.kind !== "running" || dep.result.isRunning
}
const configSatisfied = (packageId: DependencyId) =>
find(packageId).result.configSatisfied
const healthCheckSatisfied = (
packageId: DependencyId,
healthCheckId?: HealthCheckId,
) => {
const dep = find(packageId)
if (
healthCheckId &&
(dep.requirement.kind !== "running" ||
!dep.requirement.healthChecks.includes(healthCheckId))
) {
throw new Error(`Unknown HealthCheckId ${healthCheckId}`)
}
const errors = Object.entries(dep.result.healthChecks)
.filter(([id, _]) => (healthCheckId ? id === healthCheckId : true))
.filter(([_, res]) => res.result !== "success")
return errors.length === 0
}
const pkgSatisfied = (packageId: DependencyId) =>
installedSatisfied(packageId) &&
installedVersionSatisfied(packageId) &&
runningSatisfied(packageId) &&
configSatisfied(packageId) &&
healthCheckSatisfied(packageId)
const satisfied = (packageId?: DependencyId) =>
packageId
? pkgSatisfied(packageId)
: dependencies.every((d) => pkgSatisfied(d.id as DependencyId))
const throwIfInstalledNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
if (!dep.result.installedVersion) {
throw new Error(`${dep.result.title || packageId} is not installed`)
}
}
const throwIfInstalledVersionNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
if (!dep.result.installedVersion) {
throw new Error(`${dep.result.title || packageId} is not installed`)
}
if (
![dep.result.installedVersion, ...dep.result.satisfies].find((v) =>
ExtendedVersion.parse(v).satisfies(
VersionRange.parse(dep.requirement.versionRange),
),
)
) {
throw new Error(
`Installed version ${dep.result.installedVersion} of ${dep.result.title || packageId} does not match expected version range ${dep.requirement.versionRange}`,
)
}
}
const throwIfRunningNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
if (dep.requirement.kind === "running" && !dep.result.isRunning) {
throw new Error(`${dep.result.title || packageId} is not running`)
}
}
const throwIfConfigNotSatisfied = (packageId: DependencyId) => {
const dep = find(packageId)
if (!dep.result.configSatisfied) {
throw new Error(
`${dep.result.title || packageId}'s configuration does not satisfy requirements`,
)
}
}
const throwIfHealthNotSatisfied = (
packageId: DependencyId,
healthCheckId?: HealthCheckId,
) => {
const dep = find(packageId)
if (
healthCheckId &&
(dep.requirement.kind !== "running" ||
!dep.requirement.healthChecks.includes(healthCheckId))
) {
throw new Error(`Unknown HealthCheckId ${healthCheckId}`)
}
const errors = Object.entries(dep.result.healthChecks)
.filter(([id, _]) => (healthCheckId ? id === healthCheckId : true))
.filter(([_, res]) => res.result !== "success")
if (errors.length) {
throw new Error(
errors
.map(
([_, e]) =>
`Health Check ${e.name} of ${dep.result.title || packageId} failed with status ${e.result}${e.message ? `: ${e.message}` : ""}`,
)
.join("; "),
)
}
}
const throwIfPkgNotSatisfied = (packageId: DependencyId) => {
throwIfInstalledNotSatisfied(packageId)
throwIfInstalledVersionNotSatisfied(packageId)
throwIfRunningNotSatisfied(packageId)
throwIfConfigNotSatisfied(packageId)
throwIfHealthNotSatisfied(packageId)
}
const throwIfNotSatisfied = (packageId?: DependencyId) =>
packageId
? throwIfPkgNotSatisfied(packageId)
: (() => {
const err = dependencies.flatMap((d) => {
try {
throwIfPkgNotSatisfied(d.id as DependencyId)
} catch (e) {
if (e instanceof Error) return [e.message]
throw e
}
return []
})
if (err.length) {
throw new Error(err.join("; "))
}
})()
return {
notRunning,
notInstalled,
configNotSatisfied,
healthErrors,
throwIfNotRunning,
installedSatisfied,
installedVersionSatisfied,
runningSatisfied,
configSatisfied,
healthCheckSatisfied,
satisfied,
throwIfInstalledNotSatisfied,
throwIfInstalledVersionNotSatisfied,
throwIfRunningNotSatisfied,
throwIfConfigNotSatisfied,
throwIfNotValid,
throwIfNotInstalled,
throwIfHealthError,
isValid,
throwIfHealthNotSatisfied,
throwIfNotSatisfied,
}
}

View File

@@ -44,7 +44,7 @@ type Not = {
}
export class VersionRange {
private constructor(private atom: Anchor | And | Or | Not | P.Any | P.None) {}
private constructor(public atom: Anchor | And | Or | Not | P.Any | P.None) {}
toString(): string {
switch (this.atom.type) {
@@ -63,67 +63,6 @@ export class VersionRange {
}
}
/**
* Returns a boolean indicating whether a given version satisfies the VersionRange
* !( >= 1:1 <= 2:2) || <=#bitcoin:1.2.0-alpha:0
*/
satisfiedBy(version: ExtendedVersion): boolean {
switch (this.atom.type) {
case "Anchor":
const otherVersion = this.atom.version
switch (this.atom.operator) {
case "=":
return version.equals(otherVersion)
case ">":
return version.greaterThan(otherVersion)
case "<":
return version.lessThan(otherVersion)
case ">=":
return version.greaterThanOrEqual(otherVersion)
case "<=":
return version.lessThanOrEqual(otherVersion)
case "!=":
return !version.equals(otherVersion)
case "^":
const nextMajor = this.atom.version.incrementMajor()
if (
version.greaterThanOrEqual(otherVersion) &&
version.lessThan(nextMajor)
) {
return true
} else {
return false
}
case "~":
const nextMinor = this.atom.version.incrementMinor()
if (
version.greaterThanOrEqual(otherVersion) &&
version.lessThan(nextMinor)
) {
return true
} else {
return false
}
}
case "And":
return (
this.atom.left.satisfiedBy(version) &&
this.atom.right.satisfiedBy(version)
)
case "Or":
return (
this.atom.left.satisfiedBy(version) ||
this.atom.right.satisfiedBy(version)
)
case "Not":
return !this.atom.value.satisfiedBy(version)
case "Any":
return true
case "None":
return false
}
}
private static parseAtom(atom: P.VersionRangeAtom): VersionRange {
switch (atom.type) {
case "Not":
@@ -207,6 +146,10 @@ export class VersionRange {
static none() {
return new VersionRange({ type: "None" })
}
satisfiedBy(version: Version | ExtendedVersion) {
return version.satisfies(this)
}
}
export class Version {
@@ -266,6 +209,12 @@ export class Version {
const parsed = P.parse(version, { startRule: "Version" })
return new Version(parsed.number, parsed.prerelease)
}
satisfies(versionRange: VersionRange): boolean {
return new ExtendedVersion(null, this, new Version([0], [])).satisfies(
versionRange,
)
}
}
// #flavor:0.1.2-beta.1:0
@@ -404,6 +353,67 @@ export class ExtendedVersion {
updatedDownstream,
)
}
/**
* Returns a boolean indicating whether a given version satisfies the VersionRange
* !( >= 1:1 <= 2:2) || <=#bitcoin:1.2.0-alpha:0
*/
satisfies(versionRange: VersionRange): boolean {
switch (versionRange.atom.type) {
case "Anchor":
const otherVersion = versionRange.atom.version
switch (versionRange.atom.operator) {
case "=":
return this.equals(otherVersion)
case ">":
return this.greaterThan(otherVersion)
case "<":
return this.lessThan(otherVersion)
case ">=":
return this.greaterThanOrEqual(otherVersion)
case "<=":
return this.lessThanOrEqual(otherVersion)
case "!=":
return !this.equals(otherVersion)
case "^":
const nextMajor = versionRange.atom.version.incrementMajor()
if (
this.greaterThanOrEqual(otherVersion) &&
this.lessThan(nextMajor)
) {
return true
} else {
return false
}
case "~":
const nextMinor = versionRange.atom.version.incrementMinor()
if (
this.greaterThanOrEqual(otherVersion) &&
this.lessThan(nextMinor)
) {
return true
} else {
return false
}
}
case "And":
return (
this.satisfies(versionRange.atom.left) &&
this.satisfies(versionRange.atom.right)
)
case "Or":
return (
this.satisfies(versionRange.atom.left) ||
this.satisfies(versionRange.atom.right)
)
case "Not":
return !this.satisfies(versionRange.atom.value)
case "Any":
return true
case "None":
return false
}
}
}
export const testTypeExVer = <T extends string>(t: T & ValidateExVer<T>) => t

View File

@@ -1,5 +1,5 @@
import { Effects } from "../types"
import { CheckResult } from "./checkFns/CheckResult"
import { HealthCheckResult } from "./checkFns/HealthCheckResult"
import { HealthReceipt } from "./HealthReceipt"
import { Trigger } from "../trigger"
import { TriggerInput } from "../trigger/TriggerInput"
@@ -9,66 +9,52 @@ import { Overlay } from "../util/Overlay"
import { object, unknown } from "ts-matches"
import * as T from "../types"
export type HealthCheckParams<Manifest extends T.Manifest> = {
export type HealthCheckParams = {
effects: Effects
name: string
image: {
id: keyof Manifest["images"] & T.ImageId
sharedRun?: boolean
}
trigger?: Trigger
fn(overlay: Overlay): Promise<CheckResult> | CheckResult
fn(): Promise<HealthCheckResult> | HealthCheckResult
onFirstSuccess?: () => unknown | Promise<unknown>
}
export function healthCheck<Manifest extends T.Manifest>(
o: HealthCheckParams<Manifest>,
) {
export function healthCheck(o: HealthCheckParams) {
new Promise(async () => {
const overlay = await Overlay.of(o.effects, o.image)
try {
let currentValue: TriggerInput = {
hadSuccess: false,
let currentValue: TriggerInput = {}
const getCurrentValue = () => currentValue
const trigger = (o.trigger ?? defaultTrigger)(getCurrentValue)
const triggerFirstSuccess = once(() =>
Promise.resolve(
"onFirstSuccess" in o && o.onFirstSuccess
? o.onFirstSuccess()
: undefined,
),
)
for (
let res = await trigger.next();
!res.done;
res = await trigger.next()
) {
try {
const { result, message } = await o.fn()
await o.effects.setHealth({
name: o.name,
id: o.name,
result,
message: message || "",
})
currentValue.lastResult = result
await triggerFirstSuccess().catch((err) => {
console.error(err)
})
} catch (e) {
await o.effects.setHealth({
name: o.name,
id: o.name,
result: "failure",
message: asMessage(e) || "",
})
currentValue.lastResult = "failure"
}
const getCurrentValue = () => currentValue
const trigger = (o.trigger ?? defaultTrigger)(getCurrentValue)
const triggerFirstSuccess = once(() =>
Promise.resolve(
"onFirstSuccess" in o && o.onFirstSuccess
? o.onFirstSuccess()
: undefined,
),
)
for (
let res = await trigger.next();
!res.done;
res = await trigger.next()
) {
try {
const { status, message } = await o.fn(overlay)
await o.effects.setHealth({
name: o.name,
id: o.name,
result: status,
message: message || "",
})
currentValue.hadSuccess = true
currentValue.lastResult = "success"
await triggerFirstSuccess().catch((err) => {
console.error(err)
})
} catch (e) {
await o.effects.setHealth({
name: o.name,
id: o.name,
result: "failure",
message: asMessage(e) || "",
})
currentValue.lastResult = "failure"
}
}
} finally {
await overlay.destroy()
}
})
return {} as HealthReceipt

View File

@@ -1,6 +0,0 @@
import { HealthStatus } from "../../types"
export type CheckResult = {
status: HealthStatus
message: string | null
}

View File

@@ -0,0 +1,3 @@
import { T } from "../.."
export type HealthCheckResult = Omit<T.NamedHealthCheckResult, "name">

View File

@@ -1,6 +1,6 @@
import { Effects } from "../../types"
import { stringFromStdErrOut } from "../../util/stringFromStdErrOut"
import { CheckResult } from "./CheckResult"
import { HealthCheckResult } from "./HealthCheckResult"
import { promisify } from "node:util"
import * as CP from "node:child_process"
@@ -32,8 +32,8 @@ export async function checkPortListening(
timeoutMessage?: string
timeout?: number
},
): Promise<CheckResult> {
return Promise.race<CheckResult>([
): Promise<HealthCheckResult> {
return Promise.race<HealthCheckResult>([
Promise.resolve().then(async () => {
const hasAddress =
containsAddress(
@@ -45,10 +45,10 @@ export async function checkPortListening(
port,
)
if (hasAddress) {
return { status: "success", message: options.successMessage }
return { result: "success", message: options.successMessage }
}
return {
status: "failure",
result: "failure",
message: options.errorMessage,
}
}),
@@ -56,7 +56,7 @@ export async function checkPortListening(
setTimeout(
() =>
resolve({
status: "failure",
result: "failure",
message:
options.timeoutMessage || `Timeout trying to check port ${port}`,
}),

View File

@@ -1,5 +1,5 @@
import { Effects } from "../../types"
import { CheckResult } from "./CheckResult"
import { HealthCheckResult } from "./HealthCheckResult"
import { timeoutPromise } from "./index"
import "isomorphic-fetch"
@@ -17,12 +17,12 @@ export const checkWebUrl = async (
successMessage = `Reached ${url}`,
errorMessage = `Error while fetching URL: ${url}`,
} = {},
): Promise<CheckResult> => {
): Promise<HealthCheckResult> => {
return Promise.race([fetch(url), timeoutPromise(timeout)])
.then(
(x) =>
({
status: "success",
result: "success",
message: successMessage,
}) as const,
)
@@ -30,6 +30,6 @@ export const checkWebUrl = async (
console.warn(`Error while fetching URL: ${url}`)
console.error(JSON.stringify(e))
console.error(e.toString())
return { status: "failure" as const, message: errorMessage }
return { result: "failure" as const, message: errorMessage }
})
}

View File

@@ -1,6 +1,6 @@
import { runHealthScript } from "./runHealthScript"
export { checkPortListening } from "./checkPortListening"
export { CheckResult } from "./CheckResult"
export { HealthCheckResult } from "./HealthCheckResult"
export { checkWebUrl } from "./checkWebUrl"
export function timeoutPromise(ms: number, { message = "Timed out" } = {}) {

View File

@@ -1,7 +1,7 @@
import { Effects } from "../../types"
import { Overlay } from "../../util/Overlay"
import { stringFromStdErrOut } from "../../util/stringFromStdErrOut"
import { CheckResult } from "./CheckResult"
import { HealthCheckResult } from "./HealthCheckResult"
import { timeoutPromise } from "./index"
/**
@@ -12,7 +12,6 @@ import { timeoutPromise } from "./index"
* @returns
*/
export const runHealthScript = async (
effects: Effects,
runCommand: string[],
overlay: Overlay,
{
@@ -21,7 +20,7 @@ export const runHealthScript = async (
message = (res: string) =>
`Have ran script ${runCommand} and the result: ${res}`,
} = {},
): Promise<CheckResult> => {
): Promise<HealthCheckResult> => {
const res = await Promise.race([
overlay.exec(runCommand),
timeoutPromise(timeout),
@@ -29,10 +28,10 @@ export const runHealthScript = async (
console.warn(errorMessage)
console.warn(JSON.stringify(e))
console.warn(e.toString())
throw { status: "failure", message: errorMessage } as CheckResult
throw { result: "failure", message: errorMessage } as HealthCheckResult
})
return {
status: "success",
result: "success",
message: message(res.stdout.toString()),
} as CheckResult
} as HealthCheckResult
}

View File

@@ -47,7 +47,6 @@ export class Origin<T extends Host> {
name,
description,
hasPrimary,
disabled,
id,
type,
username,
@@ -69,7 +68,6 @@ export class Origin<T extends Host> {
name,
description,
hasPrimary,
disabled,
addressInfo,
type,
masked,

View File

@@ -21,7 +21,6 @@ export class ServiceInterfaceBuilder {
id: string
description: string
hasPrimary: boolean
disabled: boolean
type: ServiceInterfaceType
username: string | null
path: string

View File

@@ -1,6 +1,6 @@
import { NO_TIMEOUT, SIGKILL, SIGTERM, Signals } from "../StartSdk"
import { HealthReceipt } from "../health/HealthReceipt"
import { CheckResult } from "../health/checkFns"
import { HealthCheckResult } from "../health/checkFns"
import { Trigger } from "../trigger"
import { TriggerInput } from "../trigger/TriggerInput"
@@ -23,7 +23,7 @@ export const cpExec = promisify(CP.exec)
export const cpExecFile = promisify(CP.execFile)
export type Ready = {
display: string | null
fn: () => Promise<CheckResult> | CheckResult
fn: () => Promise<HealthCheckResult> | HealthCheckResult
trigger?: Trigger
}

View File

@@ -1,8 +1,8 @@
import { CheckResult } from "../health/checkFns"
import { HealthCheckResult } from "../health/checkFns"
import { defaultTrigger } from "../trigger/defaultTrigger"
import { Ready } from "./Daemons"
import { Daemon } from "./Daemon"
import { Effects } from "../types"
import { Effects, SetHealth } from "../types"
import { DEFAULT_SIGTERM_TIMEOUT } from "."
const oncePromise = <T>() => {
@@ -21,10 +21,9 @@ const oncePromise = <T>() => {
*
*/
export class HealthDaemon {
#health: CheckResult = { status: "starting", message: null }
#health: HealthCheckResult = { result: "starting", message: null }
#healthWatchers: Array<() => unknown> = []
#running = false
#hadSuccess = false
constructor(
readonly daemon: Promise<Daemon>,
readonly daemonIndex: number,
@@ -77,7 +76,7 @@ export class HealthDaemon {
;(await this.daemon).stop()
this.turnOffHealthCheck()
this.setHealth({ status: "starting", message: null })
this.setHealth({ result: "starting", message: null })
}
}
@@ -88,8 +87,7 @@ export class HealthDaemon {
private async setupHealthCheck() {
if (this.#healthCheckCleanup) return
const trigger = (this.ready.trigger ?? defaultTrigger)(() => ({
hadSuccess: this.#hadSuccess,
lastResult: this.#health.status,
lastResult: this.#health.result,
}))
const { promise: status, resolve: setStatus } = oncePromise<{
@@ -101,19 +99,16 @@ export class HealthDaemon {
!res.done;
res = await Promise.race([status, trigger.next()])
) {
const response: CheckResult = await Promise.resolve(
const response: HealthCheckResult = await Promise.resolve(
this.ready.fn(),
).catch((err) => {
console.error(err)
return {
status: "failure",
result: "failure",
message: "message" in err ? err.message : String(err),
}
})
this.setHealth(response)
if (response.status === "success") {
this.#hadSuccess = true
}
await this.setHealth(response)
}
}).catch((err) => console.error(`Daemon ${this.id} failed: ${err}`))
@@ -123,37 +118,23 @@ export class HealthDaemon {
}
}
private setHealth(health: CheckResult) {
private async setHealth(health: HealthCheckResult) {
this.#health = health
this.#healthWatchers.forEach((watcher) => watcher())
const display = this.ready.display
const status = health.status
const result = health.result
if (!display) {
return
}
if (
status === "success" ||
status === "disabled" ||
status === "starting"
) {
this.effects.setHealth({
result: status,
message: health.message,
id: this.id,
name: display,
})
} else {
this.effects.setHealth({
result: health.status,
message: health.message || "",
id: this.id,
name: display,
})
}
await this.effects.setHealth({
...health,
id: this.id,
name: display,
} as SetHealth)
}
private async updateStatus() {
const healths = this.dependencies.map((d) => d.#health)
this.changeRunning(healths.every((x) => x.status === "success"))
this.changeRunning(healths.every((x) => x.result === "success"))
}
}

View File

@@ -3,6 +3,11 @@ import { ImageConfig, ImageId, VolumeId } from "../osBindings"
import { SDKManifest, SDKImageConfig } from "./ManifestTypes"
import { SDKVersion } from "../StartSdk"
/**
* This is an example of a function that takes a manifest and returns a new manifest with additional properties
* @param manifest Manifests are the description of the package
* @returns The manifest with additional properties
*/
export function setupManifest<
Id extends string,
Version extends string,
@@ -10,15 +15,16 @@ export function setupManifest<
VolumesTypes extends VolumeId,
AssetTypes extends VolumeId,
ImagesTypes extends ImageId,
Manifest extends SDKManifest<Version, Satisfies> & {
Manifest extends {
dependencies: Dependencies
id: Id
assets: AssetTypes[]
images: Record<ImagesTypes, SDKImageConfig>
volumes: VolumesTypes[]
version: Version
},
Satisfies extends string[] = [],
>(manifest: Manifest & { version: Version }): Manifest & T.Manifest {
>(manifest: SDKManifest<Version, Satisfies> & Manifest): Manifest & T.Manifest {
const images = Object.entries(manifest.images).reduce(
(images, [k, v]) => {
v.arch = v.arch || ["aarch64", "x86_64"]

View File

@@ -1,13 +1,14 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HealthCheckId } from "./HealthCheckId"
import type { HealthCheckResult } from "./HealthCheckResult"
import type { NamedHealthCheckResult } from "./NamedHealthCheckResult"
import type { PackageId } from "./PackageId"
export type CheckDependenciesResult = {
packageId: PackageId
isInstalled: boolean
title: string | null
installedVersion: string | null
satisfies: string[]
isRunning: boolean
configSatisfied: boolean
healthChecks: { [key: HealthCheckId]: HealthCheckResult }
version: string | null
healthChecks: { [key: HealthCheckId]: NamedHealthCheckResult }
}

View File

@@ -8,7 +8,6 @@ export type ExportServiceInterfaceParams = {
name: string
description: string
hasPrimary: boolean
disabled: boolean
masked: boolean
addressInfo: AddressInfo
type: ServiceInterfaceType

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HealthCheckId } from "./HealthCheckId"
import type { HealthCheckResult } from "./HealthCheckResult"
import type { NamedHealthCheckResult } from "./NamedHealthCheckResult"
export type MainStatus =
| { status: "stopped" }
@@ -11,10 +11,10 @@ export type MainStatus =
| {
status: "running"
started: string
health: { [key: HealthCheckId]: HealthCheckResult }
health: { [key: HealthCheckId]: NamedHealthCheckResult }
}
| {
status: "backingUp"
started: string | null
health: { [key: HealthCheckId]: HealthCheckResult }
health: { [key: HealthCheckId]: NamedHealthCheckResult }
}

View File

@@ -1,6 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HealthCheckResult = { name: string } & (
export type NamedHealthCheckResult = { name: string } & (
| { result: "success"; message: string | null }
| { result: "disabled"; message: string | null }
| { result: "starting"; message: string | null }

View File

@@ -8,7 +8,6 @@ export type ServiceInterface = {
name: string
description: string
hasPrimary: boolean
disabled: boolean
masked: boolean
addressInfo: AddressInfo
type: ServiceInterfaceType

View File

@@ -69,7 +69,6 @@ export { Governor } from "./Governor"
export { Guid } from "./Guid"
export { HardwareRequirements } from "./HardwareRequirements"
export { HealthCheckId } from "./HealthCheckId"
export { HealthCheckResult } from "./HealthCheckResult"
export { HostAddress } from "./HostAddress"
export { HostId } from "./HostId"
export { HostKind } from "./HostKind"
@@ -98,6 +97,7 @@ export { MaybeUtf8String } from "./MaybeUtf8String"
export { MerkleArchiveCommitment } from "./MerkleArchiveCommitment"
export { MountParams } from "./MountParams"
export { MountTarget } from "./MountTarget"
export { NamedHealthCheckResult } from "./NamedHealthCheckResult"
export { NamedProgress } from "./NamedProgress"
export { OnionHostname } from "./OnionHostname"
export { OsIndex } from "./OsIndex"

View File

@@ -16,7 +16,6 @@ describe("host", () => {
id: "foo",
description: "A Foo",
hasPrimary: false,
disabled: false,
type: "ui",
username: "bar",
path: "/baz",

View File

@@ -2,5 +2,4 @@ import { HealthStatus } from "../types"
export type TriggerInput = {
lastResult?: HealthStatus
hadSuccess?: boolean
}

View File

@@ -5,10 +5,12 @@ export function changeOnFirstSuccess(o: {
afterFirstSuccess: Trigger
}): Trigger {
return async function* (getInput) {
const beforeFirstSuccess = o.beforeFirstSuccess(getInput)
yield
let currentValue = getInput()
beforeFirstSuccess.next()
while (!currentValue.lastResult) {
yield
currentValue = getInput()
}
const beforeFirstSuccess = o.beforeFirstSuccess(getInput)
for (
let res = await beforeFirstSuccess.next();
currentValue?.lastResult !== "success" && !res.done;

View File

@@ -2,7 +2,7 @@ import { cooldownTrigger } from "./cooldownTrigger"
import { changeOnFirstSuccess } from "./changeOnFirstSuccess"
import { successFailure } from "./successFailure"
export const defaultTrigger = successFailure({
duringSuccess: cooldownTrigger(0),
duringError: cooldownTrigger(30000),
export const defaultTrigger = changeOnFirstSuccess({
beforeFirstSuccess: cooldownTrigger(1000),
afterFirstSuccess: cooldownTrigger(30000),
})

View File

@@ -0,0 +1,33 @@
import { Trigger } from "."
import { HealthStatus } from "../types"
export type LastStatusTriggerParams = { [k in HealthStatus]?: Trigger } & {
default: Trigger
}
export function lastStatus(o: LastStatusTriggerParams): Trigger {
return async function* (getInput) {
let trigger = o.default(getInput)
const triggers: {
[k in HealthStatus]?: AsyncIterator<unknown, unknown, never>
} & { default: AsyncIterator<unknown, unknown, never> } = {
default: trigger,
}
while (true) {
let currentValue = getInput()
let prev: HealthStatus | "default" | undefined = currentValue.lastResult
if (!prev) {
yield
continue
}
if (!(prev in o)) {
prev = "default"
}
if (!triggers[prev]) {
triggers[prev] = o[prev]!(getInput)
}
await triggers[prev]?.next()
yield
}
}
}

View File

@@ -1,32 +1,7 @@
import { Trigger } from "."
import { lastStatus } from "./lastStatus"
export function successFailure(o: {
export const successFailure = (o: {
duringSuccess: Trigger
duringError: Trigger
}): Trigger {
return async function* (getInput) {
while (true) {
const beforeSuccess = o.duringSuccess(getInput)
yield
let currentValue = getInput()
beforeSuccess.next()
for (
let res = await beforeSuccess.next();
currentValue?.lastResult !== "success" && !res.done;
res = await beforeSuccess.next()
) {
yield
currentValue = getInput()
}
const duringError = o.duringError(getInput)
for (
let res = await duringError.next();
currentValue?.lastResult === "success" && !res.done;
res = await duringError.next()
) {
yield
currentValue = getInput()
}
}
}
}
}) => lastStatus({ success: o.duringSuccess, default: o.duringError })

View File

@@ -3,7 +3,7 @@ export * as configTypes from "./config/configTypes"
import {
DependencyRequirement,
SetHealth,
HealthCheckResult,
NamedHealthCheckResult,
SetMainStatus,
ServiceInterface,
Host,
@@ -174,7 +174,7 @@ export type Daemon = {
[DaemonProof]: never
}
export type HealthStatus = HealthCheckResult["result"]
export type HealthStatus = NamedHealthCheckResult["result"]
export type SmtpValue = {
server: string
port: number
@@ -249,15 +249,15 @@ export type SdkPropertiesValue =
}
| {
type: "string"
/** Value */
/** The value to display to the user */
value: string
/** A human readable description or explanation of the value */
description?: string
/** (string/number only) Whether or not to mask the value, for example, when displaying a password */
/** Whether or not to mask the value, for example, when displaying a password */
masked: boolean
/** (string/number only) Whether or not to include a button for copying the value to clipboard */
/** Whether or not to include a button for copying the value to clipboard */
copyable?: boolean
/** (string/number only) Whether or not to include a button for displaying the value as a QR code */
/** Whether or not to include a button for displaying the value as a QR code */
qr?: boolean
}
@@ -273,15 +273,15 @@ export type PropertiesValue =
}
| {
type: "string"
/** Value */
/** The value to display to the user */
value: string
/** A human readable description or explanation of the value */
description: string | null
/** (string/number only) Whether or not to mask the value, for example, when displaying a password */
/** Whether or not to mask the value, for example, when displaying a password */
masked: boolean
/** (string/number only) Whether or not to include a button for copying the value to clipboard */
/** Whether or not to include a button for copying the value to clipboard */
copyable: boolean | null
/** (string/number only) Whether or not to include a button for displaying the value as a QR code */
/** Whether or not to include a button for displaying the value as a QR code */
qr: boolean | null
}

View File

@@ -39,6 +39,23 @@ export class Overlay {
return new Overlay(effects, id, rootfs, guid)
}
static async with<T>(
effects: T.Effects,
image: { id: T.ImageId; sharedRun?: boolean },
mounts: { options: MountOptions; path: string }[],
fn: (overlay: Overlay) => Promise<T>,
): Promise<T> {
const overlay = await Overlay.of(effects, image)
try {
for (let mount of mounts) {
await overlay.mount(mount.options, mount.path)
}
return await fn(overlay)
} finally {
await overlay.destroy()
}
}
async mount(options: MountOptions, path: string): Promise<Overlay> {
path = path.startsWith("/")
? `${this.rootfs}${path}`

View File

@@ -50,8 +50,6 @@ export type ServiceInterfaceFilled = {
description: string
/** Whether or not the interface has a primary URL */
hasPrimary: boolean
/** Whether or not the interface disabled */
disabled: boolean
/** Whether or not to mask the URIs for this interface. Useful if the URIs contain sensitive information, such as a password, macaroon, or API key */
masked: boolean
/** Information about the host for this binding */

4
sdk/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@start9labs/start-sdk",
"version": "0.3.6-alpha5",
"version": "0.3.6-alpha7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@start9labs/start-sdk",
"version": "0.3.6-alpha5",
"version": "0.3.6-alpha7",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@start9labs/start-sdk",
"version": "0.3.6-alpha6",
"version": "0.3.6-alpha7",
"description": "Software development kit to facilitate packaging services for StartOS",
"main": "./cjs/lib/index.js",
"types": "./cjs/lib/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "startos-ui",
"version": "0.3.6-alpha.3",
"version": "0.3.6-alpha.4",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",

View File

@@ -21,5 +21,5 @@
"ackInstructions": {},
"theme": "Dark",
"widgets": [],
"ack-welcome": "0.3.6-alpha.3"
"ack-welcome": "0.3.6-alpha.4"
}

View File

@@ -4,7 +4,7 @@ import { ErrorService } from '@start9labs/shared'
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page'
import {
ApiService,
StartOSDiskInfoWithId,
StartOSDiskInfoFull,
} from 'src/app/services/api/api.service'
import { StateService } from 'src/app/services/state.service'
import { PasswordPage } from '../../modals/password/password.page'
@@ -16,7 +16,7 @@ import { PasswordPage } from '../../modals/password/password.page'
})
export class RecoverPage {
loading = true
servers: StartOSDiskInfoWithId[] = []
servers: StartOSDiskInfoFull[] = []
constructor(
private readonly apiService: ApiService,
@@ -78,7 +78,7 @@ export class RecoverPage {
await modal.present()
}
async select(server: StartOSDiskInfoWithId) {
async select(server: StartOSDiskInfoFull) {
const modal = await this.modalController.create({
component: PasswordPage,
componentProps: { passwordHash: server.passwordHash },
@@ -90,7 +90,7 @@ export class RecoverPage {
type: 'backup',
target: {
type: 'disk',
logicalname: res.data.logicalname,
logicalname: server.partition.logicalname,
},
serverId: server.id,
password: res.data.password,

View File

@@ -12,7 +12,7 @@
<ion-content class="ion-padding">
<h2>This Release</h2>
<h4>0.3.6-alpha.3</h4>
<h4>0.3.6-alpha.4</h4>
<h6>This is an ALPHA release! DO NOT use for production data!</h6>
<h6>Expect that any data you create or store on this version of the OS can be LOST FOREVER!</h6>

View File

@@ -10,15 +10,15 @@ import { ConnectionService } from 'src/app/services/connection.service'
})
export class AppShowHealthChecksComponent {
@Input()
healthChecks!: Record<string, T.HealthCheckResult>
healthChecks!: Record<string, T.NamedHealthCheckResult>
constructor(readonly connection$: ConnectionService) {}
isLoading(result: T.HealthCheckResult['result']): boolean {
isLoading(result: T.NamedHealthCheckResult['result']): boolean {
return result === 'starting' || result === 'loading'
}
isReady(result: T.HealthCheckResult['result']): boolean {
isReady(result: T.NamedHealthCheckResult['result']): boolean {
return result !== 'failure' && result !== 'loading'
}

View File

@@ -5,7 +5,7 @@ import { T } from '@start9labs/start-sdk'
name: 'healthColor',
})
export class HealthColorPipe implements PipeTransform {
transform(val: T.HealthCheckResult['result']): string {
transform(val: T.NamedHealthCheckResult['result']): string {
switch (val) {
case 'success':
return 'success'

View File

@@ -14,7 +14,7 @@ export class ToHealthChecksPipe implements PipeTransform {
transform(
manifest: T.Manifest,
): Observable<Record<string, T.HealthCheckResult | null> | null> {
): Observable<Record<string, T.NamedHealthCheckResult | null> | null> {
return this.patch.watch$('packageData', manifest.id, 'status', 'main').pipe(
map(main => {
return main.status === 'running' && !isEmptyObject(main.health)

View File

@@ -1699,7 +1699,6 @@ export module Mock {
ui: {
id: 'ui',
hasPrimary: false,
disabled: false,
masked: false,
name: 'Web UI',
description:
@@ -1717,7 +1716,6 @@ export module Mock {
rpc: {
id: 'rpc',
hasPrimary: false,
disabled: false,
masked: false,
name: 'RPC',
description:
@@ -1735,7 +1733,6 @@ export module Mock {
p2p: {
id: 'p2p',
hasPrimary: true,
disabled: false,
masked: false,
name: 'P2P',
description:
@@ -1876,7 +1873,6 @@ export module Mock {
ui: {
id: 'ui',
hasPrimary: false,
disabled: false,
masked: false,
name: 'Web UI',
description: 'A launchable web app for Bitcoin Proxy',
@@ -1925,7 +1921,6 @@ export module Mock {
grpc: {
id: 'grpc',
hasPrimary: false,
disabled: false,
masked: false,
name: 'GRPC',
description:
@@ -1943,7 +1938,6 @@ export module Mock {
lndconnect: {
id: 'lndconnect',
hasPrimary: false,
disabled: false,
masked: true,
name: 'LND Connect',
description:
@@ -1961,7 +1955,6 @@ export module Mock {
p2p: {
id: 'p2p',
hasPrimary: true,
disabled: false,
masked: false,
name: 'P2P',
description:

View File

@@ -535,7 +535,7 @@ export interface DependencyErrorConfigUnsatisfied {
export interface DependencyErrorHealthChecksFailed {
type: 'healthChecksFailed'
check: T.HealthCheckResult
check: T.NamedHealthCheckResult
}
export interface DependencyErrorTransitive {

View File

@@ -132,7 +132,6 @@ export const mockPatchData: DataModel = {
ui: {
id: 'ui',
hasPrimary: false,
disabled: false,
masked: false,
name: 'Web UI',
description:
@@ -150,7 +149,6 @@ export const mockPatchData: DataModel = {
rpc: {
id: 'rpc',
hasPrimary: false,
disabled: false,
masked: false,
name: 'RPC',
description:
@@ -168,7 +166,6 @@ export const mockPatchData: DataModel = {
p2p: {
id: 'p2p',
hasPrimary: true,
disabled: false,
masked: false,
name: 'P2P',
description:
@@ -311,7 +308,6 @@ export const mockPatchData: DataModel = {
grpc: {
id: 'grpc',
hasPrimary: false,
disabled: false,
masked: false,
name: 'GRPC',
description:
@@ -329,7 +325,6 @@ export const mockPatchData: DataModel = {
lndconnect: {
id: 'lndconnect',
hasPrimary: false,
disabled: false,
masked: true,
name: 'LND Connect',
description:
@@ -347,7 +342,6 @@ export const mockPatchData: DataModel = {
p2p: {
id: 'p2p',
hasPrimary: true,
disabled: false,
masked: false,
name: 'P2P',
description: