Refactor/status info (#3066)

* refactor status info

* wip fe

* frontend changes and version bump

* fix tests and motd

* add registry workflow

* better starttunnel instructions

* placeholders for starttunnel tables

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2025-12-02 16:31:02 -07:00
committed by GitHub
parent 7c772e873d
commit 3c27499795
80 changed files with 920 additions and 1062 deletions

100
.github/workflows/start-registry.yaml vendored Normal file
View File

@@ -0,0 +1,100 @@
name: Start-Registry
on:
workflow_call:
workflow_dispatch:
inputs:
environment:
type: choice
description: Environment
options:
- NONE
- dev
- unstable
- dev-unstable
runner:
type: choice
description: Runner
options:
- standard
- fast
arch:
type: choice
description: Architecture
options:
- ALL
- x86_64
- aarch64
- riscv64
push:
branches:
- master
- next/*
pull_request:
branches:
- master
- next/*
env:
NODEJS_VERSION: "24.11.0"
ENVIRONMENT: '${{ fromJson(format(''["{0}", ""]'', github.event.inputs.environment || ''dev''))[github.event.inputs.environment == ''NONE''] }}'
jobs:
compile:
name: Build Debian Package
strategy:
fail-fast: true
matrix:
arch: >-
${{
fromJson('{
"x86_64": ["x86_64"],
"aarch64": ["aarch64"],
"riscv64": ["riscv64"],
"ALL": ["x86_64", "aarch64", "riscv64"]
}')[github.event.inputs.platform || 'ALL']
}}
runs-on: ${{ fromJson('["ubuntu-latest", "buildjet-32vcpu-ubuntu-2204"]')[github.event.inputs.runner == 'fast'] }}
steps:
- name: Cleaning up unnecessary files
run: |
sudo apt-get remove --purge -y google-chrome-stable firefox mono-devel
sudo apt-get autoremove -y
sudo apt-get clean
- run: |
sudo mount -t tmpfs tmpfs .
if: ${{ github.event.inputs.runner == 'fast' }}
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODEJS_VERSION }}
- name: Set up docker QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Configure sccache
uses: actions/github-script@v7
with:
script: |
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
- name: Make
run: make registry-deb
env:
PLATFORM: ${{ matrix.arch }}
SCCACHE_GHA_ENABLED: on
SCCACHE_GHA_VERSION: 0
- uses: actions/upload-artifact@v4
with:
name: start-registry_${{ matrix.arch }}.deb
path: results/start-registry-*_${{ matrix.arch }}.deb

View File

@@ -26,18 +26,18 @@ Use it for private remote access to self-hosted services running on a personal s
1. Access the VPS via SSH. 1. Access the VPS via SSH.
1. Install StartTunnel: 1. Run the StartTunnel install script:
```sh curl -fsSL https://start9labs.github.io/start-tunnel | sh
TMP_DIR=$(mktemp -d) && (cd $TMP_DIR && wget https://github.com/Start9Labs/start-os/releases/download/v0.4.0-alpha.15/start-tunnel-0.4.0-alpha.15-a53b15f.dev_$(uname -m).deb && apt-get install -y ./start-tunnel-0.4.0-alpha.15-a53b15f.dev_$(uname -m).deb) && rm -rf $TMP_DIR && systemctl start start-tunneld && echo "Installation Succeeded"
```
5. [Initialize the web interface](#web-interface) (recommended) 1. [Initialize the web interface](#web-interface) (recommended)
## Updating ## Updating
Simply re-run the install command:
```sh ```sh
TMP_DIR=$(mktemp -d) && (cd $TMP_DIR && wget https://github.com/Start9Labs/start-os/releases/download/v0.4.0-alpha.15/start-tunnel-0.4.0-alpha.15-a53b15f.dev_$(uname -m).deb && apt-get install --reinstall -y ./start-tunnel-0.4.0-alpha.15-a53b15f.dev_$(uname -m).deb) && rm -rf $TMP_DIR && systemctl daemon-reload && systemctl restart start-tunneld && echo "Update Succeeded" curl -fsSL https://start9labs.github.io/start-tunnel | sh
``` ```
## CLI ## CLI
@@ -84,7 +84,7 @@ Enable the web interface (recommended in most cases) to access your StartTunnel
3. Paste the contents of your Root CA. 3. Paste the contents of your Root CA.
4. Save the file as `ca.crt` or `ca.pem` (make sure it saves as plain text, not rich text). 4. Save the file with a `.crt` extension (e.g. `start-tunnel.crt`) (make sure it saves as plain text, not rich text).
5. Trust the Root CA on your client device(s): 5. Trust the Root CA on your client device(s):

View File

@@ -22,7 +22,7 @@ parse_essential_db_info() {
RAM_GB="unknown" RAM_GB="unknown"
fi fi
RUNNING_SERVICES=$(jq -r '[.value.packageData[] | select(.status.main == "running")] | length' "$DB_DUMP" 2>/dev/null) RUNNING_SERVICES=$(jq -r '[.value.packageData[] | select(.statusInfo.started != null)] | length' "$DB_DUMP" 2>/dev/null)
TOTAL_SERVICES=$(jq -r '.value.packageData | length' "$DB_DUMP" 2>/dev/null) TOTAL_SERVICES=$(jq -r '.value.packageData | length' "$DB_DUMP" 2>/dev/null)
rm -f "$DB_DUMP" rm -f "$DB_DUMP"

2
core/Cargo.lock generated
View File

@@ -7908,7 +7908,7 @@ dependencies = [
[[package]] [[package]]
name = "start-os" name = "start-os"
version = "0.4.0-alpha.15" version = "0.4.0-alpha.16"
dependencies = [ dependencies = [
"aes 0.7.5", "aes 0.7.5",
"arti-client", "arti-client",

View File

@@ -11,6 +11,7 @@ use rpc_toolkit::yajrc::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use ts_rs::TS;
use crate::InvalidId; use crate::InvalidId;
@@ -407,7 +408,7 @@ impl From<patch_db::value::Error> for Error {
} }
} }
#[derive(Clone, Deserialize, Serialize)] #[derive(Clone, Deserialize, Serialize, TS)]
pub struct ErrorData { pub struct ErrorData {
pub details: String, pub details: String,
pub debug: String, pub debug: String,

View File

@@ -15,7 +15,7 @@ license = "MIT"
name = "start-os" name = "start-os"
readme = "README.md" readme = "README.md"
repository = "https://github.com/Start9Labs/start-os" repository = "https://github.com/Start9Labs/start-os"
version = "0.4.0-alpha.15" # VERSION_BUMP version = "0.4.0-alpha.16" # VERSION_BUMP
[lib] [lib]
name = "startos" name = "startos"
@@ -64,7 +64,7 @@ default = ["cli", "cli-container", "registry", "startd", "tunnel"]
dev = ["backtrace-on-stack-overflow"] dev = ["backtrace-on-stack-overflow"]
docker = [] docker = []
registry = [] registry = []
startd = [] startd = ["procfs", "pty-process"]
test = [] test = []
tunnel = [] tunnel = []
unstable = ["backtrace-on-stack-overflow"] unstable = ["backtrace-on-stack-overflow"]

View File

@@ -1,15 +1,12 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use chrono::{DateTime, Utc}; use models::PackageId;
use models::{HostId, PackageId};
use reqwest::Url;
use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async}; use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::context::CliContext; use crate::context::CliContext;
#[allow(unused_imports)] #[allow(unused_imports)]
use crate::prelude::*; use crate::prelude::*;
use crate::util::serde::{Base32, Base64};
pub mod backup_bulk; pub mod backup_bulk;
pub mod os; pub mod os;
@@ -58,13 +55,3 @@ pub fn package_backup<C: Context>() -> ParentHandler<C> {
.with_call_remote::<CliContext>(), .with_call_remote::<CliContext>(),
) )
} }
#[derive(Deserialize, Serialize)]
struct BackupMetadata {
pub timestamp: DateTime<Utc>,
#[serde(default)]
pub network_keys: BTreeMap<HostId, Base64<[u8; 32]>>,
#[serde(default)]
pub tor_keys: BTreeMap<HostId, Base32<[u8; 64]>>, // DEPRECATED
pub registry: Option<Url>,
}

View File

@@ -45,6 +45,7 @@ use crate::service::ServiceMap;
use crate::service::action::update_tasks; use crate::service::action::update_tasks;
use crate::service::effects::callbacks::ServiceCallbacks; use crate::service::effects::callbacks::ServiceCallbacks;
use crate::shutdown::Shutdown; use crate::shutdown::Shutdown;
use crate::status::DesiredStatus;
use crate::util::io::delete_file; use crate::util::io::delete_file;
use crate::util::lshw::LshwDevice; use crate::util::lshw::LshwDevice;
use crate::util::sync::{SyncMutex, SyncRwLock, Watch}; use crate::util::sync::{SyncMutex, SyncRwLock, Watch};
@@ -416,46 +417,34 @@ impl RpcContext {
} }
} }
} }
for id in
self.db self.db
.mutate::<Vec<PackageId>>(|db| { .mutate(|db| {
for (package_id, action_input) in &action_input { for (package_id, action_input) in &action_input {
for (action_id, input) in action_input { for (action_id, input) in action_input {
for (_, pde) in for (_, pde) in db.as_public_mut().as_package_data_mut().as_entries_mut()? {
db.as_public_mut().as_package_data_mut().as_entries_mut()? pde.as_tasks_mut().mutate(|tasks| {
{ Ok(update_tasks(tasks, package_id, action_id, input, false))
pde.as_tasks_mut().mutate(|tasks| { })?;
Ok(update_tasks(tasks, package_id, action_id, input, false))
})?;
}
} }
} }
db.as_public() }
.as_package_data() for (_, pde) in db.as_public_mut().as_package_data_mut().as_entries_mut()? {
.as_entries()? if pde
.as_tasks()
.de()?
.into_iter() .into_iter()
.filter_map(|(id, pkg)| { .any(|(_, t)| t.active && t.task.severity == TaskSeverity::Critical)
(|| { {
if pkg.as_tasks().de()?.into_iter().any(|(_, t)| { pde.as_status_info_mut()
t.active && t.task.severity == TaskSeverity::Critical .as_desired_mut()
}) { .ser(&DesiredStatus::Stopped)?;
Ok(Some(id)) }
} else { }
Ok(None) Ok(())
} })
})() .await
.transpose() .result?;
})
.collect()
})
.await
.result?
{
let svc = self.services.get(&id).await;
if let Some(svc) = &*svc {
svc.stop(procedure_id.clone(), false).await?;
}
}
check_tasks.complete(); check_tasks.complete();
Ok(()) Ok(())

View File

@@ -1,5 +1,4 @@
use clap::Parser; use clap::Parser;
use color_eyre::eyre::eyre;
use models::PackageId; use models::PackageId;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::instrument; use tracing::instrument;
@@ -8,7 +7,6 @@ use ts_rs::TS;
use crate::Error; use crate::Error;
use crate::context::RpcContext; use crate::context::RpcContext;
use crate::prelude::*; use crate::prelude::*;
use crate::rpc_continuations::Guid;
#[derive(Deserialize, Serialize, Parser, TS)] #[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -19,37 +17,51 @@ pub struct ControlParams {
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn start(ctx: RpcContext, ControlParams { id }: ControlParams) -> Result<(), Error> { pub async fn start(ctx: RpcContext, ControlParams { id }: ControlParams) -> Result<(), Error> {
ctx.services ctx.db
.get(&id) .mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&id)
.or_not_found(&id)?
.as_status_info_mut()
.as_desired_mut()
.map_mutate(|s| Ok(s.start()))
})
.await .await
.as_ref() .result?;
.or_not_found(lazy_format!("Manager for {id}"))?
.start(Guid::new())
.await?;
Ok(()) Ok(())
} }
pub async fn stop(ctx: RpcContext, ControlParams { id }: ControlParams) -> Result<(), Error> { pub async fn stop(ctx: RpcContext, ControlParams { id }: ControlParams) -> Result<(), Error> {
ctx.services ctx.db
.get(&id) .mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&id)
.or_not_found(&id)?
.as_status_info_mut()
.stop()
})
.await .await
.as_ref() .result?;
.ok_or_else(|| Error::new(eyre!("Manager not found"), crate::ErrorKind::InvalidRequest))?
.stop(Guid::new(), true)
.await?;
Ok(()) Ok(())
} }
pub async fn restart(ctx: RpcContext, ControlParams { id }: ControlParams) -> Result<(), Error> { pub async fn restart(ctx: RpcContext, ControlParams { id }: ControlParams) -> Result<(), Error> {
ctx.services ctx.db
.get(&id) .mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&id)
.or_not_found(&id)?
.as_status_info_mut()
.as_desired_mut()
.map_mutate(|s| Ok(s.restart()))
})
.await .await
.as_ref() .result?;
.ok_or_else(|| Error::new(eyre!("Manager not found"), crate::ErrorKind::InvalidRequest))?
.restart(Guid::new(), false)
.await?;
Ok(()) Ok(())
} }

View File

@@ -1,7 +1,6 @@
pub mod model; pub mod model;
pub mod prelude; pub mod prelude;
use std::panic::UnwindSafe;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;

View File

@@ -16,7 +16,7 @@ use crate::net::service_interface::ServiceInterface;
use crate::prelude::*; use crate::prelude::*;
use crate::progress::FullProgress; use crate::progress::FullProgress;
use crate::s9pk::manifest::Manifest; use crate::s9pk::manifest::Manifest;
use crate::status::MainStatus; use crate::status::StatusInfo;
use crate::util::serde::{Pem, is_partial_of}; use crate::util::serde::{Pem, is_partial_of};
#[derive(Debug, Default, Deserialize, Serialize, TS)] #[derive(Debug, Default, Deserialize, Serialize, TS)]
@@ -365,7 +365,7 @@ impl Default for ActionVisibility {
pub struct PackageDataEntry { pub struct PackageDataEntry {
pub state_info: PackageState, pub state_info: PackageState,
pub s9pk: PathBuf, pub s9pk: PathBuf,
pub status: MainStatus, pub status_info: StatusInfo,
#[ts(type = "string | null")] #[ts(type = "string | null")]
pub registry: Option<Url>, pub registry: Option<Url>,
#[ts(type = "string")] #[ts(type = "string")]

View File

@@ -5,11 +5,11 @@ use std::sync::{Arc, Weak};
use std::time::Duration; use std::time::Duration;
use clap::builder::ValueParserFactory; use clap::builder::ValueParserFactory;
use futures::{AsyncWriteExt, StreamExt}; use futures::StreamExt;
use imbl_value::{InOMap, InternedString}; use imbl_value::InternedString;
use models::{FromStrParser, InvalidId, PackageId}; use models::{FromStrParser, InvalidId, PackageId};
use rpc_toolkit::yajrc::RpcError; use rpc_toolkit::yajrc::RpcError;
use rpc_toolkit::{GenericRpcMethod, RpcRequest, RpcResponse}; use rpc_toolkit::{RpcRequest, RpcResponse};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command; use tokio::process::Command;
@@ -17,7 +17,7 @@ use tokio::sync::Mutex;
use tokio::time::Instant; use tokio::time::Instant;
use ts_rs::TS; use ts_rs::TS;
use crate::context::{CliContext, RpcContext}; use crate::context::RpcContext;
use crate::disk::mount::filesystem::bind::Bind; use crate::disk::mount::filesystem::bind::Bind;
use crate::disk::mount::filesystem::block_dev::BlockDev; use crate::disk::mount::filesystem::block_dev::BlockDev;
use crate::disk::mount::filesystem::idmapped::IdMapped; use crate::disk::mount::filesystem::idmapped::IdMapped;

View File

@@ -23,7 +23,6 @@ use hickory_server::proto::rr::{Name, Record, RecordType};
use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo}; use hickory_server::server::{Request, RequestHandler, ResponseHandler, ResponseInfo};
use imbl::OrdMap; use imbl::OrdMap;
use imbl_value::InternedString; use imbl_value::InternedString;
use itertools::Itertools;
use models::{GatewayId, OptionExt, PackageId}; use models::{GatewayId, OptionExt, PackageId};
use patch_db::json_ptr::JsonPointer; use patch_db::json_ptr::JsonPointer;
use rpc_toolkit::{ use rpc_toolkit::{

View File

@@ -73,11 +73,6 @@ macro_rules! else_empty_dir {
}}; }};
} }
const EMBEDDED_UI_ROOT: Dir<'_> = else_empty_dir!(
feature = "startd" =>
include_dir::include_dir!("$CARGO_MANIFEST_DIR/../../web/dist/static")
);
pub trait UiContext: Context + AsRef<RpcContinuations> + Clone + Sized { pub trait UiContext: Context + AsRef<RpcContinuations> + Clone + Sized {
const UI_DIR: &'static Dir<'static>; const UI_DIR: &'static Dir<'static>;
fn api() -> ParentHandler<Self>; fn api() -> ParentHandler<Self>;

View File

@@ -376,7 +376,7 @@ impl Default for AlpnInfo {
} }
} }
type Mapping<A: Accept> = BTreeMap<Option<InternedString>, InOMap<DynVHostTarget<A>, Weak<()>>>; type Mapping<A> = BTreeMap<Option<InternedString>, InOMap<DynVHostTarget<A>, Weak<()>>>;
pub struct GetVHostAcmeProvider<A: Accept + 'static>(pub Watch<Mapping<A>>); pub struct GetVHostAcmeProvider<A: Accept + 'static>(pub Watch<Mapping<A>>);
impl<A: Accept + 'static> Clone for GetVHostAcmeProvider<A> { impl<A: Accept + 'static> Clone for GetVHostAcmeProvider<A> {

View File

@@ -11,7 +11,6 @@ use crate::context::CliContext;
use crate::middleware::cors::Cors; use crate::middleware::cors::Cors;
use crate::middleware::signature::SignatureAuth; use crate::middleware::signature::SignatureAuth;
use crate::net::static_server::{bad_request, not_found, server_error}; use crate::net::static_server::{bad_request, not_found, server_error};
use crate::net::web_server::{Accept, WebServer};
use crate::prelude::*; use crate::prelude::*;
use crate::registry::context::RegistryContext; use crate::registry::context::RegistryContext;
use crate::registry::device_info::DeviceInfoMiddleware; use crate::registry::device_info::DeviceInfoMiddleware;

View File

@@ -55,7 +55,7 @@ pub trait FileSource: Send + Sync + Sized + 'static {
fn to_vec( fn to_vec(
src: &impl FileSource, src: &impl FileSource,
verify: Option<(Hash, u64)>, verify: Option<(Hash, u64)>,
) -> BoxFuture<Result<Vec<u8>, Error>> { ) -> BoxFuture<'_, Result<Vec<u8>, Error>> {
async move { async move {
let mut vec = Vec::with_capacity(if let Some((_, size)) = &verify { let mut vec = Vec::with_capacity(if let Some((_, size)) = &verify {
*size *size

View File

@@ -8,7 +8,7 @@ use models::{ImageId, VolumeId};
use tokio::io::{AsyncRead, AsyncSeek, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncSeek, AsyncWriteExt};
use tokio::process::Command; use tokio::process::Command;
use crate::dependencies::{DepInfo, Dependencies, MetadataSrc}; use crate::dependencies::{DepInfo, Dependencies};
use crate::prelude::*; use crate::prelude::*;
use crate::s9pk::manifest::{DeviceFilter, Manifest}; use crate::s9pk::manifest::{DeviceFilter, Manifest};
use crate::s9pk::merkle_archive::directory_contents::DirectoryContents; use crate::s9pk::merkle_archive::directory_contents::DirectoryContents;

View File

@@ -130,11 +130,11 @@ impl Handler<RunAction> for ServiceActor {
ref action_id, ref action_id,
input, input,
}: RunAction, }: RunAction,
jobs: &BackgroundJobQueue, _: &BackgroundJobQueue,
) -> Self::Response { ) -> Self::Response {
let container = &self.0.persistent_container; let container = &self.0.persistent_container;
let package_id = &self.0.id; let package_id = &self.0.id;
let action = self let pde = self
.0 .0
.ctx .ctx
.db .db
@@ -143,9 +143,10 @@ impl Handler<RunAction> for ServiceActor {
.into_public() .into_public()
.into_package_data() .into_package_data()
.into_idx(package_id) .into_idx(package_id)
.or_not_found(package_id)? .or_not_found(package_id)?;
.into_actions() let action = pde
.into_idx(action_id) .as_actions()
.as_idx(action_id)
.or_not_found(lazy_format!("{package_id} action {action_id}"))? .or_not_found(lazy_format!("{package_id} action {action_id}"))?
.de()?; .de()?;
if matches!(&action.visibility, ActionVisibility::Disabled(_)) { if matches!(&action.visibility, ActionVisibility::Disabled(_)) {
@@ -154,7 +155,7 @@ impl Handler<RunAction> for ServiceActor {
ErrorKind::Action, ErrorKind::Action,
)); ));
} }
let running = container.state.borrow().running_status.as_ref().is_some(); let running = pde.as_status_info().as_started().transpose_ref().is_some();
if match action.allowed_statuses { if match action.allowed_statuses {
AllowedStatuses::OnlyRunning => !running, AllowedStatuses::OnlyRunning => !running,
AllowedStatuses::OnlyStopped => running, AllowedStatuses::OnlyStopped => running,
@@ -177,44 +178,21 @@ impl Handler<RunAction> for ServiceActor {
.await .await
.with_kind(ErrorKind::Action)?; .with_kind(ErrorKind::Action)?;
let package_id = package_id.clone(); let package_id = package_id.clone();
for to_stop in self self.0
.0
.ctx .ctx
.db .db
.mutate(|db| { .mutate(|db| {
let mut to_stop = Vec::new(); for (_, pde) in db.as_public_mut().as_package_data_mut().as_entries_mut()? {
for (id, pde) in db.as_public_mut().as_package_data_mut().as_entries_mut()? {
if pde.as_tasks_mut().mutate(|tasks| { if pde.as_tasks_mut().mutate(|tasks| {
Ok(update_tasks(tasks, &package_id, action_id, &input, true)) Ok(update_tasks(tasks, &package_id, action_id, &input, true))
})? { })? {
to_stop.push(id) pde.as_status_info_mut().stop()?;
} }
} }
Ok(to_stop) Ok(())
}) })
.await .await
.result? .result?;
{
if to_stop == package_id {
<Self as Handler<super::control::Stop>>::handle(
self,
id.clone(),
super::control::Stop { wait: false },
jobs,
)
.await;
} else {
self.0
.ctx
.services
.get(&to_stop)
.await
.as_ref()
.or_not_found(&to_stop)?
.stop(id.clone(), false)
.await?;
}
}
Ok(result) Ok(result)
} }
} }

View File

@@ -1,67 +0,0 @@
use futures::future::OptionFuture;
use crate::prelude::*;
use crate::rpc_continuations::Guid;
use crate::service::action::RunAction;
use crate::service::start_stop::StartStop;
use crate::service::transition::TransitionKind;
use crate::service::{Service, ServiceActor};
use crate::util::actor::background::BackgroundJobQueue;
use crate::util::actor::{ConflictBuilder, Handler};
pub(super) struct Start;
impl Handler<Start> for ServiceActor {
type Response = ();
fn conflicts_with(_: &Start) -> ConflictBuilder<Self> {
ConflictBuilder::everything().except::<RunAction>()
}
async fn handle(&mut self, _: Guid, _: Start, _: &BackgroundJobQueue) -> Self::Response {
self.0.persistent_container.state.send_modify(|x| {
x.desired_state = StartStop::Start;
});
self.0.synchronized.notified().await
}
}
impl Service {
pub async fn start(&self, id: Guid) -> Result<(), Error> {
self.actor.send(id, Start).await
}
}
pub(super) struct Stop {
pub wait: bool,
}
impl Handler<Stop> for ServiceActor {
type Response = ();
fn conflicts_with(_: &Stop) -> ConflictBuilder<Self> {
ConflictBuilder::everything().except::<RunAction>()
}
async fn handle(
&mut self,
_: Guid,
Stop { wait }: Stop,
_: &BackgroundJobQueue,
) -> Self::Response {
let mut transition_state = None;
self.0.persistent_container.state.send_modify(|x| {
x.desired_state = StartStop::Stop;
if x.transition_state.as_ref().map(|x| x.kind()) == Some(TransitionKind::Restarting) {
transition_state = std::mem::take(&mut x.transition_state);
}
});
let notif = if wait {
Some(self.0.synchronized.notified())
} else {
None
};
if let Some(restart) = transition_state {
restart.abort().await;
}
OptionFuture::from(notif).await;
}
}
impl Service {
pub async fn stop(&self, id: Guid, wait: bool) -> Result<(), Error> {
self.actor.send(id, Stop { wait }).await
}
}

View File

@@ -261,19 +261,20 @@ async fn create_task(
}, },
None => true, None => true,
}; };
if active && task.severity == TaskSeverity::Critical {
context.stop(procedure_id, false).await?;
}
context context
.seed .seed
.ctx .ctx
.db .db
.mutate(|db| { .mutate(|db| {
db.as_public_mut() let pde = db
.as_public_mut()
.as_package_data_mut() .as_package_data_mut()
.as_idx_mut(src_id) .as_idx_mut(src_id)
.or_not_found(src_id)? .or_not_found(src_id)?;
.as_tasks_mut() if active && task.severity == TaskSeverity::Critical {
pde.as_status_info_mut().stop()?;
}
pde.as_tasks_mut()
.insert(&replay_id, &TaskEntry { active, task }) .insert(&replay_id, &TaskEntry { active, task })
}) })
.await .await

View File

@@ -1,12 +1,13 @@
use std::str::FromStr; use std::str::FromStr;
use chrono::Utc;
use clap::builder::ValueParserFactory; use clap::builder::ValueParserFactory;
use models::{FromStrParser, PackageId}; use models::{FromStrParser, PackageId};
use crate::service::RebuildParams; use crate::service::RebuildParams;
use crate::service::effects::prelude::*; use crate::service::effects::prelude::*;
use crate::service::rpc::CallbackId; use crate::service::rpc::CallbackId;
use crate::status::MainStatus; use crate::status::{DesiredStatus, StatusInfo};
pub async fn rebuild(context: EffectContext) -> Result<(), Error> { pub async fn rebuild(context: EffectContext) -> Result<(), Error> {
let seed = context.deref()?.seed.clone(); let seed = context.deref()?.seed.clone();
@@ -21,15 +22,44 @@ pub async fn rebuild(context: EffectContext) -> Result<(), Error> {
Ok(()) Ok(())
} }
pub async fn restart(context: EffectContext, EventId { event_id }: EventId) -> Result<(), Error> { pub async fn restart(context: EffectContext) -> Result<(), Error> {
let context = context.deref()?; let context = context.deref()?;
context.restart(event_id, false).await?; let id = &context.seed.id;
context
.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_status_info_mut()
.as_desired_mut()
.map_mutate(|s| Ok(s.restart()))
})
.await
.result?;
Ok(()) Ok(())
} }
pub async fn shutdown(context: EffectContext, EventId { event_id }: EventId) -> Result<(), Error> { pub async fn shutdown(context: EffectContext) -> Result<(), Error> {
let context = context.deref()?; let context = context.deref()?;
context.stop(event_id, false).await?; let id = &context.seed.id;
context
.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_status_info_mut()
.stop()
})
.await
.result?;
Ok(()) Ok(())
} }
@@ -50,7 +80,7 @@ pub async fn get_status(
package_id, package_id,
callback, callback,
}: GetStatusParams, }: GetStatusParams,
) -> Result<MainStatus, Error> { ) -> Result<StatusInfo, Error> {
let context = context.deref()?; let context = context.deref()?;
let id = package_id.unwrap_or_else(|| context.seed.id.clone()); let id = package_id.unwrap_or_else(|| context.seed.id.clone());
let db = context.seed.ctx.db.peek().await; let db = context.seed.ctx.db.peek().await;
@@ -68,13 +98,13 @@ pub async fn get_status(
.as_package_data() .as_package_data()
.as_idx(&id) .as_idx(&id)
.or_not_found(&id)? .or_not_found(&id)?
.as_status() .as_status_info()
.de()?; .de()?;
Ok(status) Ok(status)
} }
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub enum SetMainStatusStatus { pub enum SetMainStatusStatus {
@@ -109,9 +139,34 @@ pub async fn set_main_status(
SetMainStatus { status }: SetMainStatus, SetMainStatus { status }: SetMainStatus,
) -> Result<(), Error> { ) -> Result<(), Error> {
let context = context.deref()?; let context = context.deref()?;
match status { let id = &context.seed.id;
SetMainStatusStatus::Running => context.seed.started(), context
SetMainStatusStatus::Stopped => context.seed.stopped(), .seed
} .ctx
.db
.mutate(|db| {
let s = db
.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_status_info_mut();
let prev = s.as_started_mut().replace(&match status {
SetMainStatusStatus::Running => Some(Utc::now()),
SetMainStatusStatus::Stopped => None,
})?;
if prev.is_none() && status == SetMainStatusStatus::Running {
s.as_desired_mut().map_mutate(|s| {
Ok(match s {
DesiredStatus::Restarting => DesiredStatus::Running,
x => x,
})
})?;
}
Ok(())
})
.await
.result?;
Ok(()) Ok(())
} }

View File

@@ -4,7 +4,6 @@ use std::str::FromStr;
use clap::builder::ValueParserFactory; use clap::builder::ValueParserFactory;
use exver::VersionRange; use exver::VersionRange;
use imbl::OrdMap;
use imbl_value::InternedString; use imbl_value::InternedString;
use models::{FromStrParser, HealthCheckId, PackageId, ReplayId, VersionString, VolumeId}; use models::{FromStrParser, HealthCheckId, PackageId, ReplayId, VersionString, VolumeId};
@@ -96,13 +95,6 @@ pub async fn get_installed_packages(context: EffectContext) -> Result<BTreeSet<P
.keys() .keys()
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub enum DependencyKind {
Exists,
Running,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)] #[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", tag = "kind")] #[serde(rename_all = "camelCase", tag = "kind")]
#[serde(rename_all_fields = "camelCase")] #[serde(rename_all_fields = "camelCase")]
@@ -287,8 +279,7 @@ pub struct CheckDependenciesResult {
satisfies: BTreeSet<VersionString>, satisfies: BTreeSet<VersionString>,
is_running: bool, is_running: bool,
tasks: BTreeMap<ReplayId, TaskEntry>, tasks: BTreeMap<ReplayId, TaskEntry>,
#[ts(as = "BTreeMap::<HealthCheckId, NamedHealthCheckResult>")] health_checks: BTreeMap<HealthCheckId, NamedHealthCheckResult>,
health_checks: OrdMap<HealthCheckId, NamedHealthCheckResult>,
} }
pub async fn check_dependencies( pub async fn check_dependencies(
context: EffectContext, context: EffectContext,
@@ -336,14 +327,12 @@ pub async fn check_dependencies(
let installed_version = manifest.as_version().de()?.into_version(); let installed_version = manifest.as_version().de()?.into_version();
let satisfies = manifest.as_satisfies().de()?; let satisfies = manifest.as_satisfies().de()?;
let installed_version = Some(installed_version.clone().into()); let installed_version = Some(installed_version.clone().into());
let is_installed = true; let is_running = package
let status = package.as_status().de()?; .as_status_info()
let is_running = if is_installed { .as_started()
status.running() .transpose_ref()
} else { .is_some();
false let health_checks = package.as_status_info().as_health().de()?;
};
let health_checks = status.health().cloned().unwrap_or_default();
let tasks = tasks let tasks = tasks
.iter() .iter()
.filter(|(_, v)| v.task.package_id == package_id) .filter(|(_, v)| v.task.package_id == package_id)

View File

@@ -1,7 +1,6 @@
use models::HealthCheckId; use models::HealthCheckId;
use crate::service::effects::prelude::*; use crate::service::effects::prelude::*;
use crate::status::MainStatus;
use crate::status::health_check::NamedHealthCheckResult; use crate::status::health_check::NamedHealthCheckResult;
#[derive(Debug, Clone, Serialize, Deserialize, TS)] #[derive(Debug, Clone, Serialize, Deserialize, TS)]
@@ -28,16 +27,9 @@ pub async fn set_health(
.as_package_data_mut() .as_package_data_mut()
.as_idx_mut(package_id) .as_idx_mut(package_id)
.or_not_found(package_id)? .or_not_found(package_id)?
.as_status_mut() .as_status_info_mut()
.mutate(|main| { .as_health_mut()
match main { .insert(&id, &result)
MainStatus::Running { health, .. } | MainStatus::Starting { health } => {
health.insert(id, result);
}
_ => (),
}
Ok(())
})
}) })
.await .await
.result?; .result?;

View File

@@ -11,14 +11,14 @@ use crate::service::effects::prelude::*;
use crate::service::persistent_container::Subcontainer; use crate::service::persistent_container::Subcontainer;
use crate::util::Invoke; use crate::util::Invoke;
#[cfg(feature = "cli-container")] #[cfg(any(feature = "cli-container", feature = "startd"))]
mod sync; mod sync;
#[cfg(not(feature = "cli-container"))] #[cfg(not(any(feature = "cli-container", feature = "startd")))]
mod sync_dummy; mod sync_dummy;
pub use sync::*; pub use sync::*;
#[cfg(not(feature = "cli-container"))] #[cfg(not(any(feature = "cli-container", feature = "startd")))]
use sync_dummy as sync; use sync_dummy as sync;
#[derive(Debug, Deserialize, Serialize, Parser, TS)] #[derive(Debug, Deserialize, Serialize, Parser, TS)]
@@ -41,7 +41,7 @@ pub async fn destroy_subcontainer_fs(
.await .await
.remove(&guid) .remove(&guid)
{ {
#[cfg(feature = "container-runtime")] #[cfg(feature = "startd")]
if tokio::fs::metadata(overlay.overlay.path().join("proc/1")) if tokio::fs::metadata(overlay.overlay.path().join("proc/1"))
.await .await
.is_ok() .is_ok()

View File

@@ -9,33 +9,31 @@ use std::sync::{Arc, Weak};
use std::time::Duration; use std::time::Duration;
use axum::extract::ws::{Utf8Bytes, WebSocket}; use axum::extract::ws::{Utf8Bytes, WebSocket};
use chrono::{DateTime, Utc};
use clap::Parser; use clap::Parser;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use futures::stream::FusedStream; use futures::stream::FusedStream;
use futures::{FutureExt, SinkExt, StreamExt, TryStreamExt}; use futures::{FutureExt, SinkExt, StreamExt, TryStreamExt};
use helpers::NonDetachingJoinHandle; use helpers::{AtomicFile, NonDetachingJoinHandle};
use imbl_value::{InternedString, json}; use imbl_value::{InternedString, json};
use itertools::Itertools; use itertools::Itertools;
use models::{ActionId, HostId, ImageId, PackageId}; use models::{ActionId, HostId, ImageId, PackageId};
use nix::sys::signal::Signal; use nix::sys::signal::Signal;
use persistent_container::{PersistentContainer, Subcontainer}; use persistent_container::{PersistentContainer, Subcontainer};
use rpc_toolkit::{HandlerArgs, HandlerFor}; use rpc_toolkit::HandlerArgs;
use rpc_toolkit::yajrc::RpcError;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use service_actor::ServiceActor; use service_actor::ServiceActor;
use start_stop::StartStop;
use termion::raw::IntoRawMode; use termion::raw::IntoRawMode;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::Notify;
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use ts_rs::TS; use ts_rs::TS;
use url::Url; use url::Url;
use crate::context::{CliContext, RpcContext}; use crate::context::{CliContext, RpcContext};
use crate::db::model::package::{ use crate::db::model::package::{
InstalledState, ManifestPreference, PackageDataEntry, PackageState, PackageStateMatchModelRef, InstalledState, ManifestPreference, PackageState, PackageStateMatchModelRef, TaskSeverity,
TaskSeverity, UpdatingState, UpdatingState,
}; };
use crate::disk::mount::filesystem::ReadOnly; use crate::disk::mount::filesystem::ReadOnly;
use crate::disk::mount::guard::{GenericMountGuard, MountGuard}; use crate::disk::mount::guard::{GenericMountGuard, MountGuard};
@@ -49,15 +47,15 @@ use crate::service::service_map::InstallProgressHandles;
use crate::service::uninstall::cleanup; use crate::service::uninstall::cleanup;
use crate::util::Never; use crate::util::Never;
use crate::util::actor::concurrent::ConcurrentActor; use crate::util::actor::concurrent::ConcurrentActor;
use crate::util::io::{AsyncReadStream, TermSize, create_file, delete_file}; use crate::util::io::{AsyncReadStream, TermSize, delete_file};
use crate::util::net::WebSocketExt; use crate::util::net::WebSocketExt;
use crate::util::serde::Pem; use crate::util::serde::Pem;
use crate::util::sync::SyncMutex;
use crate::volume::data_dir; use crate::volume::data_dir;
use crate::{CAP_1_KiB, DATA_DIR}; use crate::{CAP_1_KiB, DATA_DIR};
pub mod action; pub mod action;
pub mod cli; pub mod cli;
mod control;
pub mod effects; pub mod effects;
pub mod persistent_container; pub mod persistent_container;
mod rpc; mod rpc;
@@ -66,7 +64,6 @@ pub mod service_map;
pub mod start_stop; pub mod start_stop;
mod transition; mod transition;
pub mod uninstall; pub mod uninstall;
mod util;
pub use service_map::ServiceMap; pub use service_map::ServiceMap;
@@ -222,24 +219,17 @@ impl Service {
async fn new( async fn new(
ctx: RpcContext, ctx: RpcContext,
s9pk: S9pk, s9pk: S9pk,
start: StartStop,
procedure_id: Guid, procedure_id: Guid,
init_kind: Option<InitKind>, init_kind: Option<InitKind>,
recovery_source: Option<impl GenericMountGuard>, recovery_source: Option<impl GenericMountGuard>,
) -> Result<ServiceRef, Error> { ) -> Result<ServiceRef, Error> {
let id = s9pk.as_manifest().id.clone(); let id = s9pk.as_manifest().id.clone();
let persistent_container = PersistentContainer::new( let persistent_container = PersistentContainer::new(&ctx, s9pk).await?;
&ctx, s9pk,
start,
// desired_state.subscribe(),
// temp_desired_state.subscribe(),
)
.await?;
let seed = Arc::new(ServiceActorSeed { let seed = Arc::new(ServiceActorSeed {
id, id,
persistent_container, persistent_container,
ctx, ctx,
synchronized: Arc::new(Notify::new()), backup: SyncMutex::new(None),
}); });
let service: ServiceRef = Self { let service: ServiceRef = Self {
actor: ConcurrentActor::new(ServiceActor(seed.clone())), actor: ConcurrentActor::new(ServiceActor(seed.clone())),
@@ -279,19 +269,14 @@ impl Service {
) -> Result<Option<ServiceRef>, Error> { ) -> Result<Option<ServiceRef>, Error> {
let handle_installed = { let handle_installed = {
let ctx = ctx.clone(); let ctx = ctx.clone();
move |s9pk: S9pk, i: Model<PackageDataEntry>| async move { move |s9pk: S9pk| async move {
for volume_id in &s9pk.as_manifest().volumes { for volume_id in &s9pk.as_manifest().volumes {
let path = data_dir(DATA_DIR, &s9pk.as_manifest().id, volume_id); let path = data_dir(DATA_DIR, &s9pk.as_manifest().id, volume_id);
if tokio::fs::metadata(&path).await.is_err() { if tokio::fs::metadata(&path).await.is_err() {
tokio::fs::create_dir_all(&path).await?; tokio::fs::create_dir_all(&path).await?;
} }
} }
let start_stop = if i.as_status().de()?.running() { Self::new(ctx, s9pk, Guid::new(), None, None::<MountGuard>)
StartStop::Start
} else {
StartStop::Stop
};
Self::new(ctx, s9pk, start_stop, Guid::new(), None, None::<MountGuard>)
.await .await
.map(Some) .map(Some)
} }
@@ -319,7 +304,7 @@ impl Service {
s9pk, s9pk,
&s9pk_path, &s9pk_path,
&None, &None,
None, InitKind::Install,
None::<Never>, None::<Never>,
None, None,
) )
@@ -353,7 +338,7 @@ impl Service {
s9pk, s9pk,
&s9pk_path, &s9pk_path,
&None, &None,
Some(entry.as_status().de()?.run_state()), InitKind::Update,
None::<Never>, None::<Never>,
None, None,
) )
@@ -388,7 +373,7 @@ impl Service {
} }
}) })
.await.result?; .await.result?;
handle_installed(s9pk, entry).await handle_installed(s9pk).await
} }
PackageStateMatchModelRef::Removing(_) | PackageStateMatchModelRef::Restoring(_) => { PackageStateMatchModelRef::Removing(_) | PackageStateMatchModelRef::Restoring(_) => {
if let Ok(s9pk) = S9pk::open(s9pk_path, Some(id)).await.map_err(|e| { if let Ok(s9pk) = S9pk::open(s9pk_path, Some(id)).await.map_err(|e| {
@@ -396,11 +381,7 @@ impl Service {
tracing::debug!("{e:?}") tracing::debug!("{e:?}")
}) { }) {
let err_state = |e: Error| async move { let err_state = |e: Error| async move {
let state = crate::status::MainStatus::Error { let e = e.into();
on_rebuild: StartStop::Stop,
message: e.to_string(),
debug: Some(format!("{e:?}")),
};
ctx.db ctx.db
.mutate(move |db| { .mutate(move |db| {
if let Some(pde) = if let Some(pde) =
@@ -413,22 +394,14 @@ impl Service {
.clone(), .clone(),
})) }))
})?; })?;
pde.as_status_mut().ser(&state)?; pde.as_status_info_mut().as_error_mut().ser(&Some(e))?;
} }
Ok(()) Ok(())
}) })
.await .await
.result .result
}; };
match Self::new( match Self::new(ctx.clone(), s9pk, Guid::new(), None, None::<MountGuard>).await
ctx.clone(),
s9pk,
StartStop::Stop,
Guid::new(),
None,
None::<MountGuard>,
)
.await
{ {
Ok(service) => match async { Ok(service) => match async {
service service
@@ -463,7 +436,7 @@ impl Service {
Ok(None) Ok(None)
} }
PackageStateMatchModelRef::Installed(_) => { PackageStateMatchModelRef::Installed(_) => {
handle_installed(S9pk::open(s9pk_path, Some(id)).await?, entry).await handle_installed(S9pk::open(s9pk_path, Some(id)).await?).await
} }
PackageStateMatchModelRef::Error(e) => Err(Error::new( PackageStateMatchModelRef::Error(e) => Err(Error::new(
eyre!("Failed to parse PackageDataEntry, found {e:?}"), eyre!("Failed to parse PackageDataEntry, found {e:?}"),
@@ -478,7 +451,7 @@ impl Service {
s9pk: S9pk, s9pk: S9pk,
s9pk_path: &PathBuf, s9pk_path: &PathBuf,
registry: &Option<Url>, registry: &Option<Url>,
prev_state: Option<StartStop>, kind: InitKind,
recovery_source: Option<impl GenericMountGuard>, recovery_source: Option<impl GenericMountGuard>,
progress: Option<InstallProgressHandles>, progress: Option<InstallProgressHandles>,
) -> Result<ServiceRef, Error> { ) -> Result<ServiceRef, Error> {
@@ -489,15 +462,8 @@ impl Service {
let service = Self::new( let service = Self::new(
ctx.clone(), ctx.clone(),
s9pk, s9pk,
StartStop::Stop,
procedure_id.clone(), procedure_id.clone(),
Some(if recovery_source.is_some() { Some(kind),
InitKind::Restore
} else if prev_state.is_some() {
InitKind::Update
} else {
InitKind::Install
}),
recovery_source, recovery_source,
) )
.await?; .await?;
@@ -550,8 +516,7 @@ impl Service {
} }
} }
} }
let has_critical = ctx ctx.db
.db
.mutate(|db| { .mutate(|db| {
for (action_id, input) in &action_input { for (action_id, input) in &action_input {
for (_, pde) in db.as_public_mut().as_package_data_mut().as_entries_mut()? { for (_, pde) in db.as_public_mut().as_package_data_mut().as_entries_mut()? {
@@ -566,13 +531,15 @@ impl Service {
.as_idx_mut(&manifest.id) .as_idx_mut(&manifest.id)
.or_not_found(&manifest.id)?; .or_not_found(&manifest.id)?;
let actions = entry.as_actions().keys()?; let actions = entry.as_actions().keys()?;
let has_critical = entry.as_tasks_mut().mutate(|t| { if entry.as_tasks_mut().mutate(|t| {
t.retain(|_, v| { t.retain(|_, v| {
v.task.package_id != manifest.id || actions.contains(&v.task.action_id) v.task.package_id != manifest.id || actions.contains(&v.task.action_id)
}); });
Ok(t.iter() Ok(t.iter()
.any(|(_, t)| t.active && t.task.severity == TaskSeverity::Critical)) .any(|(_, t)| t.active && t.task.severity == TaskSeverity::Critical))
})?; })? {
entry.as_status_info_mut().stop()?;
}
entry entry
.as_state_info_mut() .as_state_info_mut()
.ser(&PackageState::Installed(InstalledState { manifest }))?; .ser(&PackageState::Installed(InstalledState { manifest }))?;
@@ -581,38 +548,42 @@ impl Service {
entry.as_icon_mut().ser(&icon)?; entry.as_icon_mut().ser(&icon)?;
entry.as_registry_mut().ser(registry)?; entry.as_registry_mut().ser(registry)?;
Ok(has_critical) Ok(())
}) })
.await .await
.result?; .result?;
if prev_state == Some(StartStop::Start) && !has_critical {
service.start(procedure_id).await?;
}
Ok(service) Ok(service)
} }
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn backup(&self, guard: impl GenericMountGuard) -> Result<(), Error> { pub async fn backup(&self, guard: impl GenericMountGuard) -> Result<(), Error> {
let id = &self.seed.id; let id = &self.seed.id;
let mut file = create_file(guard.path().join(id).with_extension("s9pk")).await?; let mut file = AtomicFile::new(guard.path().join(id).with_extension("s9pk"), None::<&str>)
.await
.with_kind(ErrorKind::Filesystem)?;
self.seed self.seed
.persistent_container .persistent_container
.s9pk .s9pk
.clone() .clone()
.serialize(&mut file, true) .serialize(&mut *file, true)
.await?;
drop(file);
self.actor
.send(
Guid::new(),
transition::backup::Backup {
path: guard.path().join("data"),
},
)
.await??
.await?; .await?;
file.save().await.with_kind(ErrorKind::Filesystem)?;
// TODO: reverify?
self.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_status_info_mut()
.as_desired_mut()
.map_mutate(|s| Ok(s.backing_up()))
})
.await
.result?;
Ok(()) Ok(())
} }
@@ -661,41 +632,12 @@ impl Service {
} }
} }
#[derive(Debug, Clone)]
pub struct RunningStatus {
started: DateTime<Utc>,
}
struct ServiceActorSeed { struct ServiceActorSeed {
ctx: RpcContext, ctx: RpcContext,
id: PackageId, id: PackageId,
/// Needed to interact with the container for the service /// Needed to interact with the container for the service
persistent_container: PersistentContainer, persistent_container: PersistentContainer,
/// This is notified every time the background job created in ServiceActor::init responds to a change backup: SyncMutex<Option<BoxFuture<'static, Result<(), RpcError>>>>,
synchronized: Arc<Notify>,
}
impl ServiceActorSeed {
/// Used to indicate that we have finished the task of starting the service
pub fn started(&self) {
self.persistent_container.state.send_modify(|state| {
state.running_status =
Some(
state
.running_status
.take()
.unwrap_or_else(|| RunningStatus {
started: Utc::now(),
}),
);
});
}
/// Used to indicate that we have finished the task of stopping the service
pub fn stopped(&self) {
self.persistent_container.state.send_modify(|state| {
state.running_status = None;
});
}
} }
#[derive(Deserialize, Serialize, Parser, TS)] #[derive(Deserialize, Serialize, Parser, TS)]

View File

@@ -34,9 +34,7 @@ use crate::service::effects::handler;
use crate::service::rpc::{ use crate::service::rpc::{
CallbackHandle, CallbackId, CallbackParams, ExitParams, InitKind, InitParams, CallbackHandle, CallbackId, CallbackParams, ExitParams, InitKind, InitParams,
}; };
use crate::service::start_stop::StartStop; use crate::service::{Service, rpc};
use crate::service::transition::{TransitionKind, TransitionState};
use crate::service::{RunningStatus, Service, rpc};
use crate::util::Invoke; use crate::util::Invoke;
use crate::util::io::create_file; use crate::util::io::create_file;
use crate::util::rpc_client::UnixRpcClient; use crate::util::rpc_client::UnixRpcClient;
@@ -49,41 +47,15 @@ const RPC_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
pub struct ServiceState { pub struct ServiceState {
// indicates whether the service container runtime has been initialized yet // indicates whether the service container runtime has been initialized yet
pub(super) rt_initialized: bool, pub(super) rt_initialized: bool,
// This contains the start time and health check information for when the service is running. Note: Will be overwritting to the db,
pub(super) running_status: Option<RunningStatus>,
// This tracks references to callbacks registered by the running service: // This tracks references to callbacks registered by the running service:
pub(super) callbacks: BTreeSet<Arc<CallbackId>>, pub(super) callbacks: BTreeSet<Arc<CallbackId>>,
/// Setting this value causes the service actor to try to bring the service to the specified state. This is done in the background job created in ServiceActor::init
pub(super) desired_state: StartStop,
/// Override the current desired state for the service during a transition (this is protected by a guard that sets this value to null on drop)
pub(super) temp_desired_state: Option<StartStop>,
/// This represents a currently running task that affects the service's shown state, such as BackingUp or Restarting.
pub(super) transition_state: Option<TransitionState>,
}
#[derive(Debug)]
pub struct ServiceStateKinds {
pub transition_state: Option<TransitionKind>,
pub running_status: Option<RunningStatus>,
pub desired_state: StartStop,
} }
impl ServiceState { impl ServiceState {
pub fn new(desired_state: StartStop) -> Self { pub fn new() -> Self {
Self { Self {
rt_initialized: false, rt_initialized: false,
running_status: Default::default(),
callbacks: Default::default(), callbacks: Default::default(),
temp_desired_state: Default::default(),
transition_state: Default::default(),
desired_state,
}
}
pub fn kinds(&self) -> ServiceStateKinds {
ServiceStateKinds {
transition_state: self.transition_state.as_ref().map(|x| x.kind()),
desired_state: self.temp_desired_state.unwrap_or(self.desired_state),
running_status: self.running_status.clone(),
} }
} }
} }
@@ -117,7 +89,7 @@ pub struct PersistentContainer {
impl PersistentContainer { impl PersistentContainer {
#[instrument(skip_all)] #[instrument(skip_all)]
pub async fn new(ctx: &RpcContext, s9pk: S9pk, start: StartStop) -> Result<Self, Error> { pub async fn new(ctx: &RpcContext, s9pk: S9pk) -> Result<Self, Error> {
let lxc_container = ctx let lxc_container = ctx
.lxc_manager .lxc_manager
.create( .create(
@@ -305,7 +277,7 @@ impl PersistentContainer {
assets, assets,
images, images,
subcontainers: Arc::new(Mutex::new(BTreeMap::new())), subcontainers: Arc::new(Mutex::new(BTreeMap::new())),
state: Arc::new(watch::channel(ServiceState::new(start)).0), state: Arc::new(watch::channel(ServiceState::new()).0),
net_service, net_service,
destroyed: false, destroyed: false,
}) })

View File

@@ -203,11 +203,6 @@ impl serde::Serialize for Sandbox {
pub struct CallbackId(u64); pub struct CallbackId(u64);
impl CallbackId { impl CallbackId {
pub fn register(self, container: &PersistentContainer) -> CallbackHandle { pub fn register(self, container: &PersistentContainer) -> CallbackHandle {
crate::dbg!(eyre!(
"callback {} registered for {}",
self.0,
container.s9pk.as_manifest().id
));
let this = Arc::new(self); let this = Arc::new(self);
let res = Arc::downgrade(&this); let res = Arc::downgrade(&this);
container container

View File

@@ -1,17 +1,14 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use futures::FutureExt;
use futures::future::{BoxFuture, Either};
use imbl::vector; use imbl::vector;
use patch_db::TypedDbWatch;
use super::ServiceActorSeed; use super::ServiceActorSeed;
use super::start_stop::StartStop;
use crate::prelude::*; use crate::prelude::*;
use crate::service::SYNC_RETRY_COOLDOWN_SECONDS; use crate::service::SYNC_RETRY_COOLDOWN_SECONDS;
use crate::service::persistent_container::ServiceStateKinds; use crate::service::transition::{Transition, TransitionKind};
use crate::service::transition::TransitionKind; use crate::status::{DesiredStatus, StatusInfo};
use crate::status::MainStatus;
use crate::util::actor::Actor; use crate::util::actor::Actor;
use crate::util::actor::background::BackgroundJobQueue; use crate::util::actor::background::BackgroundJobQueue;
@@ -21,159 +18,123 @@ pub(super) struct ServiceActor(pub(super) Arc<ServiceActorSeed>);
impl Actor for ServiceActor { impl Actor for ServiceActor {
fn init(&mut self, jobs: &BackgroundJobQueue) { fn init(&mut self, jobs: &BackgroundJobQueue) {
let seed = self.0.clone(); let seed = self.0.clone();
let mut current = seed.persistent_container.state.subscribe(); let mut state = seed.persistent_container.state.subscribe();
let initialized = async move { state.wait_for(|s| s.rt_initialized).await.map(|_| ()) };
jobs.add_job(async move { jobs.add_job(async move {
let _ = current.wait_for(|s| s.rt_initialized).await; if initialized.await.is_err() {
let mut start_stop_task: Option<Either<_, _>> = None; return;
}
let mut watch = seed
.ctx
.db
.watch(
format!("/public/packageData/{}/statusInfo", seed.id)
.parse()
.unwrap(),
) // TODO: typed pointers
.await
.typed::<StatusInfo>();
let mut transition: Option<Transition> = None;
loop { loop {
let wait = match service_actor_loop(&current, &seed, &mut start_stop_task).await { let res = service_actor_loop(&mut watch, &seed, &mut transition).await;
Ok(()) => Either::Right(current.changed().then(|res| async move { let wait = async {
match res { if let Err(e) = async {
Ok(()) => (), res?;
Err(_) => futures::future::pending().await, watch.changed().await?;
} Ok::<_, Error>(())
})), }
Err(e) => { .await
{
tracing::error!("error synchronizing state of service: {e}"); tracing::error!("error synchronizing state of service: {e}");
tracing::debug!("{e:?}"); tracing::debug!("{e:?}");
seed.synchronized.notify_waiters();
tracing::error!("Retrying in {}s...", SYNC_RETRY_COOLDOWN_SECONDS); tracing::error!("Retrying in {}s...", SYNC_RETRY_COOLDOWN_SECONDS);
Either::Left(tokio::time::sleep(Duration::from_secs( tokio::time::timeout(
SYNC_RETRY_COOLDOWN_SECONDS, Duration::from_secs(SYNC_RETRY_COOLDOWN_SECONDS),
))) async {
watch.changed().await.log_err();
},
)
.await
.ok();
} }
}; };
tokio::pin!(wait); tokio::pin!(wait);
let start_stop_handler = async { let transition_handler = async {
match &mut start_stop_task { match &mut transition {
Some(task) => { Some(Transition { future, .. }) => {
let err = task.await.log_err().is_none(); // TODO: ideally this error should be sent to service logs let err = future.await.log_err().is_none(); // TODO: ideally this error should be sent to service logs
start_stop_task.take(); transition.take();
if err { if err {
tokio::time::sleep(Duration::from_secs( tokio::time::sleep(Duration::from_secs(
SYNC_RETRY_COOLDOWN_SECONDS, SYNC_RETRY_COOLDOWN_SECONDS,
)) ))
.await; .await;
} else {
futures::future::pending().await
} }
} }
_ => futures::future::pending().await, _ => futures::future::pending().await,
} }
}; };
tokio::pin!(start_stop_handler); tokio::pin!(transition_handler);
futures::future::select(wait, start_stop_handler).await; futures::future::select(wait, transition_handler).await;
} }
}); });
} }
} }
async fn service_actor_loop<'a>( async fn service_actor_loop<'a>(
current: &tokio::sync::watch::Receiver<super::persistent_container::ServiceState>, watch: &mut TypedDbWatch<StatusInfo>,
seed: &'a Arc<ServiceActorSeed>, seed: &'a Arc<ServiceActorSeed>,
start_stop_task: &mut Option< transition: &mut Option<Transition<'a>>,
Either<BoxFuture<'a, Result<(), Error>>, BoxFuture<'a, Result<(), Error>>>,
>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let id = &seed.id; let id = &seed.id;
let kinds = current.borrow().kinds(); let status_model = watch.peek_and_mark_seen()?;
let status = status_model.de()?;
let major_changes_state = seed if let Some(callbacks) = seed.ctx.callbacks.get_status(id) {
.ctx callbacks
.db .call(vector![patch_db::ModelExt::into_value(status_model)])
.mutate(|d| { .await?;
if let Some(i) = d.as_public_mut().as_package_data_mut().as_idx_mut(&id) {
let previous = i.as_status().de()?;
let main_status = match &kinds {
ServiceStateKinds {
transition_state: Some(TransitionKind::Restarting),
..
} => MainStatus::Restarting,
ServiceStateKinds {
transition_state: Some(TransitionKind::BackingUp),
..
} => previous.backing_up(),
ServiceStateKinds {
running_status: Some(status),
desired_state: StartStop::Start,
..
} => MainStatus::Running {
started: status.started,
health: previous.health().cloned().unwrap_or_default(),
},
ServiceStateKinds {
running_status: None,
desired_state: StartStop::Start,
..
} => MainStatus::Starting {
health: previous.health().cloned().unwrap_or_default(),
},
ServiceStateKinds {
running_status: Some(_),
desired_state: StartStop::Stop,
..
} => MainStatus::Stopping,
ServiceStateKinds {
running_status: None,
desired_state: StartStop::Stop,
..
} => MainStatus::Stopped,
};
i.as_status_mut().ser(&main_status)?;
return Ok(previous
.major_changes(&main_status)
.then_some((previous, main_status)));
}
Ok(None)
})
.await
.result?;
if let Some((previous, new_state)) = major_changes_state {
if let Some(callbacks) = seed.ctx.callbacks.get_status(id) {
callbacks
.call(vector![to_value(&previous)?, to_value(&new_state)?])
.await?;
}
} }
seed.synchronized.notify_waiters();
match kinds { match status {
ServiceStateKinds { StatusInfo {
running_status: None, desired: DesiredStatus::Running | DesiredStatus::Restarting,
desired_state: StartStop::Start, started: None,
.. ..
} => { } => {
let task = start_stop_task let task = transition
.take() .take()
.filter(|task| matches!(task, Either::Right(_))); .filter(|task| task.kind == TransitionKind::Starting);
*start_stop_task = Some( *transition = task.or_else(|| Some(seed.start()));
task.unwrap_or_else(|| Either::Right(seed.persistent_container.start().boxed())),
);
} }
ServiceStateKinds { StatusInfo {
running_status: Some(_), desired:
desired_state: StartStop::Stop, DesiredStatus::Stopped | DesiredStatus::Restarting | DesiredStatus::BackingUp { .. },
started: Some(_),
.. ..
} => { } => {
let task = start_stop_task let task = transition
.take() .take()
.filter(|task| matches!(task, Either::Left(_))); .filter(|task| task.kind == TransitionKind::Stopping);
*start_stop_task = Some(task.unwrap_or_else(|| { *transition = task.or_else(|| Some(seed.stop()));
Either::Left( }
async { StatusInfo {
seed.persistent_container.stop().await?; desired: DesiredStatus::BackingUp { .. },
seed.persistent_container started: None,
.state ..
.send_if_modified(|s| s.running_status.take().is_some()); } => {
Ok::<_, Error>(()) let task = transition
} .take()
.boxed(), .filter(|task| task.kind == TransitionKind::BackingUp);
) *transition = task.or_else(|| Some(seed.backup()));
})); }
_ => {
*transition = None;
} }
_ => (),
}; };
Ok(()) Ok(())
} }

View File

@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -27,11 +28,10 @@ use crate::progress::{FullProgressTracker, PhaseProgressTrackerHandle, ProgressT
use crate::s9pk::S9pk; use crate::s9pk::S9pk;
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::service::rpc::ExitParams; use crate::service::rpc::{ExitParams, InitKind};
use crate::service::start_stop::StartStop;
use crate::service::{LoadDisposition, Service, ServiceRef}; use crate::service::{LoadDisposition, Service, ServiceRef};
use crate::sign::commitment::merkle_archive::MerkleArchiveCommitment; use crate::sign::commitment::merkle_archive::MerkleArchiveCommitment;
use crate::status::MainStatus; use crate::status::{DesiredStatus, StatusInfo};
use crate::util::serde::{Base32, Pem}; use crate::util::serde::{Base32, Pem};
use crate::util::sync::SyncMutex; use crate::util::sync::SyncMutex;
@@ -123,17 +123,7 @@ impl ServiceMap {
ctx.db ctx.db
.mutate(|db| { .mutate(|db| {
if let Some(pde) = db.as_public_mut().as_package_data_mut().as_idx_mut(id) { if let Some(pde) = db.as_public_mut().as_package_data_mut().as_idx_mut(id) {
pde.as_status_mut().map_mutate(|s| { pde.as_status_info_mut().as_error_mut().ser(&Some(e))?;
Ok(MainStatus::Error {
on_rebuild: if s.running() {
StartStop::Start
} else {
StartStop::Stop
},
message: e.details,
debug: Some(e.debug),
})
})?;
} }
Ok(()) Ok(())
}) })
@@ -242,7 +232,12 @@ impl ServiceMap {
PackageState::Installing(installing) PackageState::Installing(installing)
}, },
s9pk: installed_path, s9pk: installed_path,
status: MainStatus::Stopped, status_info: StatusInfo {
error: None,
health: BTreeMap::new(),
started: None,
desired: DesiredStatus::Stopped,
},
registry, registry,
developer_key: Pem::new(developer_key), developer_key: Pem::new(developer_key),
icon, icon,
@@ -333,15 +328,9 @@ impl ServiceMap {
next_can_migrate_from.clone(), next_can_migrate_from.clone(),
)) ))
}; };
let run_state = service
.seed
.persistent_container
.state
.borrow()
.desired_state;
let cleanup = service.uninstall(uninit, false, false).await?; let cleanup = service.uninstall(uninit, false, false).await?;
progress.complete(); progress.complete();
Some((run_state, cleanup)) Some(cleanup)
} else { } else {
None None
}; };
@@ -350,7 +339,13 @@ impl ServiceMap {
s9pk, s9pk,
&installed_path, &installed_path,
&registry, &registry,
prev.as_ref().map(|(s, _)| *s), if recovery_source.is_some() {
InitKind::Restore
} else if prev.is_some() {
InitKind::Update
} else {
InitKind::Install
},
recovery_source, recovery_source,
Some(InstallProgressHandles { Some(InstallProgressHandles {
finalization_progress, finalization_progress,
@@ -360,7 +355,7 @@ impl ServiceMap {
.await?; .await?;
*service = Some(new_service.into()); *service = Some(new_service.into());
if let Some((_, cleanup)) = prev { if let Some(cleanup) = prev {
cleanup.await?; cleanup.await?;
} }

View File

@@ -9,23 +9,7 @@ pub enum StartStop {
} }
impl StartStop { impl StartStop {
pub(crate) fn is_start(&self) -> bool { pub fn is_start(&self) -> bool {
matches!(self, StartStop::Start) matches!(self, StartStop::Start)
} }
} }
// impl From<MainStatus> for StartStop {
// fn from(value: MainStatus) -> Self {
// match value {
// MainStatus::Stopped => StartStop::Stop,
// MainStatus::Restoring => StartStop::Stop,
// MainStatus::Restarting => StartStop::Start,
// MainStatus::Stopping { .. } => StartStop::Stop,
// MainStatus::Starting => StartStop::Start,
// MainStatus::Running {
// started: _,
// health: _,
// } => StartStop::Start,
// MainStatus::BackingUp { on_complete } => on_complete,
// }
// }
// }

View File

@@ -1,22 +1,66 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use futures::FutureExt;
use futures::future::BoxFuture; use futures::future::BoxFuture;
use futures::{FutureExt, TryFutureExt};
use models::ProcedureName; use models::ProcedureName;
use rpc_toolkit::yajrc::RpcError;
use super::TempDesiredRestore;
use crate::disk::mount::filesystem::ReadWrite; use crate::disk::mount::filesystem::ReadWrite;
use crate::prelude::*; use crate::prelude::*;
use crate::rpc_continuations::Guid; use crate::rpc_continuations::Guid;
use crate::service::ServiceActor;
use crate::service::action::GetActionInput; use crate::service::action::GetActionInput;
use crate::service::transition::{TransitionKind, TransitionState}; use crate::service::start_stop::StartStop;
use crate::service::transition::{Transition, TransitionKind};
use crate::service::{ServiceActor, ServiceActorSeed};
use crate::status::DesiredStatus;
use crate::util::actor::background::BackgroundJobQueue; use crate::util::actor::background::BackgroundJobQueue;
use crate::util::actor::{ConflictBuilder, Handler}; use crate::util::actor::{ConflictBuilder, Handler};
use crate::util::future::RemoteCancellable;
use crate::util::serde::NoOutput; use crate::util::serde::NoOutput;
impl ServiceActorSeed {
pub fn backup(&self) -> Transition<'_> {
Transition {
kind: TransitionKind::BackingUp,
future: async {
let res = if let Some(fut) = self.backup.replace(None) {
fut.await.map_err(Error::from)
} else {
Err(Error::new(
eyre!("No backup to resume"),
ErrorKind::Cancelled,
))
};
let id = &self.id;
self.ctx
.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(id)
.or_not_found(id)?
.as_status_info_mut()
.as_desired_mut()
.map_mutate(|s| {
Ok(match s {
DesiredStatus::BackingUp {
on_complete: StartStop::Start,
} => DesiredStatus::Running,
DesiredStatus::BackingUp {
on_complete: StartStop::Stop,
} => DesiredStatus::Stopped,
x => x,
})
})
})
.await
.result?;
res
}
.boxed(),
}
}
}
pub(in crate::service) struct Backup { pub(in crate::service) struct Backup {
pub path: PathBuf, pub path: PathBuf,
} }
@@ -28,63 +72,31 @@ impl Handler<Backup> for ServiceActor {
async fn handle( async fn handle(
&mut self, &mut self,
id: Guid, id: Guid,
backup: Backup, Backup { path }: Backup,
jobs: &BackgroundJobQueue, _: &BackgroundJobQueue,
) -> Self::Response { ) -> Self::Response {
// So Need a handle to just a single field in the state
let temp: TempDesiredRestore = TempDesiredRestore::new(&self.0.persistent_container.state);
let mut current = self.0.persistent_container.state.subscribe();
let path = backup.path.clone();
let seed = self.0.clone(); let seed = self.0.clone();
let transition = RemoteCancellable::new(async move { let transition = async move {
temp.stop(); async {
current let backup_guard = seed
.wait_for(|s| s.running_status.is_none()) .persistent_container
.await .mount_backup(path, ReadWrite)
.with_kind(ErrorKind::Unknown)?; .await?;
seed.persistent_container
.execute::<NoOutput>(id, ProcedureName::CreateBackup, Value::Null, None)
.await?;
backup_guard.unmount(true).await?;
let backup_guard = seed Ok::<_, Error>(())
.persistent_container
.mount_backup(path, ReadWrite)
.await?;
seed.persistent_container
.execute::<NoOutput>(id, ProcedureName::CreateBackup, Value::Null, None)
.await?;
backup_guard.unmount(true).await?;
if temp.restore().is_start() {
current
.wait_for(|s| s.running_status.is_some())
.await
.with_kind(ErrorKind::Unknown)?;
} }
drop(temp); .await
Ok::<_, Arc<Error>>(()) .map_err(RpcError::from)
});
let cancel_handle = transition.cancellation_handle();
let transition = transition.shared();
let job_transition = transition.clone();
jobs.add_job(job_transition.map(|_| ()));
let mut old = None;
self.0.persistent_container.state.send_modify(|s| {
old = std::mem::replace(
&mut s.transition_state,
Some(TransitionState {
kind: TransitionKind::BackingUp,
cancel_handle,
}),
)
});
if let Some(t) = old {
t.abort().await;
} }
Ok(transition .shared();
.map(|r| {
r.ok_or_else(|| Error::new(eyre!("Backup canceled"), ErrorKind::Cancelled))? self.0.backup.replace(Some(transition.clone().boxed()));
.map_err(|e| e.clone_output())
}) Ok(transition.map_err(Error::from).boxed())
.boxed())
} }
} }

View File

@@ -1,91 +1,42 @@
use std::sync::Arc; use futures::FutureExt;
use futures::future::BoxFuture;
use futures::{Future, FutureExt}; use crate::prelude::*;
use tokio::sync::watch; use crate::service::ServiceActorSeed;
use super::persistent_container::ServiceState;
use crate::service::start_stop::StartStop;
use crate::util::actor::background::BackgroundJobQueue;
use crate::util::future::{CancellationHandle, RemoteCancellable};
pub mod backup; pub mod backup;
pub mod restart;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum TransitionKind { pub enum TransitionKind {
BackingUp, BackingUp,
Restarting, Starting,
Stopping,
} }
/// Used only in the manager/mod and is used to keep track of the state of the manager during the pub struct Transition<'a> {
/// transitional states pub kind: TransitionKind,
pub struct TransitionState { pub future: BoxFuture<'a, Result<(), Error>>,
cancel_handle: CancellationHandle,
kind: TransitionKind,
} }
impl ::std::fmt::Debug for TransitionState { impl<'a> ::std::fmt::Debug for Transition<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TransitionState") f.debug_struct("Transition")
.field("kind", &self.kind) .field("kind", &self.kind)
.finish_non_exhaustive() .finish_non_exhaustive()
} }
} }
impl TransitionState { impl ServiceActorSeed {
pub fn kind(&self) -> TransitionKind { pub fn start(&self) -> Transition<'_> {
self.kind Transition {
kind: TransitionKind::Starting,
future: self.persistent_container.start().boxed(),
}
} }
pub async fn abort(mut self) {
self.cancel_handle.cancel_and_wait().await pub fn stop(&self) -> Transition<'_> {
} Transition {
fn new( kind: TransitionKind::Stopping,
task: impl Future<Output = ()> + Send + 'static, future: self.persistent_container.stop().boxed(),
kind: TransitionKind,
jobs: &BackgroundJobQueue,
) -> Self {
let task = RemoteCancellable::new(task);
let cancel_handle = task.cancellation_handle();
jobs.add_job(task.map(|_| ()));
Self {
cancel_handle,
kind,
} }
} }
} }
impl Drop for TransitionState {
fn drop(&mut self) {
self.cancel_handle.cancel();
}
}
#[derive(Debug, Clone)]
pub struct TempDesiredRestore(pub(super) Arc<watch::Sender<ServiceState>>, StartStop);
impl TempDesiredRestore {
pub fn new(state: &Arc<watch::Sender<ServiceState>>) -> Self {
Self(state.clone(), state.borrow().desired_state)
}
pub fn stop(&self) {
self.0
.send_modify(|s| s.temp_desired_state = Some(StartStop::Stop));
}
pub fn restore(&self) -> StartStop {
let restore_state = self.1;
self.0
.send_modify(|s| s.temp_desired_state = Some(restore_state));
restore_state
}
}
impl Drop for TempDesiredRestore {
fn drop(&mut self) {
self.0.send_modify(|s| {
s.temp_desired_state.take();
s.transition_state.take();
});
}
}
// impl Deref for TempDesiredState {
// type Target = watch::Sender<Option<StartStop>>;
// fn deref(&self) -> &Self::Target {
// &*self.0
// }
// }

View File

@@ -1,83 +0,0 @@
use futures::FutureExt;
use futures::future::BoxFuture;
use super::TempDesiredRestore;
use crate::prelude::*;
use crate::rpc_continuations::Guid;
use crate::service::action::GetActionInput;
use crate::service::transition::{TransitionKind, TransitionState};
use crate::service::{Service, ServiceActor};
use crate::util::actor::background::BackgroundJobQueue;
use crate::util::actor::{ConflictBuilder, Handler};
use crate::util::future::RemoteCancellable;
pub(super) struct Restart;
impl Handler<Restart> for ServiceActor {
type Response = BoxFuture<'static, Option<()>>;
fn conflicts_with(_: &Restart) -> ConflictBuilder<Self> {
ConflictBuilder::everything().except::<GetActionInput>()
}
async fn handle(&mut self, _: Guid, _: Restart, jobs: &BackgroundJobQueue) -> Self::Response {
// So Need a handle to just a single field in the state
let temp = TempDesiredRestore::new(&self.0.persistent_container.state);
let mut current = self.0.persistent_container.state.subscribe();
let state = self.0.persistent_container.state.clone();
let transition = RemoteCancellable::new(
async move {
temp.stop();
current
.wait_for(|s| s.running_status.is_none())
.await
.with_kind(ErrorKind::Unknown)?;
if temp.restore().is_start() {
current
.wait_for(|s| s.running_status.is_some())
.await
.with_kind(ErrorKind::Unknown)?;
}
drop(temp);
state.send_modify(|s| {
s.transition_state.take();
});
Ok::<_, Error>(())
}
.map(|x| {
if let Err(err) = x {
tracing::debug!("{:?}", err);
tracing::warn!("{}", err);
}
}),
);
let cancel_handle = transition.cancellation_handle();
let transition = transition.shared();
let job_transition = transition.clone();
jobs.add_job(job_transition.map(|_| ()));
let mut old = None;
self.0.persistent_container.state.send_modify(|s| {
old = std::mem::replace(
&mut s.transition_state,
Some(TransitionState {
kind: TransitionKind::Restarting,
cancel_handle,
}),
)
});
if let Some(t) = old {
t.abort().await;
}
transition.boxed()
}
}
impl Service {
#[instrument(skip_all)]
pub async fn restart(&self, id: Guid, wait: bool) -> Result<(), Error> {
let fut = self.actor.send(id, Restart).await?;
if wait {
if fut.await.is_none() {
tracing::warn!("Restart has been cancelled");
}
}
Ok(())
}
}

View File

@@ -1,14 +0,0 @@
use futures::Future;
use tokio::sync::Notify;
use crate::prelude::*;
pub async fn cancellable<T>(
cancel_transition: &Notify,
transition: impl Future<Output = T>,
) -> Result<T, Error> {
tokio::select! {
a = transition => Ok(a),
_ = cancel_transition.notified() => Err(Error::new(eyre!("transition was cancelled"), ErrorKind::Cancelled)),
}
}

View File

@@ -1,65 +1,66 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use imbl::OrdMap; use models::{ErrorData, HealthCheckId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
use self::health_check::HealthCheckId;
use crate::prelude::*; use crate::prelude::*;
use crate::service::start_stop::StartStop; use crate::service::start_stop::StartStop;
use crate::status::health_check::NamedHealthCheckResult; use crate::status::health_check::NamedHealthCheckResult;
pub mod health_check; pub mod health_check;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, TS)] #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
#[serde(tag = "main")]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[model = "Model<Self>"]
pub struct StatusInfo {
pub health: BTreeMap<HealthCheckId, NamedHealthCheckResult>,
pub error: Option<ErrorData>,
#[ts(type = "string | null")]
pub started: Option<DateTime<Utc>>,
pub desired: DesiredStatus,
}
impl StatusInfo {
pub fn stop(&mut self) {
self.desired = self.desired.stop();
self.health.clear();
}
}
impl Model<StatusInfo> {
pub fn stop(&mut self) -> Result<(), Error> {
self.as_desired_mut().map_mutate(|s| Ok(s.stop()))?;
self.as_health_mut().ser(&Default::default())?;
Ok(())
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, TS)]
#[serde(tag = "main")]
#[serde(rename_all = "kebab-case")]
#[serde(rename_all_fields = "camelCase")] #[serde(rename_all_fields = "camelCase")]
pub enum MainStatus { pub enum DesiredStatus {
Error {
on_rebuild: StartStop,
message: String,
debug: Option<String>,
},
Stopped, Stopped,
Restarting, Restarting,
Stopping, Running,
Starting { BackingUp { on_complete: StartStop },
#[ts(as = "BTreeMap<HealthCheckId, NamedHealthCheckResult>")]
health: OrdMap<HealthCheckId, NamedHealthCheckResult>,
},
Running {
#[ts(type = "string")]
started: DateTime<Utc>,
#[ts(as = "BTreeMap<HealthCheckId, NamedHealthCheckResult>")]
health: OrdMap<HealthCheckId, NamedHealthCheckResult>,
},
BackingUp {
on_complete: StartStop,
},
} }
impl MainStatus { impl Default for DesiredStatus {
fn default() -> Self {
Self::Stopped
}
}
impl DesiredStatus {
pub fn running(&self) -> bool { pub fn running(&self) -> bool {
match self { match self {
MainStatus::Starting { .. } Self::Running
| MainStatus::Running { .. } | Self::Restarting
| MainStatus::Restarting | Self::BackingUp {
| MainStatus::BackingUp {
on_complete: StartStop::Start, on_complete: StartStop::Start,
}
| MainStatus::Error {
on_rebuild: StartStop::Start,
..
} => true, } => true,
MainStatus::Stopped Self::Stopped
| MainStatus::Stopping { .. } | Self::BackingUp {
| MainStatus::BackingUp {
on_complete: StartStop::Stop, on_complete: StartStop::Stop,
}
| MainStatus::Error {
on_rebuild: StartStop::Stop,
..
} => false, } => false,
} }
} }
@@ -71,37 +72,35 @@ impl MainStatus {
} }
} }
pub fn major_changes(&self, other: &Self) -> bool {
match (self, other) {
(MainStatus::Running { .. }, MainStatus::Running { .. }) => false,
(MainStatus::Starting { .. }, MainStatus::Starting { .. }) => false,
(MainStatus::Stopping, MainStatus::Stopping) => false,
(MainStatus::Stopped, MainStatus::Stopped) => false,
(MainStatus::Restarting, MainStatus::Restarting) => false,
(MainStatus::BackingUp { .. }, MainStatus::BackingUp { .. }) => false,
(MainStatus::Error { .. }, MainStatus::Error { .. }) => false,
_ => true,
}
}
pub fn backing_up(&self) -> Self { pub fn backing_up(&self) -> Self {
MainStatus::BackingUp { Self::BackingUp {
on_complete: if self.running() { on_complete: self.run_state(),
StartStop::Start
} else {
StartStop::Stop
},
} }
} }
pub fn health(&self) -> Option<&OrdMap<HealthCheckId, NamedHealthCheckResult>> { pub fn stop(&self) -> Self {
match self { match self {
MainStatus::Running { health, .. } | MainStatus::Starting { health } => Some(health), Self::BackingUp { .. } => Self::BackingUp {
MainStatus::BackingUp { .. } on_complete: StartStop::Stop,
| MainStatus::Stopped },
| MainStatus::Stopping { .. } _ => Self::Stopped,
| MainStatus::Restarting }
| MainStatus::Error { .. } => None, }
pub fn start(&self) -> Self {
match self {
Self::BackingUp { .. } => Self::BackingUp {
on_complete: StartStop::Start,
},
Self::Stopped => Self::Running,
x => *x,
}
}
pub fn restart(&self) -> Self {
match self {
Self::Running => Self::Restarting,
x => *x, // no-op: restart is meaningless in any other state
} }
} }
} }

View File

@@ -1039,6 +1039,7 @@ pub async fn test_smtp(
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
AsyncSmtpTransport::<Tokio1Executor>::relay(&server)? AsyncSmtpTransport::<Tokio1Executor>::relay(&server)?
.port(port)
.credentials(Credentials::new(login, password)) .credentials(Credentials::new(login, password))
.build() .build()
.send( .send(

View File

@@ -3,7 +3,7 @@ use imbl::HashMap;
use imbl_value::InternedString; use imbl_value::InternedString;
use itertools::Itertools; use itertools::Itertools;
use patch_db::HasModel; use patch_db::HasModel;
use rpc_toolkit::{Context, Empty, HandlerArgs, HandlerExt, ParentHandler, from_fn_async}; use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;

View File

@@ -33,7 +33,7 @@ use crate::rpc_continuations::{OpenAuthedContinuations, RpcContinuations};
use crate::tunnel::TUNNEL_DEFAULT_LISTEN; use crate::tunnel::TUNNEL_DEFAULT_LISTEN;
use crate::tunnel::api::tunnel_api; use crate::tunnel::api::tunnel_api;
use crate::tunnel::db::TunnelDatabase; use crate::tunnel::db::TunnelDatabase;
use crate::tunnel::wg::WIREGUARD_INTERFACE_NAME; use crate::tunnel::wg::{WIREGUARD_INTERFACE_NAME, WgSubnetConfig};
use crate::util::Invoke; use crate::util::Invoke;
use crate::util::collections::OrdMapIterMut; use crate::util::collections::OrdMapIterMut;
use crate::util::io::read_file_to_string; use crate::util::io::read_file_to_string;
@@ -100,7 +100,17 @@ impl TunnelContext {
let db_path = datadir.join("tunnel.db"); let db_path = datadir.join("tunnel.db");
let db = TypedPatchDb::<TunnelDatabase>::load_or_init( let db = TypedPatchDb::<TunnelDatabase>::load_or_init(
PatchDb::open(&db_path).await?, PatchDb::open(&db_path).await?,
|| async { Ok(Default::default()) }, || async {
let mut db = TunnelDatabase::default();
db.wg.subnets.0.insert(
"10.59.0.1/24".parse()?,
WgSubnetConfig {
name: "Default Subnet".into(),
..Default::default()
},
);
Ok(db)
},
) )
.await?; .await?;
let listen = config.tunnel_listen.unwrap_or(TUNNEL_DEFAULT_LISTEN); let listen = config.tunnel_listen.unwrap_or(TUNNEL_DEFAULT_LISTEN);

View File

@@ -466,7 +466,7 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
println!("✅ Success! ✅"); println!("✅ Success! ✅");
println!( println!(
"The webserver is running. Below is your URL{} and SSL certificate.", "The webserver is running. Below is your URL{} and Root Certificate Authority (Root CA).",
if password.is_some() { if password.is_some() {
", password," ", password,"
} else { } else {
@@ -496,7 +496,7 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
println!("{password}"); println!("{password}");
println!(); println!();
println!(concat!( println!(concat!(
"If you lose or forget your password, you can reset it using the command: ", "If you lose or forget your password, you can reset it using the following command: ",
"start-tunnel auth reset-password" "start-tunnel auth reset-password"
)); ));
} else { } else {
@@ -516,12 +516,22 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
.pop() .pop()
.map(Pem) .map(Pem)
.or_not_found("certificate in chain")?; .or_not_found("certificate in chain")?;
println!("📝 Root SSL Certificate:"); println!("📝 Root CA:");
print!("{cert}"); print!("{cert}");
println!(concat!( println!(concat!(
"If you haven't already, ", "To trust your StartTunnel Root CA (above):\n",
"trust the certificate in your system keychain and/or browser." " 1. Copy the Root CA ",
"(starting with -----BEGIN CERTIFICATE----- and ending with -----END CERTIFICATE-----).\n",
" 2. Open a text editor: \n",
" - Linux: gedit, nano, or any editor\n",
" - Mac: TextEdit\n",
" - Windows: Notepad\n",
" 3. Paste the contents of your Root CA.\n",
" 4. Save the file with a `.crt` extension ",
"(e.g. `start-tunnel.crt`) (make sure it saves as plain text, not rich text).\n",
" 5. Follow instructions to trust you StartTunnel Root CA: ",
"https://staging.docs.start9.com/user-manual/trust-ca.html#2-trust-your-servers-root-ca."
)); ));
return Ok(()); return Ok(());
@@ -534,28 +544,14 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
.await?, .await?,
)?; )?;
suggested_addrs.sort_by_cached_key(|a| match a { suggested_addrs.retain(|ip| match ip {
IpAddr::V4(a) => { IpAddr::V4(a) => !a.is_loopback() && !a.is_private(),
if a.is_loopback() { IpAddr::V6(a) => !a.is_loopback() && !a.is_unicast_link_local(),
3
} else if a.is_private() {
2
} else {
0
}
}
IpAddr::V6(a) => {
if a.is_loopback() {
5
} else if a.is_unicast_link_local() {
4
} else {
1
}
}
}); });
let ip = if suggested_addrs.is_empty() { let ip = if suggested_addrs.len() == 1 {
suggested_addrs[0]
} else if suggested_addrs.is_empty() {
prompt("Listen Address: ", parse_as::<IpAddr>("IP Address"), None).await? prompt("Listen Address: ", parse_as::<IpAddr>("IP Address"), None).await?
} else if suggested_addrs.len() > 16 { } else if suggested_addrs.len() > 16 {
prompt( prompt(
@@ -565,22 +561,7 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
) )
.await? .await?
} else { } else {
*choose_custom_display("Listen Address:", &suggested_addrs, |a| match a { *choose("Listen Address:", &suggested_addrs).await?
a if a.is_loopback() => {
format!("{a} (Loopback Address: only use if planning to proxy traffic)")
}
IpAddr::V4(a) if a.is_private() => {
format!("{a} (Private Address: only available from Local Area Network)")
}
IpAddr::V6(a) if a.is_unicast_link_local() => {
format!(
"[{a}] (Private Address: only available from Local Area Network)"
)
}
IpAddr::V6(a) => format!("[{a}]"),
a => a.to_string(),
})
.await?
}; };
println!(concat!( println!(concat!(
@@ -608,8 +589,8 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
impl std::fmt::Display for Choice { impl std::fmt::Display for Choice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Generate => write!(f, "Generate an SSL certificate"), Self::Generate => write!(f, "Generate"),
Self::Provide => write!(f, "Provide your own certificate and key"), Self::Provide => write!(f, "Provide"),
} }
} }
} }
@@ -617,7 +598,7 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
let choice = choose( let choice = choose(
concat!( concat!(
"Select whether to generate an SSL certificate ", "Select whether to generate an SSL certificate ",
"or provide your own certificate and key:" "or provide your own certificate (and key):"
), ),
&options, &options,
) )
@@ -631,35 +612,35 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
)? )?
.filter(|a| !a.ip().is_unspecified()); .filter(|a| !a.ip().is_unspecified());
let default_prompt = if let Some(listen) = listen { let san_info = if let Some(listen) = listen {
format!("Subject Alternative Name(s) [{}]: ", listen.ip()) vec![InternedString::from_display(&listen.ip())]
} else { } else {
"Subject Alternative Name(s): ".to_string() println!(
"List all IP addresses and domains for which to sign the certificate, separated by commas."
);
prompt(
"Subject Alternative Name(s): ",
|s| {
s.split(",")
.map(|s| {
let s = s.trim();
if let Ok(ip) = s.parse::<IpAddr>() {
Ok(InternedString::from_display(&ip))
} else if is_valid_domain(s) {
Ok(s.into())
} else {
Err(format!(
"{s} is not a valid ip address or domain"
))
}
})
.collect()
},
listen.map(|l| vec![InternedString::from_display(&l.ip())]),
)
.await?
}; };
println!(
"List all IP addresses and domains for which to sign the certificate, separated by commas."
);
let san_info = prompt(
&default_prompt,
|s| {
s.split(",")
.map(|s| {
let s = s.trim();
if let Ok(ip) = s.parse::<IpAddr>() {
Ok(InternedString::from_display(&ip))
} else if is_valid_domain(s) {
Ok(s.into())
} else {
Err(format!("{s} is not a valid ip address or domain"))
}
})
.collect()
},
listen.map(|l| vec![InternedString::from_display(&l.ip())]),
)
.await?;
ctx.call_remote::<TunnelContext>( ctx.call_remote::<TunnelContext>(
"web.generate-certificate", "web.generate-certificate",
to_value(&GenerateCertParams { subject: san_info })?, to_value(&GenerateCertParams { subject: san_info })?,

View File

@@ -258,7 +258,7 @@ mod test {
#[derive(Clone)] #[derive(Clone)]
struct CActor; struct CActor;
impl Actor for CActor { impl Actor for CActor {
fn init(&mut self, jobs: &BackgroundJobQueue) {} fn init(&mut self, _: &BackgroundJobQueue) {}
} }
struct Pending; struct Pending;
impl Handler<Pending> for CActor { impl Handler<Pending> for CActor {

View File

@@ -14,7 +14,7 @@ use std::time::Duration;
use bytes::{Buf, BytesMut}; use bytes::{Buf, BytesMut};
use clap::builder::ValueParserFactory; use clap::builder::ValueParserFactory;
use futures::future::{BoxFuture, Fuse}; use futures::future::{BoxFuture, Fuse};
use futures::{FutureExt, Stream, StreamExt, TryStreamExt}; use futures::{FutureExt, Stream, TryStreamExt};
use helpers::{AtomicFile, NonDetachingJoinHandle}; use helpers::{AtomicFile, NonDetachingJoinHandle};
use inotify::{EventMask, EventStream, Inotify, WatchMask}; use inotify::{EventMask, EventStream, Inotify, WatchMask};
use models::FromStrParser; use models::FromStrParser;
@@ -22,7 +22,7 @@ use nix::unistd::{Gid, Uid};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::fs::{File, OpenOptions}; use tokio::fs::{File, OpenOptions};
use tokio::io::{ use tokio::io::{
AsyncRead, AsyncReadExt, AsyncSeek, AsyncWrite, AsyncWriteExt, DuplexStream, ReadBuf, SeekFrom, AsyncRead, AsyncReadExt, AsyncSeek, AsyncWrite, AsyncWriteExt, DuplexStream, ReadBuf,
WriteHalf, duplex, WriteHalf, duplex,
}; };
use tokio::net::TcpStream; use tokio::net::TcpStream;

View File

@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, VecDeque}; use std::collections::VecDeque;
use std::ops::Deref; use std::ops::Deref;
use std::pin::Pin; use std::pin::Pin;
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
@@ -28,6 +28,7 @@ fn annotate_lock<F, T>(f: F, id: usize, write: bool) -> T
where where
F: FnOnce() -> T, F: FnOnce() -> T,
{ {
use std::collections::BTreeMap;
std::thread_local! { std::thread_local! {
static LOCK_CTX: std::cell::RefCell<BTreeMap<usize, Result<(), usize>>> = std::cell::RefCell::new(BTreeMap::new()); static LOCK_CTX: std::cell::RefCell<BTreeMap<usize, Result<(), usize>>> = std::cell::RefCell::new(BTreeMap::new());
} }

View File

@@ -55,8 +55,9 @@ mod v0_4_0_alpha_12;
mod v0_4_0_alpha_13; mod v0_4_0_alpha_13;
mod v0_4_0_alpha_14; mod v0_4_0_alpha_14;
mod v0_4_0_alpha_15; mod v0_4_0_alpha_15;
mod v0_4_0_alpha_16;
pub type Current = v0_4_0_alpha_15::Version; // VERSION_BUMP pub type Current = v0_4_0_alpha_16::Version; // VERSION_BUMP
impl Current { impl Current {
#[instrument(skip(self, db))] #[instrument(skip(self, db))]
@@ -173,7 +174,8 @@ enum Version {
V0_4_0_alpha_12(Wrapper<v0_4_0_alpha_12::Version>), V0_4_0_alpha_12(Wrapper<v0_4_0_alpha_12::Version>),
V0_4_0_alpha_13(Wrapper<v0_4_0_alpha_13::Version>), V0_4_0_alpha_13(Wrapper<v0_4_0_alpha_13::Version>),
V0_4_0_alpha_14(Wrapper<v0_4_0_alpha_14::Version>), V0_4_0_alpha_14(Wrapper<v0_4_0_alpha_14::Version>),
V0_4_0_alpha_15(Wrapper<v0_4_0_alpha_15::Version>), // VERSION_BUMP V0_4_0_alpha_15(Wrapper<v0_4_0_alpha_15::Version>),
V0_4_0_alpha_16(Wrapper<v0_4_0_alpha_16::Version>), // VERSION_BUMP
Other(exver::Version), Other(exver::Version),
} }
@@ -231,7 +233,8 @@ impl Version {
Self::V0_4_0_alpha_12(v) => DynVersion(Box::new(v.0)), Self::V0_4_0_alpha_12(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_13(v) => DynVersion(Box::new(v.0)), Self::V0_4_0_alpha_13(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_14(v) => DynVersion(Box::new(v.0)), Self::V0_4_0_alpha_14(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_15(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP Self::V0_4_0_alpha_15(v) => DynVersion(Box::new(v.0)),
Self::V0_4_0_alpha_16(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
Self::Other(v) => { Self::Other(v) => {
return Err(Error::new( return Err(Error::new(
eyre!("unknown version {v}"), eyre!("unknown version {v}"),
@@ -281,7 +284,8 @@ impl Version {
Version::V0_4_0_alpha_12(Wrapper(x)) => x.semver(), Version::V0_4_0_alpha_12(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_13(Wrapper(x)) => x.semver(), Version::V0_4_0_alpha_13(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_14(Wrapper(x)) => x.semver(), Version::V0_4_0_alpha_14(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_15(Wrapper(x)) => x.semver(), // VERSION_BUMP Version::V0_4_0_alpha_15(Wrapper(x)) => x.semver(),
Version::V0_4_0_alpha_16(Wrapper(x)) => x.semver(), // VERSION_BUMP
Version::Other(x) => x.clone(), Version::Other(x) => x.clone(),
} }
} }

View File

@@ -0,0 +1,66 @@
use exver::{PreReleaseSegment, VersionRange};
use super::v0_3_5::V0_3_0_COMPAT;
use super::{VersionT, v0_4_0_alpha_15};
use crate::prelude::*;
use crate::status::StatusInfo;
lazy_static::lazy_static! {
static ref V0_4_0_alpha_16: exver::Version = exver::Version::new(
[0, 4, 0],
[PreReleaseSegment::String("alpha".into()), 16.into()]
);
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Version;
impl VersionT for Version {
type Previous = v0_4_0_alpha_15::Version;
type PreUpRes = ();
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
Ok(())
}
fn semver(self) -> exver::Version {
V0_4_0_alpha_16.clone()
}
fn compat(self) -> &'static VersionRange {
&V0_3_0_COMPAT
}
#[instrument(skip_all)]
fn up(self, db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
for (_, pde) in db["public"]["packageData"]
.as_object_mut()
.into_iter()
.flat_map(|x| x.iter_mut())
{
pde["statusInfo"] = to_value(&StatusInfo::default())?;
if match pde["status"]["main"].as_str().unwrap_or_default() {
"running" | "starting" | "restarting" => true,
"backingUp"
if pde["status"]["main"]["onComplete"]
.as_bool()
.unwrap_or(false) =>
{
true
}
"error"
if pde["status"]["main"]["onRebuild"]
.as_bool()
.unwrap_or(false) =>
{
true
}
_ => false,
} {
pde["statusInfo"]["desired"]["main"] = to_value(&"running")?;
}
}
Ok(Value::Null)
}
fn down(self, _db: &mut Value) -> Result<(), Error> {
Ok(())
}
}

View File

@@ -55,8 +55,7 @@ StartOS v${VERSION}
EOF EOF
# change timezone # change timezone
rm -f /etc/localtime ln -sf /usr/share/zoneinfo/Etc/UTC /etc/localtime
ln -s /usr/share/zoneinfo/Etc/UTC /etc/localtime
rm /etc/resolv.conf rm /etc/resolv.conf
echo "nameserver 127.0.0.1" > /etc/resolv.conf echo "nameserver 127.0.0.1" > /etc/resolv.conf
@@ -122,8 +121,6 @@ ln -sf /usr/lib/startos/scripts/wireguard-vps-proxy-setup /usr/bin/wireguard-vps
echo "fs.inotify.max_user_watches=1048576" > /etc/sysctl.d/97-startos.conf echo "fs.inotify.max_user_watches=1048576" > /etc/sysctl.d/97-startos.conf
dpkg-reconfigure --frontend noninteractive locales
if ! getent group | grep '^startos:'; then if ! getent group | grep '^startos:'; then
groupadd startos groupadd startos
fi fi

View File

@@ -13,8 +13,8 @@ import {
ExportServiceInterfaceParams, ExportServiceInterfaceParams,
ServiceInterface, ServiceInterface,
CreateTaskParams, CreateTaskParams,
MainStatus,
MountParams, MountParams,
StatusInfo,
} from "./osBindings" } from "./osBindings"
import { import {
PackageId, PackageId,
@@ -66,7 +66,7 @@ export type Effects = {
getStatus(options: { getStatus(options: {
packageId?: PackageId packageId?: PackageId
callback?: () => void callback?: () => void
}): Promise<MainStatus> }): Promise<StatusInfo>
/** 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<null> setMainStatus(options: SetMainStatus): Promise<null>

View File

@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { StartStop } from "./StartStop"
export type DesiredStatus =
| { main: "stopped" }
| { main: "restarting" }
| { main: "running" }
| { main: "backing-up"; onComplete: StartStop }

View File

@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DependencyKind = "exists" | "running" export type ErrorData = { details: string; debug: string }

View File

@@ -1,25 +0,0 @@
// 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 { NamedHealthCheckResult } from "./NamedHealthCheckResult"
import type { StartStop } from "./StartStop"
export type MainStatus =
| {
main: "error"
onRebuild: StartStop
message: string
debug: string | null
}
| { main: "stopped" }
| { main: "restarting" }
| { main: "stopping" }
| {
main: "starting"
health: { [key: HealthCheckId]: NamedHealthCheckResult }
}
| {
main: "running"
started: string
health: { [key: HealthCheckId]: NamedHealthCheckResult }
}
| { main: "backingUp"; onComplete: StartStop }

View File

@@ -4,17 +4,17 @@ import type { ActionMetadata } from "./ActionMetadata"
import type { CurrentDependencies } from "./CurrentDependencies" import type { CurrentDependencies } from "./CurrentDependencies"
import type { DataUrl } from "./DataUrl" import type { DataUrl } from "./DataUrl"
import type { Hosts } from "./Hosts" import type { Hosts } from "./Hosts"
import type { MainStatus } from "./MainStatus"
import type { PackageState } from "./PackageState" import type { PackageState } from "./PackageState"
import type { ReplayId } from "./ReplayId" import type { ReplayId } from "./ReplayId"
import type { ServiceInterface } from "./ServiceInterface" import type { ServiceInterface } from "./ServiceInterface"
import type { ServiceInterfaceId } from "./ServiceInterfaceId" import type { ServiceInterfaceId } from "./ServiceInterfaceId"
import type { StatusInfo } from "./StatusInfo"
import type { TaskEntry } from "./TaskEntry" import type { TaskEntry } from "./TaskEntry"
export type PackageDataEntry = { export type PackageDataEntry = {
stateInfo: PackageState stateInfo: PackageState
s9pk: string s9pk: string
status: MainStatus statusInfo: StatusInfo
registry: string | null registry: string | null
developerKey: string developerKey: string
icon: DataUrl icon: DataUrl

View File

@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DesiredStatus } from "./DesiredStatus"
import type { ErrorData } from "./ErrorData"
import type { HealthCheckId } from "./HealthCheckId"
import type { NamedHealthCheckResult } from "./NamedHealthCheckResult"
export type StatusInfo = {
health: { [key: HealthCheckId]: NamedHealthCheckResult }
error: ErrorData | null
started: string | null
desired: DesiredStatus
}

View File

@@ -59,11 +59,11 @@ export { CurrentDependencies } from "./CurrentDependencies"
export { CurrentDependencyInfo } from "./CurrentDependencyInfo" export { CurrentDependencyInfo } from "./CurrentDependencyInfo"
export { DataUrl } from "./DataUrl" export { DataUrl } from "./DataUrl"
export { Dependencies } from "./Dependencies" export { Dependencies } from "./Dependencies"
export { DependencyKind } from "./DependencyKind"
export { DependencyMetadata } from "./DependencyMetadata" export { DependencyMetadata } from "./DependencyMetadata"
export { DependencyRequirement } from "./DependencyRequirement" export { DependencyRequirement } from "./DependencyRequirement"
export { DepInfo } from "./DepInfo" export { DepInfo } from "./DepInfo"
export { Description } from "./Description" export { Description } from "./Description"
export { DesiredStatus } from "./DesiredStatus"
export { DestroySubcontainerFsParams } from "./DestroySubcontainerFsParams" export { DestroySubcontainerFsParams } from "./DestroySubcontainerFsParams"
export { DeviceFilter } from "./DeviceFilter" export { DeviceFilter } from "./DeviceFilter"
export { DnsSettings } from "./DnsSettings" export { DnsSettings } from "./DnsSettings"
@@ -72,6 +72,7 @@ export { Duration } from "./Duration"
export { EchoParams } from "./EchoParams" export { EchoParams } from "./EchoParams"
export { EditSignerParams } from "./EditSignerParams" export { EditSignerParams } from "./EditSignerParams"
export { EncryptedWire } from "./EncryptedWire" export { EncryptedWire } from "./EncryptedWire"
export { ErrorData } from "./ErrorData"
export { EventId } from "./EventId" export { EventId } from "./EventId"
export { ExportActionParams } from "./ExportActionParams" export { ExportActionParams } from "./ExportActionParams"
export { ExportServiceInterfaceParams } from "./ExportServiceInterfaceParams" export { ExportServiceInterfaceParams } from "./ExportServiceInterfaceParams"
@@ -123,7 +124,6 @@ export { LoginParams } from "./LoginParams"
export { LshwDevice } from "./LshwDevice" export { LshwDevice } from "./LshwDevice"
export { LshwDisplay } from "./LshwDisplay" export { LshwDisplay } from "./LshwDisplay"
export { LshwProcessor } from "./LshwProcessor" export { LshwProcessor } from "./LshwProcessor"
export { MainStatus } from "./MainStatus"
export { Manifest } from "./Manifest" export { Manifest } from "./Manifest"
export { MaybeUtf8String } from "./MaybeUtf8String" export { MaybeUtf8String } from "./MaybeUtf8String"
export { MebiBytes } from "./MebiBytes" export { MebiBytes } from "./MebiBytes"
@@ -201,6 +201,7 @@ export { SignAssetParams } from "./SignAssetParams"
export { SignerInfo } from "./SignerInfo" export { SignerInfo } from "./SignerInfo"
export { SmtpValue } from "./SmtpValue" export { SmtpValue } from "./SmtpValue"
export { StartStop } from "./StartStop" export { StartStop } from "./StartStop"
export { StatusInfo } from "./StatusInfo"
export { TaskCondition } from "./TaskCondition" export { TaskCondition } from "./TaskCondition"
export { TaskEntry } from "./TaskEntry" export { TaskEntry } from "./TaskEntry"
export { TaskInput } from "./TaskInput" export { TaskInput } from "./TaskInput"

View File

@@ -61,7 +61,7 @@ import {
} from "../../base/lib/inits" } from "../../base/lib/inits"
import { DropGenerator } from "../../base/lib/util/Drop" import { DropGenerator } from "../../base/lib/util/Drop"
export const OSVersion = testTypeVersion("0.4.0-alpha.15") export const OSVersion = testTypeVersion("0.4.0-alpha.16")
// prettier-ignore // prettier-ignore
type AnyNeverCond<T extends any[], Then, Else> = type AnyNeverCond<T extends any[], Then, Else> =

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "startos-ui", "name": "startos-ui",
"version": "0.4.0-alpha.15", "version": "0.4.0-alpha.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "startos-ui", "name": "startos-ui",
"version": "0.4.0-alpha.15", "version": "0.4.0-alpha.16",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@angular/animations": "^20.3.0", "@angular/animations": "^20.3.0",

View File

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

View File

@@ -19,7 +19,6 @@ import { PatchDB } from 'patch-db-client'
import { filter, map } from 'rxjs' import { filter, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service' import { ApiService } from 'src/app/services/api/api.service'
import { TunnelData } from 'src/app/services/patch-db/data-model' import { TunnelData } from 'src/app/services/patch-db/data-model'
import { DEVICES_ADD } from './add' import { DEVICES_ADD } from './add'
import { DEVICES_CONFIG } from './config' import { DEVICES_CONFIG } from './config'
import { MappedDevice, MappedSubnet } from './utils' import { MappedDevice, MappedSubnet } from './utils'
@@ -84,6 +83,8 @@ import { MappedDevice, MappedSubnet } from './utils'
</button> </button>
</td> </td>
</tr> </tr>
} @empty {
<div class="placeholder">No devices</div>
} }
</tbody> </tbody>
</table> </table>

View File

@@ -55,6 +55,8 @@ import { MappedDevice, MappedForward } from './utils'
</button> </button>
</td> </td>
</tr> </tr>
} @empty {
<div class="placeholder">No port forwards</div>
} }
</tbody> </tbody>
</table> </table>

View File

@@ -67,6 +67,8 @@ import { SUBNETS_ADD } from './add'
</button> </button>
</td> </td>
</tr> </tr>
} @empty {
<div class="placeholder">No subnets</div>
} }
</tbody> </tbody>
</table> </table>

View File

@@ -80,6 +80,12 @@ tui-dialog[new][data-appearance~='start-9'] {
} }
} }
.placeholder {
padding: 1rem;
font: var(--tui-font-text-l);
color: var(--tui-text-tertiary);
}
qr-code { qr-code {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -15,7 +15,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
selector: 'service-error', selector: 'service-error',
template: ` template: `
<header>{{ 'Service Launch Error' | i18n }}</header> <header>{{ 'Service Launch Error' | i18n }}</header>
<p class="error-message">{{ error?.message }}</p> <p class="error-message">{{ error?.details }}</p>
<p>{{ error?.debug }}</p> <p>{{ error?.debug }}</p>
<h4> <h4>
{{ 'Actions' | i18n }} {{ 'Actions' | i18n }}
@@ -95,7 +95,7 @@ export class ServiceErrorComponent {
overflow = false overflow = false
get error() { get error() {
return this.pkg.status.main === 'error' ? this.pkg.status : null return this.pkg.statusInfo.error
} }
rebuild() { rebuild() {
@@ -108,7 +108,7 @@ export class ServiceErrorComponent {
show() { show() {
this.dialog this.dialog
.openAlert(this.error?.message as i18nKey, { label: 'Service error' }) .openAlert(this.error?.details as i18nKey, { label: 'Service error' })
.subscribe() .subscribe()
} }
} }

View File

@@ -37,7 +37,7 @@ import {
<span class="loading-dots"></span> <span class="loading-dots"></span>
} }
@if ($any(pkg().status)?.started; as started) { @if (pkg().statusInfo.started; as started) {
<service-uptime [started]="started" /> <service-uptime [started]="started" />
} }
</h3> </h3>

View File

@@ -19,6 +19,7 @@ import { ServiceTasksComponent } from 'src/app/routes/portal/routes/services/com
import { ActionService } from 'src/app/services/action.service' import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data' import { getManifest } from 'src/app/utils/get-package-data'
@Component({ @Component({
@@ -161,7 +162,7 @@ export class ServiceTaskComponent {
pkgInfo: { pkgInfo: {
id: this.task().packageId, id: this.task().packageId,
title, title,
mainStatus: pkg.status.main, status: getInstalledBaseStatus(pkg.statusInfo),
icon: pkg.icon, icon: pkg.icon,
}, },
actionInfo: { id: this.task().actionId, metadata }, actionInfo: { id: this.task().actionId, metadata },

View File

@@ -114,11 +114,10 @@ import { distinctUntilChanged } from 'rxjs/operators'
}) })
export class ServiceUptimeComponent { export class ServiceUptimeComponent {
protected readonly uptime$ = timer(0, 1000).pipe( protected readonly uptime$ = timer(0, 1000).pipe(
map(() => map(() => {
this.started() const started = this.started()
? Math.max(Date.now() - new Date(this.started()).getTime(), 0) return started ? Math.max(Date.now() - new Date(started).getTime(), 0) : 0
: 0, }),
),
distinctUntilChanged(), distinctUntilChanged(),
map(delta => ({ map(delta => ({
seconds: Math.floor(delta / 1000) % 60, seconds: Math.floor(delta / 1000) % 60,
@@ -128,5 +127,5 @@ export class ServiceUptimeComponent {
})), })),
) )
readonly started = input('') readonly started = input<string | null>(null)
} }

View File

@@ -34,7 +34,7 @@ import { StatusComponent } from './status.component'
></td> ></td>
<td [style.grid-area]="'2 / 2'">{{ manifest.version }}</td> <td [style.grid-area]="'2 / 2'">{{ manifest.version }}</td>
<td class="uptime"> <td class="uptime">
@if ($any(pkg.status)?.started; as started) { @if (pkg.statusInfo.started; as started) {
<span>{{ 'Uptime' | i18n }}:</span> <span>{{ 'Uptime' | i18n }}:</span>
<service-uptime [started]="started" /> <service-uptime [started]="started" />
} @else { } @else {

View File

@@ -56,7 +56,7 @@ export class StatusComponent {
const { primary, health } = this.getStatus(this.pkg) const { primary, health } = this.getStatus(this.pkg)
return ( return (
!this.hasDepErrors && !this.hasDepErrors &&
primary !== 'taskRequired' && primary !== 'task-required' &&
primary !== 'error' && primary !== 'error' &&
health !== 'failure' health !== 'failure'
) )
@@ -80,7 +80,7 @@ export class StatusComponent {
case 'updating': case 'updating':
case 'stopping': case 'stopping':
case 'starting': case 'starting':
case 'backingUp': case 'backing-up':
case 'restarting': case 'restarting':
case 'removing': case 'removing':
return true return true
@@ -93,7 +93,7 @@ export class StatusComponent {
switch (this.getStatus(this.pkg).primary) { switch (this.getStatus(this.pkg).primary) {
case 'running': case 'running':
return 'var(--tui-status-positive)' return 'var(--tui-status-positive)'
case 'taskRequired': case 'task-required':
return 'var(--tui-status-warning)' return 'var(--tui-status-warning)'
case 'error': case 'error':
return 'var(--tui-status-negative)' return 'var(--tui-status-negative)'
@@ -101,7 +101,7 @@ export class StatusComponent {
case 'updating': case 'updating':
case 'stopping': case 'stopping':
case 'starting': case 'starting':
case 'backingUp': case 'backing-up':
case 'restarting': case 'restarting':
case 'removing': case 'removing':
case 'restoring': case 'restoring':

View File

@@ -11,6 +11,7 @@ import { tuiPure } from '@taiga-ui/cdk'
import { TuiDataList, TuiDropdown, TuiButton } from '@taiga-ui/core' import { TuiDataList, TuiDropdown, TuiButton } from '@taiga-ui/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { InterfaceService } from '../../../components/interfaces/interface.service' import { InterfaceService } from '../../../components/interfaces/interface.service'
import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({ @Component({
selector: 'app-ui-launch', selector: 'app-ui-launch',
@@ -70,7 +71,7 @@ export class UILaunchComponent {
} }
get isRunning(): boolean { get isRunning(): boolean {
return this.pkg.status.main === 'running' return getInstalledPrimaryStatus(this.pkg) === 'running'
} }
@tuiPure @tuiPure

View File

@@ -27,6 +27,7 @@ import { TaskInfoComponent } from 'src/app/routes/portal/modals/config-dep.compo
import { ActionService } from 'src/app/services/action.service' import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { BaseStatus } from 'src/app/services/pkg-status-rendering.service'
import { getAllPackages, getManifest } from 'src/app/utils/get-package-data' import { getAllPackages, getManifest } from 'src/app/utils/get-package-data'
export type PackageActionData = { export type PackageActionData = {
@@ -34,7 +35,7 @@ export type PackageActionData = {
id: string id: string
title: string title: string
icon: string icon: string
mainStatus: T.MainStatus['main'] status: BaseStatus
} }
actionInfo: { actionInfo: {
id: string id: string

View File

@@ -16,6 +16,10 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
import { StandardActionsService } from 'src/app/services/standard-actions.service' import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { getManifest } from 'src/app/utils/get-package-data' import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionComponent } from '../components/action.component' import { ServiceActionComponent } from '../components/action.component'
import {
BaseStatus,
getInstalledBaseStatus,
} from 'src/app/services/pkg-status-rendering.service'
@Component({ @Component({
template: ` template: `
@@ -27,7 +31,7 @@ import { ServiceActionComponent } from '../components/action.component'
<button <button
tuiCell tuiCell
[action]="a" [action]="a"
(click)="handle(pkg.mainStatus, pkg.icon, pkg.manifest, a)" (click)="handle(pkg.status, pkg.icon, pkg.manifest, a)"
></button> ></button>
} }
</section> </section>
@@ -77,7 +81,7 @@ export default class ServiceActionsRoute {
? 'Other' ? 'Other'
: 'General' : 'General'
return { return {
mainStatus: pkg.status.main, status: getInstalledBaseStatus(pkg.statusInfo),
icon: pkg.icon, icon: pkg.icon,
manifest: getManifest(pkg), manifest: getManifest(pkg),
actions: Object.entries(pkg.actions) actions: Object.entries(pkg.actions)
@@ -131,13 +135,13 @@ export default class ServiceActionsRoute {
} }
handle( handle(
mainStatus: T.MainStatus['main'], status: BaseStatus,
icon: string, icon: string,
{ id, title }: T.Manifest, { id, title }: T.Manifest,
action: T.ActionMetadata & { id: string }, action: T.ActionMetadata & { id: string },
) { ) {
this.actions.present({ this.actions.present({
pkgInfo: { id, title, icon, mainStatus }, pkgInfo: { id, title, icon, status },
actionInfo: { id: action.id, metadata: action }, actionInfo: { id: action.id, metadata: action },
}) })
} }

View File

@@ -22,6 +22,7 @@ import {
InterfaceService, InterfaceService,
} from '../../../components/interfaces/interface.service' } from '../../../components/interfaces/interface.service'
import { GatewayService } from 'src/app/services/gateway.service' import { GatewayService } from 'src/app/services/gateway.service'
import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({ @Component({
template: ` template: `
@@ -101,7 +102,8 @@ export default class ServiceInterfaceRoute {
readonly pkg = toSignal(this.patch.watch$('packageData', this.pkgId)) readonly pkg = toSignal(this.patch.watch$('packageData', this.pkgId))
readonly isRunning = computed(() => { readonly isRunning = computed(() => {
return this.pkg()?.status.main === 'running' const pkg = this.pkg()
return pkg ? getInstalledBaseStatus(pkg.statusInfo) === 'running' : false
}) })
readonly serviceInterface = computed(() => { readonly serviceInterface = computed(() => {

View File

@@ -25,7 +25,7 @@ const INACTIVE: PrimaryStatus[] = [
'updating', 'updating',
'removing', 'removing',
'restoring', 'restoring',
'backingUp', 'backing-up',
] ]
@Component({ @Component({

View File

@@ -34,7 +34,7 @@ import { ServiceUptimeComponent } from '../components/uptime.component'
@Component({ @Component({
template: ` template: `
@if (pkg(); as pkg) { @if (pkg(); as pkg) {
@if (pkg.status.main === 'error') { @if (pkg.statusInfo.error) {
<service-error [pkg]="pkg" /> <service-error [pkg]="pkg" />
} @else if (installing()) { } @else if (installing()) {
<service-install-progress [pkg]="pkg" /> <service-install-progress [pkg]="pkg" />
@@ -45,9 +45,9 @@ import { ServiceUptimeComponent } from '../components/uptime.component'
} }
</service-status> </service-status>
@if (status() !== 'backingUp') { @if (status() !== 'backing-up') {
<service-health-checks [checks]="health()" /> <service-health-checks [checks]="health()" />
<service-uptime class="g-card" [started]="$any(pkg.status).started" /> <service-uptime class="g-card" [started]="pkg.statusInfo.started" />
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" /> <service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
@if (errors() | async; as errors) { @if (errors() | async; as errors) {
@@ -179,7 +179,7 @@ export class ServiceRoute {
protected readonly pkg = computed(() => this.services()[this.id() || '']) protected readonly pkg = computed(() => this.services()[this.id() || ''])
protected readonly health = computed((pkg = this.pkg()) => protected readonly health = computed((pkg = this.pkg()) =>
pkg ? toHealthCheck(pkg.status) : [], pkg ? toHealthCheck(pkg.statusInfo) : [],
) )
protected readonly status = computed((pkg = this.pkg()) => protected readonly status = computed((pkg = this.pkg()) =>
@@ -202,8 +202,10 @@ export class ServiceRoute {
) )
} }
function toHealthCheck(status: T.MainStatus): T.NamedHealthCheckResult[] { function toHealthCheck(statusInfo: T.StatusInfo): T.NamedHealthCheckResult[] {
return status.main !== 'running' || isEmptyObject(status.health) return statusInfo.desired.main !== 'running' ||
!statusInfo.started ||
isEmptyObject(statusInfo.health)
? [] ? []
: Object.values(status.health).filter(h => !!h) : Object.values(statusInfo.health).filter(h => !!h)
} }

View File

@@ -28,7 +28,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
<tui-icon icon="@tui.check" class="g-positive" /> <tui-icon icon="@tui.check" class="g-positive" />
{{ 'complete' | i18n }} {{ 'complete' | i18n }}
} @else { } @else {
@if ((pkg.key | tuiMapper: toStatus | async) === 'backingUp') { @if ((pkg.key | tuiMapper: toStatus | async) === 'backing-up') {
<tui-loader size="s" /> <tui-loader size="s" />
{{ 'backing up' | i18n }} {{ 'backing up' | i18n }}
} @else { } @else {
@@ -65,5 +65,5 @@ export class BackupProgressComponent {
) )
readonly toStatus = (pkgId: string) => readonly toStatus = (pkgId: string) =>
this.patch.watch$('packageData', pkgId, 'status', 'main') this.patch.watch$('packageData', pkgId, 'statusInfo', 'desired', 'main')
} }

View File

@@ -26,7 +26,7 @@ const allowedStatuses = {
'restoring', 'restoring',
'stopping', 'stopping',
'starting', 'starting',
'backingUp', 'backing-up',
]), ]),
} }
@@ -45,9 +45,7 @@ export class ActionService {
const { pkgInfo, actionInfo } = data const { pkgInfo, actionInfo } = data
if ( if (
allowedStatuses[actionInfo.metadata.allowedStatuses].has( allowedStatuses[actionInfo.metadata.allowedStatuses].has(pkgInfo.status)
pkgInfo.mainStatus,
)
) { ) {
if (actionInfo.metadata.hasInput) { if (actionInfo.metadata.hasInput) {
this.formDialog.open<PackageActionData>(ActionInputModal, { this.formDialog.open<PackageActionData>(ActionInputModal, {

View File

@@ -1921,8 +1921,9 @@ export namespace Mock {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk', s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/bitcoin-core.svg', icon: '/assets/img/service-icons/bitcoin-core.svg',
lastBackup: null, lastBackup: null,
status: { statusInfo: {
main: 'running', error: null,
desired: { main: 'running' },
started: new Date().toISOString(), started: new Date().toISOString(),
health: {}, health: {},
}, },
@@ -2201,8 +2202,11 @@ export namespace Mock {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk', s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/btc-rpc-proxy.png', icon: '/assets/img/service-icons/btc-rpc-proxy.png',
lastBackup: null, lastBackup: null,
status: { statusInfo: {
main: 'stopped', desired: { main: 'stopped' },
started: null,
health: {},
error: null,
}, },
actions: {}, actions: {},
serviceInterfaces: { serviceInterfaces: {
@@ -2246,8 +2250,11 @@ export namespace Mock {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk', s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/lnd.png', icon: '/assets/img/service-icons/lnd.png',
lastBackup: null, lastBackup: null,
status: { statusInfo: {
main: 'stopped', desired: { main: 'stopped' },
error: null,
health: {},
started: null,
}, },
actions: { actions: {
config: { config: {

View File

@@ -762,12 +762,12 @@ export class MockApiService extends ApiService {
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/` const appPath = `/packageData/${id}/statusInfo/desired/main`
const appPatch: ReplaceOperation<T.MainStatus['main']>[] = [ const appPatch: ReplaceOperation<T.DesiredStatus['main']>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: appPath, path: appPath,
value: 'backingUp', value: 'backing-up',
}, },
] ]
this.mockRevision(appPatch) this.mockRevision(appPatch)
@@ -1073,17 +1073,18 @@ 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` const path = `/packageData/${params.id}/statusInfo`
await pauseFor(2000) await pauseFor(2000)
setTimeout(async () => { setTimeout(async () => {
const patch2: ReplaceOperation<T.MainStatus & { main: 'running' }>[] = [ const patch2: ReplaceOperation<T.StatusInfo>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path, path,
value: { value: {
main: 'running', error: null,
desired: { main: 'running' },
started: new Date().toISOString(), started: new Date().toISOString(),
health: { health: {
'ephemeral-health-check': { 'ephemeral-health-check': {
@@ -1118,14 +1119,14 @@ export class MockApiService extends ApiService {
this.mockRevision(patch2) this.mockRevision(patch2)
}, 2000) }, 2000)
const originalPatch: ReplaceOperation< const originalPatch: ReplaceOperation<T.StatusInfo>[] = [
T.MainStatus & { main: 'starting' }
>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path, path,
value: { value: {
main: 'starting', desired: { main: 'running' },
started: null,
error: null,
health: {}, health: {},
}, },
}, },
@@ -1140,15 +1141,16 @@ export class MockApiService extends ApiService {
params: RR.RestartPackageReq, params: RR.RestartPackageReq,
): Promise<RR.RestartPackageRes> { ): Promise<RR.RestartPackageRes> {
await pauseFor(2000) await pauseFor(2000)
const path = `/packageData/${params.id}/status` const path = `/packageData/${params.id}/statusInfo`
setTimeout(async () => { setTimeout(async () => {
const patch2: ReplaceOperation<T.MainStatus & { main: 'running' }>[] = [ const patch2: ReplaceOperation<T.StatusInfo>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path, path,
value: { value: {
main: 'running', desired: { main: 'running' },
error: null,
started: new Date().toISOString(), started: new Date().toISOString(),
health: { health: {
'ephemeral-health-check': { 'ephemeral-health-check': {
@@ -1183,12 +1185,15 @@ export class MockApiService extends ApiService {
this.mockRevision(patch2) this.mockRevision(patch2)
}, this.revertTime) }, this.revertTime)
const patch: ReplaceOperation<T.MainStatus & { main: 'restarting' }>[] = [ const patch: ReplaceOperation<T.StatusInfo>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path, path,
value: { value: {
main: 'restarting', desired: { main: 'restarting' },
started: null,
error: null,
health: {},
}, },
}, },
] ]
@@ -1200,24 +1205,34 @@ 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` const path = `/packageData/${params.id}/statusInfo`
setTimeout(() => { setTimeout(() => {
const patch2: ReplaceOperation<T.MainStatus & { main: 'stopped' }>[] = [ const patch2: ReplaceOperation<T.StatusInfo>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: path, path: path,
value: { main: 'stopped' }, value: {
desired: { main: 'stopped' },
error: null,
health: {},
started: null,
},
}, },
] ]
this.mockRevision(patch2) this.mockRevision(patch2)
}, this.revertTime) }, this.revertTime)
const patch: ReplaceOperation<T.MainStatus & { main: 'stopping' }>[] = [ const patch: ReplaceOperation<T.StatusInfo>[] = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path: path, path: path,
value: { main: 'stopping' }, value: {
desired: { main: 'stopped' },
error: null,
health: {},
started: new Date().toISOString(),
},
}, },
] ]

View File

@@ -232,8 +232,11 @@ export const mockPatchData: DataModel = {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk', s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/bitcoin-core.svg', icon: '/assets/img/service-icons/bitcoin-core.svg',
lastBackup: new Date(new Date().valueOf() - 604800001).toISOString(), lastBackup: new Date(new Date().valueOf() - 604800001).toISOString(),
status: { statusInfo: {
main: 'stopped', desired: { main: 'stopped' },
error: null,
health: {},
started: null,
}, },
// status: { // status: {
// main: 'error', // main: 'error',
@@ -518,8 +521,11 @@ export const mockPatchData: DataModel = {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk', s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/lnd.png', icon: '/assets/img/service-icons/lnd.png',
lastBackup: null, lastBackup: null,
status: { statusInfo: {
main: 'stopped', desired: { main: 'stopped' },
error: null,
health: {},
started: null,
}, },
actions: { actions: {
config: { config: {

View File

@@ -11,6 +11,7 @@ import deepEqual from 'fast-deep-equal'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { isInstalled } from 'src/app/utils/get-package-data' import { isInstalled } from 'src/app/utils/get-package-data'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
import { getInstalledBaseStatus } from './pkg-status-rendering.service'
export type AllDependencyErrors = Record<string, PkgDependencyErrors> export type AllDependencyErrors = Record<string, PkgDependencyErrors>
export type PkgDependencyErrors = Record<string, DependencyError | null> export type PkgDependencyErrors = Record<string, DependencyError | null>
@@ -153,7 +154,7 @@ export class DepErrorService {
} }
} }
const depStatus = dep.status.main const depStatus = getInstalledBaseStatus(dep.statusInfo)
// not running // not running
if (depStatus !== 'running' && depStatus !== 'starting') { if (depStatus !== 'running' && depStatus !== 'starting') {
@@ -165,7 +166,7 @@ export class DepErrorService {
// health check failure // health check failure
if (depStatus === 'running' && currentDep?.kind === 'running') { if (depStatus === 'running' && currentDep?.kind === 'running') {
for (let id of currentDep.healthChecks) { for (let id of currentDep.healthChecks) {
const check = dep.status.health[id] const check = dep.statusInfo.health[id]
if (check && check?.result !== 'success') { if (check && check?.result !== 'success') {
return { return {
type: 'healthChecksFailed', type: 'healthChecksFailed',

View File

@@ -13,7 +13,7 @@ export function renderPkgStatus(pkg: PackageDataEntry): PackageStatus {
if (pkg.stateInfo.state === 'installed') { if (pkg.stateInfo.state === 'installed') {
primary = getInstalledPrimaryStatus(pkg) primary = getInstalledPrimaryStatus(pkg)
health = getHealthStatus(pkg.status) health = getHealthStatus(pkg.statusInfo)
} else { } else {
primary = pkg.stateInfo.state primary = pkg.stateInfo.state
} }
@@ -21,33 +21,43 @@ export function renderPkgStatus(pkg: PackageDataEntry): PackageStatus {
return { primary, health } return { primary, health }
} }
export function getInstalledPrimaryStatus({ export function getInstalledBaseStatus(statusInfo: T.StatusInfo): BaseStatus {
tasks,
status,
}: T.PackageDataEntry): PrimaryStatus {
if ( if (
Object.values(tasks).some(t => t.active && t.task.severity === 'critical') statusInfo.desired.main === 'running' &&
) { (!statusInfo.started ||
return 'taskRequired' Object.values(statusInfo.health)
} .filter(h => !!h)
.some(h => h.result === 'starting'))
if (
Object.values(status.main === 'running' && status.health)
.filter(h => !!h)
.some(h => h.result === 'starting')
) { ) {
return 'starting' return 'starting'
} }
return status.main if (statusInfo.desired.main === 'stopped' && statusInfo.started) {
return 'stopping'
}
return statusInfo.desired.main
} }
function getHealthStatus(status: T.MainStatus): T.HealthStatus | null { export function getInstalledPrimaryStatus({
if (status.main !== 'running' || !status.main) { tasks,
statusInfo,
}: T.PackageDataEntry): PrimaryStatus {
if (
Object.values(tasks).some(t => t.active && t.task.severity === 'critical')
) {
return 'task-required'
}
return getInstalledBaseStatus(statusInfo)
}
function getHealthStatus(statusInfo: T.StatusInfo): T.HealthStatus | null {
if (statusInfo.desired.main !== 'running') {
return null return null
} }
const values = Object.values(status.health).filter(h => !!h) const values = Object.values(statusInfo.health).filter(h => !!h)
if (values.some(h => h.result === 'failure')) { if (values.some(h => h.result === 'failure')) {
return 'failure' return 'failure'
@@ -70,7 +80,7 @@ export interface StatusRendering {
showDots?: boolean showDots?: boolean
} }
export type PrimaryStatus = export type BaseStatus =
| 'installing' | 'installing'
| 'updating' | 'updating'
| 'removing' | 'removing'
@@ -80,10 +90,11 @@ export type PrimaryStatus =
| 'stopping' | 'stopping'
| 'restarting' | 'restarting'
| 'stopped' | 'stopped'
| 'backingUp' | 'backing-up'
| 'taskRequired'
| 'error' | 'error'
export type PrimaryStatus = BaseStatus | 'task-required'
export type DependencyStatus = 'warning' | 'satisfied' export type DependencyStatus = 'warning' | 'satisfied'
export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = { export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
@@ -122,7 +133,7 @@ export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
color: 'dark-shade', color: 'dark-shade',
showDots: false, showDots: false,
}, },
backingUp: { 'backing-up': {
display: 'Backing Up', display: 'Backing Up',
color: 'primary', color: 'primary',
showDots: true, showDots: true,
@@ -137,7 +148,7 @@ export const PrimaryRendering: Record<PrimaryStatus, StatusRendering> = {
color: 'success', color: 'success',
showDots: false, showDots: false,
}, },
taskRequired: { 'task-required': {
display: 'Task Required', display: 'Task Required',
color: 'warning', color: 'warning',
showDots: false, showDots: false,