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. Install StartTunnel:
1. Run the StartTunnel install script:
```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"
```
curl -fsSL https://start9labs.github.io/start-tunnel | sh
5. [Initialize the web interface](#web-interface) (recommended)
1. [Initialize the web interface](#web-interface) (recommended)
## Updating
Simply re-run the install command:
```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
@@ -84,7 +84,7 @@ Enable the web interface (recommended in most cases) to access your StartTunnel
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):

View File

@@ -22,7 +22,7 @@ parse_essential_db_info() {
RAM_GB="unknown"
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)
rm -f "$DB_DUMP"

2
core/Cargo.lock generated
View File

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

View File

@@ -11,6 +11,7 @@ use rpc_toolkit::yajrc::{
};
use serde::{Deserialize, Serialize};
use tokio::task::JoinHandle;
use ts_rs::TS;
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 details: String,
pub debug: String,

View File

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

View File

@@ -1,15 +1,12 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use models::{HostId, PackageId};
use reqwest::Url;
use models::PackageId;
use rpc_toolkit::{Context, HandlerExt, ParentHandler, from_fn_async};
use serde::{Deserialize, Serialize};
use crate::context::CliContext;
#[allow(unused_imports)]
use crate::prelude::*;
use crate::util::serde::{Base32, Base64};
pub mod backup_bulk;
pub mod os;
@@ -58,13 +55,3 @@ pub fn package_backup<C: Context>() -> ParentHandler<C> {
.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::effects::callbacks::ServiceCallbacks;
use crate::shutdown::Shutdown;
use crate::status::DesiredStatus;
use crate::util::io::delete_file;
use crate::util::lshw::LshwDevice;
use crate::util::sync::{SyncMutex, SyncRwLock, Watch};
@@ -416,46 +417,34 @@ impl RpcContext {
}
}
}
for id in
self.db
.mutate::<Vec<PackageId>>(|db| {
for (package_id, action_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()?
{
pde.as_tasks_mut().mutate(|tasks| {
Ok(update_tasks(tasks, package_id, action_id, input, false))
})?;
}
self.db
.mutate(|db| {
for (package_id, action_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()? {
pde.as_tasks_mut().mutate(|tasks| {
Ok(update_tasks(tasks, package_id, action_id, input, false))
})?;
}
}
db.as_public()
.as_package_data()
.as_entries()?
}
for (_, pde) in db.as_public_mut().as_package_data_mut().as_entries_mut()? {
if pde
.as_tasks()
.de()?
.into_iter()
.filter_map(|(id, pkg)| {
(|| {
if pkg.as_tasks().de()?.into_iter().any(|(_, t)| {
t.active && t.task.severity == TaskSeverity::Critical
}) {
Ok(Some(id))
} else {
Ok(None)
}
})()
.transpose()
})
.collect()
})
.await
.result?
{
let svc = self.services.get(&id).await;
if let Some(svc) = &*svc {
svc.stop(procedure_id.clone(), false).await?;
}
}
.any(|(_, t)| t.active && t.task.severity == TaskSeverity::Critical)
{
pde.as_status_info_mut()
.as_desired_mut()
.ser(&DesiredStatus::Stopped)?;
}
}
Ok(())
})
.await
.result?;
check_tasks.complete();
Ok(())

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,11 @@ use std::sync::{Arc, Weak};
use std::time::Duration;
use clap::builder::ValueParserFactory;
use futures::{AsyncWriteExt, StreamExt};
use imbl_value::{InOMap, InternedString};
use futures::StreamExt;
use imbl_value::InternedString;
use models::{FromStrParser, InvalidId, PackageId};
use rpc_toolkit::yajrc::RpcError;
use rpc_toolkit::{GenericRpcMethod, RpcRequest, RpcResponse};
use rpc_toolkit::{RpcRequest, RpcResponse};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
@@ -17,7 +17,7 @@ use tokio::sync::Mutex;
use tokio::time::Instant;
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::block_dev::BlockDev;
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 imbl::OrdMap;
use imbl_value::InternedString;
use itertools::Itertools;
use models::{GatewayId, OptionExt, PackageId};
use patch_db::json_ptr::JsonPointer;
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 {
const UI_DIR: &'static Dir<'static>;
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>>);
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::signature::SignatureAuth;
use crate::net::static_server::{bad_request, not_found, server_error};
use crate::net::web_server::{Accept, WebServer};
use crate::prelude::*;
use crate::registry::context::RegistryContext;
use crate::registry::device_info::DeviceInfoMiddleware;

View File

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

View File

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

View File

@@ -130,11 +130,11 @@ impl Handler<RunAction> for ServiceActor {
ref action_id,
input,
}: RunAction,
jobs: &BackgroundJobQueue,
_: &BackgroundJobQueue,
) -> Self::Response {
let container = &self.0.persistent_container;
let package_id = &self.0.id;
let action = self
let pde = self
.0
.ctx
.db
@@ -143,9 +143,10 @@ impl Handler<RunAction> for ServiceActor {
.into_public()
.into_package_data()
.into_idx(package_id)
.or_not_found(package_id)?
.into_actions()
.into_idx(action_id)
.or_not_found(package_id)?;
let action = pde
.as_actions()
.as_idx(action_id)
.or_not_found(lazy_format!("{package_id} action {action_id}"))?
.de()?;
if matches!(&action.visibility, ActionVisibility::Disabled(_)) {
@@ -154,7 +155,7 @@ impl Handler<RunAction> for ServiceActor {
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 {
AllowedStatuses::OnlyRunning => !running,
AllowedStatuses::OnlyStopped => running,
@@ -177,44 +178,21 @@ impl Handler<RunAction> for ServiceActor {
.await
.with_kind(ErrorKind::Action)?;
let package_id = package_id.clone();
for to_stop in self
.0
self.0
.ctx
.db
.mutate(|db| {
let mut to_stop = Vec::new();
for (id, 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()? {
if pde.as_tasks_mut().mutate(|tasks| {
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
.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?;
}
}
.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,
};
if active && task.severity == TaskSeverity::Critical {
context.stop(procedure_id, false).await?;
}
context
.seed
.ctx
.db
.mutate(|db| {
db.as_public_mut()
let pde = db
.as_public_mut()
.as_package_data_mut()
.as_idx_mut(src_id)
.or_not_found(src_id)?
.as_tasks_mut()
.or_not_found(src_id)?;
if active && task.severity == TaskSeverity::Critical {
pde.as_status_info_mut().stop()?;
}
pde.as_tasks_mut()
.insert(&replay_id, &TaskEntry { active, task })
})
.await

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,9 +34,7 @@ use crate::service::effects::handler;
use crate::service::rpc::{
CallbackHandle, CallbackId, CallbackParams, ExitParams, InitKind, InitParams,
};
use crate::service::start_stop::StartStop;
use crate::service::transition::{TransitionKind, TransitionState};
use crate::service::{RunningStatus, Service, rpc};
use crate::service::{Service, rpc};
use crate::util::Invoke;
use crate::util::io::create_file;
use crate::util::rpc_client::UnixRpcClient;
@@ -49,41 +47,15 @@ const RPC_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
pub struct ServiceState {
// indicates whether the service container runtime has been initialized yet
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:
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 {
pub fn new(desired_state: StartStop) -> Self {
pub fn new() -> Self {
Self {
rt_initialized: false,
running_status: 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 {
#[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
.lxc_manager
.create(
@@ -305,7 +277,7 @@ impl PersistentContainer {
assets,
images,
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,
destroyed: false,
})

View File

@@ -203,11 +203,6 @@ impl serde::Serialize for Sandbox {
pub struct CallbackId(u64);
impl CallbackId {
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 res = Arc::downgrade(&this);
container

View File

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

View File

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

View File

@@ -9,23 +9,7 @@ pub enum StartStop {
}
impl StartStop {
pub(crate) fn is_start(&self) -> bool {
pub fn is_start(&self) -> bool {
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::sync::Arc;
use futures::FutureExt;
use futures::future::BoxFuture;
use futures::{FutureExt, TryFutureExt};
use models::ProcedureName;
use rpc_toolkit::yajrc::RpcError;
use super::TempDesiredRestore;
use crate::disk::mount::filesystem::ReadWrite;
use crate::prelude::*;
use crate::rpc_continuations::Guid;
use crate::service::ServiceActor;
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::{ConflictBuilder, Handler};
use crate::util::future::RemoteCancellable;
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 path: PathBuf,
}
@@ -28,63 +72,31 @@ impl Handler<Backup> for ServiceActor {
async fn handle(
&mut self,
id: Guid,
backup: Backup,
jobs: &BackgroundJobQueue,
Backup { path }: Backup,
_: &BackgroundJobQueue,
) -> 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 transition = RemoteCancellable::new(async move {
temp.stop();
current
.wait_for(|s| s.running_status.is_none())
.await
.with_kind(ErrorKind::Unknown)?;
let transition = async move {
async {
let backup_guard = seed
.persistent_container
.mount_backup(path, ReadWrite)
.await?;
seed.persistent_container
.execute::<NoOutput>(id, ProcedureName::CreateBackup, Value::Null, None)
.await?;
backup_guard.unmount(true).await?;
let backup_guard = seed
.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)?;
Ok::<_, Error>(())
}
drop(temp);
Ok::<_, Arc<Error>>(())
});
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;
.await
.map_err(RpcError::from)
}
Ok(transition
.map(|r| {
r.ok_or_else(|| Error::new(eyre!("Backup canceled"), ErrorKind::Cancelled))?
.map_err(|e| e.clone_output())
})
.boxed())
.shared();
self.0.backup.replace(Some(transition.clone().boxed()));
Ok(transition.map_err(Error::from).boxed())
}
}

View File

@@ -1,91 +1,42 @@
use std::sync::Arc;
use futures::FutureExt;
use futures::future::BoxFuture;
use futures::{Future, FutureExt};
use tokio::sync::watch;
use super::persistent_container::ServiceState;
use crate::service::start_stop::StartStop;
use crate::util::actor::background::BackgroundJobQueue;
use crate::util::future::{CancellationHandle, RemoteCancellable};
use crate::prelude::*;
use crate::service::ServiceActorSeed;
pub mod backup;
pub mod restart;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum TransitionKind {
BackingUp,
Restarting,
Starting,
Stopping,
}
/// Used only in the manager/mod and is used to keep track of the state of the manager during the
/// transitional states
pub struct TransitionState {
cancel_handle: CancellationHandle,
kind: TransitionKind,
pub struct Transition<'a> {
pub kind: TransitionKind,
pub future: BoxFuture<'a, Result<(), Error>>,
}
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 {
f.debug_struct("TransitionState")
f.debug_struct("Transition")
.field("kind", &self.kind)
.finish_non_exhaustive()
}
}
impl TransitionState {
pub fn kind(&self) -> TransitionKind {
self.kind
impl ServiceActorSeed {
pub fn start(&self) -> Transition<'_> {
Transition {
kind: TransitionKind::Starting,
future: self.persistent_container.start().boxed(),
}
}
pub async fn abort(mut self) {
self.cancel_handle.cancel_and_wait().await
}
fn new(
task: impl Future<Output = ()> + Send + 'static,
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,
pub fn stop(&self) -> Transition<'_> {
Transition {
kind: TransitionKind::Stopping,
future: self.persistent_container.stop().boxed(),
}
}
}
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 chrono::{DateTime, Utc};
use imbl::OrdMap;
use models::{ErrorData, HealthCheckId};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use self::health_check::HealthCheckId;
use crate::prelude::*;
use crate::service::start_stop::StartStop;
use crate::status::health_check::NamedHealthCheckResult;
pub mod health_check;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, TS)]
#[serde(tag = "main")]
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
#[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")]
pub enum MainStatus {
Error {
on_rebuild: StartStop,
message: String,
debug: Option<String>,
},
pub enum DesiredStatus {
Stopped,
Restarting,
Stopping,
Starting {
#[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,
},
Running,
BackingUp { on_complete: StartStop },
}
impl MainStatus {
impl Default for DesiredStatus {
fn default() -> Self {
Self::Stopped
}
}
impl DesiredStatus {
pub fn running(&self) -> bool {
match self {
MainStatus::Starting { .. }
| MainStatus::Running { .. }
| MainStatus::Restarting
| MainStatus::BackingUp {
Self::Running
| Self::Restarting
| Self::BackingUp {
on_complete: StartStop::Start,
}
| MainStatus::Error {
on_rebuild: StartStop::Start,
..
} => true,
MainStatus::Stopped
| MainStatus::Stopping { .. }
| MainStatus::BackingUp {
Self::Stopped
| Self::BackingUp {
on_complete: StartStop::Stop,
}
| MainStatus::Error {
on_rebuild: StartStop::Stop,
..
} => 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 {
MainStatus::BackingUp {
on_complete: if self.running() {
StartStop::Start
} else {
StartStop::Stop
},
Self::BackingUp {
on_complete: self.run_state(),
}
}
pub fn health(&self) -> Option<&OrdMap<HealthCheckId, NamedHealthCheckResult>> {
pub fn stop(&self) -> Self {
match self {
MainStatus::Running { health, .. } | MainStatus::Starting { health } => Some(health),
MainStatus::BackingUp { .. }
| MainStatus::Stopped
| MainStatus::Stopping { .. }
| MainStatus::Restarting
| MainStatus::Error { .. } => None,
Self::BackingUp { .. } => Self::BackingUp {
on_complete: StartStop::Stop,
},
_ => Self::Stopped,
}
}
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};
AsyncSmtpTransport::<Tokio1Executor>::relay(&server)?
.port(port)
.credentials(Credentials::new(login, password))
.build()
.send(

View File

@@ -3,7 +3,7 @@ use imbl::HashMap;
use imbl_value::InternedString;
use itertools::Itertools;
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 ts_rs::TS;

View File

@@ -33,7 +33,7 @@ use crate::rpc_continuations::{OpenAuthedContinuations, RpcContinuations};
use crate::tunnel::TUNNEL_DEFAULT_LISTEN;
use crate::tunnel::api::tunnel_api;
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::collections::OrdMapIterMut;
use crate::util::io::read_file_to_string;
@@ -100,7 +100,17 @@ impl TunnelContext {
let db_path = datadir.join("tunnel.db");
let db = TypedPatchDb::<TunnelDatabase>::load_or_init(
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?;
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!(
"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() {
", password,"
} else {
@@ -496,7 +496,7 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
println!("{password}");
println!();
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"
));
} else {
@@ -516,12 +516,22 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
.pop()
.map(Pem)
.or_not_found("certificate in chain")?;
println!("📝 Root SSL Certificate:");
println!("📝 Root CA:");
print!("{cert}");
println!(concat!(
"If you haven't already, ",
"trust the certificate in your system keychain and/or browser."
"To trust your StartTunnel Root CA (above):\n",
" 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(());
@@ -534,28 +544,14 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
.await?,
)?;
suggested_addrs.sort_by_cached_key(|a| match a {
IpAddr::V4(a) => {
if a.is_loopback() {
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
}
}
suggested_addrs.retain(|ip| match ip {
IpAddr::V4(a) => !a.is_loopback() && !a.is_private(),
IpAddr::V6(a) => !a.is_loopback() && !a.is_unicast_link_local(),
});
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?
} else if suggested_addrs.len() > 16 {
prompt(
@@ -565,22 +561,7 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
)
.await?
} else {
*choose_custom_display("Listen Address:", &suggested_addrs, |a| match a {
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?
*choose("Listen Address:", &suggested_addrs).await?
};
println!(concat!(
@@ -608,8 +589,8 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
impl std::fmt::Display for Choice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Generate => write!(f, "Generate an SSL certificate"),
Self::Provide => write!(f, "Provide your own certificate and key"),
Self::Generate => write!(f, "Generate"),
Self::Provide => write!(f, "Provide"),
}
}
}
@@ -617,7 +598,7 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
let choice = choose(
concat!(
"Select whether to generate an SSL certificate ",
"or provide your own certificate and key:"
"or provide your own certificate (and key):"
),
&options,
)
@@ -631,35 +612,35 @@ pub async fn init_web(ctx: CliContext) -> Result<(), Error> {
)?
.filter(|a| !a.ip().is_unspecified());
let default_prompt = if let Some(listen) = listen {
format!("Subject Alternative Name(s) [{}]: ", listen.ip())
let san_info = if let Some(listen) = listen {
vec![InternedString::from_display(&listen.ip())]
} 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>(
"web.generate-certificate",
to_value(&GenerateCertParams { subject: san_info })?,

View File

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

View File

@@ -14,7 +14,7 @@ use std::time::Duration;
use bytes::{Buf, BytesMut};
use clap::builder::ValueParserFactory;
use futures::future::{BoxFuture, Fuse};
use futures::{FutureExt, Stream, StreamExt, TryStreamExt};
use futures::{FutureExt, Stream, TryStreamExt};
use helpers::{AtomicFile, NonDetachingJoinHandle};
use inotify::{EventMask, EventStream, Inotify, WatchMask};
use models::FromStrParser;
@@ -22,7 +22,7 @@ use nix::unistd::{Gid, Uid};
use serde::{Deserialize, Serialize};
use tokio::fs::{File, OpenOptions};
use tokio::io::{
AsyncRead, AsyncReadExt, AsyncSeek, AsyncWrite, AsyncWriteExt, DuplexStream, ReadBuf, SeekFrom,
AsyncRead, AsyncReadExt, AsyncSeek, AsyncWrite, AsyncWriteExt, DuplexStream, ReadBuf,
WriteHalf, duplex,
};
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::pin::Pin;
use std::sync::atomic::AtomicUsize;
@@ -28,6 +28,7 @@ fn annotate_lock<F, T>(f: F, id: usize, write: bool) -> T
where
F: FnOnce() -> T,
{
use std::collections::BTreeMap;
std::thread_local! {
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_14;
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 {
#[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_13(Wrapper<v0_4_0_alpha_13::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),
}
@@ -231,7 +233,8 @@ impl Version {
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_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) => {
return Err(Error::new(
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_13(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(),
}
}

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
# change timezone
rm -f /etc/localtime
ln -s /usr/share/zoneinfo/Etc/UTC /etc/localtime
ln -sf /usr/share/zoneinfo/Etc/UTC /etc/localtime
rm /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
dpkg-reconfigure --frontend noninteractive locales
if ! getent group | grep '^startos:'; then
groupadd startos
fi

View File

@@ -13,8 +13,8 @@ import {
ExportServiceInterfaceParams,
ServiceInterface,
CreateTaskParams,
MainStatus,
MountParams,
StatusInfo,
} from "./osBindings"
import {
PackageId,
@@ -66,7 +66,7 @@ export type Effects = {
getStatus(options: {
packageId?: PackageId
callback?: () => void
}): Promise<MainStatus>
}): Promise<StatusInfo>
/** indicate to the host os what runstate the service is in */
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.
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 { DataUrl } from "./DataUrl"
import type { Hosts } from "./Hosts"
import type { MainStatus } from "./MainStatus"
import type { PackageState } from "./PackageState"
import type { ReplayId } from "./ReplayId"
import type { ServiceInterface } from "./ServiceInterface"
import type { ServiceInterfaceId } from "./ServiceInterfaceId"
import type { StatusInfo } from "./StatusInfo"
import type { TaskEntry } from "./TaskEntry"
export type PackageDataEntry = {
stateInfo: PackageState
s9pk: string
status: MainStatus
statusInfo: StatusInfo
registry: string | null
developerKey: string
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 { DataUrl } from "./DataUrl"
export { Dependencies } from "./Dependencies"
export { DependencyKind } from "./DependencyKind"
export { DependencyMetadata } from "./DependencyMetadata"
export { DependencyRequirement } from "./DependencyRequirement"
export { DepInfo } from "./DepInfo"
export { Description } from "./Description"
export { DesiredStatus } from "./DesiredStatus"
export { DestroySubcontainerFsParams } from "./DestroySubcontainerFsParams"
export { DeviceFilter } from "./DeviceFilter"
export { DnsSettings } from "./DnsSettings"
@@ -72,6 +72,7 @@ export { Duration } from "./Duration"
export { EchoParams } from "./EchoParams"
export { EditSignerParams } from "./EditSignerParams"
export { EncryptedWire } from "./EncryptedWire"
export { ErrorData } from "./ErrorData"
export { EventId } from "./EventId"
export { ExportActionParams } from "./ExportActionParams"
export { ExportServiceInterfaceParams } from "./ExportServiceInterfaceParams"
@@ -123,7 +124,6 @@ export { LoginParams } from "./LoginParams"
export { LshwDevice } from "./LshwDevice"
export { LshwDisplay } from "./LshwDisplay"
export { LshwProcessor } from "./LshwProcessor"
export { MainStatus } from "./MainStatus"
export { Manifest } from "./Manifest"
export { MaybeUtf8String } from "./MaybeUtf8String"
export { MebiBytes } from "./MebiBytes"
@@ -201,6 +201,7 @@ export { SignAssetParams } from "./SignAssetParams"
export { SignerInfo } from "./SignerInfo"
export { SmtpValue } from "./SmtpValue"
export { StartStop } from "./StartStop"
export { StatusInfo } from "./StatusInfo"
export { TaskCondition } from "./TaskCondition"
export { TaskEntry } from "./TaskEntry"
export { TaskInput } from "./TaskInput"

View File

@@ -61,7 +61,7 @@ import {
} from "../../base/lib/inits"
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
type AnyNeverCond<T extends any[], Then, Else> =

4
web/package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,8 @@ import { SUBNETS_ADD } from './add'
</button>
</td>
</tr>
} @empty {
<div class="placeholder">No subnets</div>
}
</tbody>
</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 {
display: flex;
justify-content: center;

View File

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

View File

@@ -37,7 +37,7 @@ import {
<span class="loading-dots"></span>
}
@if ($any(pkg().status)?.started; as started) {
@if (pkg().statusInfo.started; as started) {
<service-uptime [started]="started" />
}
</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 { ApiService } from 'src/app/services/api/embassy-api.service'
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'
@Component({
@@ -161,7 +162,7 @@ export class ServiceTaskComponent {
pkgInfo: {
id: this.task().packageId,
title,
mainStatus: pkg.status.main,
status: getInstalledBaseStatus(pkg.statusInfo),
icon: pkg.icon,
},
actionInfo: { id: this.task().actionId, metadata },

View File

@@ -114,11 +114,10 @@ import { distinctUntilChanged } from 'rxjs/operators'
})
export class ServiceUptimeComponent {
protected readonly uptime$ = timer(0, 1000).pipe(
map(() =>
this.started()
? Math.max(Date.now() - new Date(this.started()).getTime(), 0)
: 0,
),
map(() => {
const started = this.started()
return started ? Math.max(Date.now() - new Date(started).getTime(), 0) : 0
}),
distinctUntilChanged(),
map(delta => ({
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 [style.grid-area]="'2 / 2'">{{ manifest.version }}</td>
<td class="uptime">
@if ($any(pkg.status)?.started; as started) {
@if (pkg.statusInfo.started; as started) {
<span>{{ 'Uptime' | i18n }}:</span>
<service-uptime [started]="started" />
} @else {

View File

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

View File

@@ -11,6 +11,7 @@ import { tuiPure } from '@taiga-ui/cdk'
import { TuiDataList, TuiDropdown, TuiButton } from '@taiga-ui/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { InterfaceService } from '../../../components/interfaces/interface.service'
import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({
selector: 'app-ui-launch',
@@ -70,7 +71,7 @@ export class UILaunchComponent {
}
get isRunning(): boolean {
return this.pkg.status.main === 'running'
return getInstalledPrimaryStatus(this.pkg) === 'running'
}
@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 { ApiService } from 'src/app/services/api/embassy-api.service'
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'
export type PackageActionData = {
@@ -34,7 +35,7 @@ export type PackageActionData = {
id: string
title: string
icon: string
mainStatus: T.MainStatus['main']
status: BaseStatus
}
actionInfo: {
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 { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionComponent } from '../components/action.component'
import {
BaseStatus,
getInstalledBaseStatus,
} from 'src/app/services/pkg-status-rendering.service'
@Component({
template: `
@@ -27,7 +31,7 @@ import { ServiceActionComponent } from '../components/action.component'
<button
tuiCell
[action]="a"
(click)="handle(pkg.mainStatus, pkg.icon, pkg.manifest, a)"
(click)="handle(pkg.status, pkg.icon, pkg.manifest, a)"
></button>
}
</section>
@@ -77,7 +81,7 @@ export default class ServiceActionsRoute {
? 'Other'
: 'General'
return {
mainStatus: pkg.status.main,
status: getInstalledBaseStatus(pkg.statusInfo),
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.entries(pkg.actions)
@@ -131,13 +135,13 @@ export default class ServiceActionsRoute {
}
handle(
mainStatus: T.MainStatus['main'],
status: BaseStatus,
icon: string,
{ id, title }: T.Manifest,
action: T.ActionMetadata & { id: string },
) {
this.actions.present({
pkgInfo: { id, title, icon, mainStatus },
pkgInfo: { id, title, icon, status },
actionInfo: { id: action.id, metadata: action },
})
}

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ import { ServiceUptimeComponent } from '../components/uptime.component'
@Component({
template: `
@if (pkg(); as pkg) {
@if (pkg.status.main === 'error') {
@if (pkg.statusInfo.error) {
<service-error [pkg]="pkg" />
} @else if (installing()) {
<service-install-progress [pkg]="pkg" />
@@ -45,9 +45,9 @@ import { ServiceUptimeComponent } from '../components/uptime.component'
}
</service-status>
@if (status() !== 'backingUp') {
@if (status() !== 'backing-up') {
<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'" />
@if (errors() | async; as errors) {
@@ -179,7 +179,7 @@ export class ServiceRoute {
protected readonly pkg = computed(() => this.services()[this.id() || ''])
protected readonly health = computed((pkg = this.pkg()) =>
pkg ? toHealthCheck(pkg.status) : [],
pkg ? toHealthCheck(pkg.statusInfo) : [],
)
protected readonly status = computed((pkg = this.pkg()) =>
@@ -202,8 +202,10 @@ export class ServiceRoute {
)
}
function toHealthCheck(status: T.MainStatus): T.NamedHealthCheckResult[] {
return status.main !== 'running' || isEmptyObject(status.health)
function toHealthCheck(statusInfo: T.StatusInfo): T.NamedHealthCheckResult[] {
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" />
{{ 'complete' | i18n }}
} @else {
@if ((pkg.key | tuiMapper: toStatus | async) === 'backingUp') {
@if ((pkg.key | tuiMapper: toStatus | async) === 'backing-up') {
<tui-loader size="s" />
{{ 'backing up' | i18n }}
} @else {
@@ -65,5 +65,5 @@ export class BackupProgressComponent {
)
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',
'stopping',
'starting',
'backingUp',
'backing-up',
]),
}
@@ -45,9 +45,7 @@ export class ActionService {
const { pkgInfo, actionInfo } = data
if (
allowedStatuses[actionInfo.metadata.allowedStatuses].has(
pkgInfo.mainStatus,
)
allowedStatuses[actionInfo.metadata.allowedStatuses].has(pkgInfo.status)
) {
if (actionInfo.metadata.hasInput) {
this.formDialog.open<PackageActionData>(ActionInputModal, {

View File

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

View File

@@ -762,12 +762,12 @@ export class MockApiService extends ApiService {
setTimeout(async () => {
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const appPath = `/packageData/${id}/status/main/`
const appPatch: ReplaceOperation<T.MainStatus['main']>[] = [
const appPath = `/packageData/${id}/statusInfo/desired/main`
const appPatch: ReplaceOperation<T.DesiredStatus['main']>[] = [
{
op: PatchOp.REPLACE,
path: appPath,
value: 'backingUp',
value: 'backing-up',
},
]
this.mockRevision(appPatch)
@@ -1073,17 +1073,18 @@ export class MockApiService extends ApiService {
}
async startPackage(params: RR.StartPackageReq): Promise<RR.StartPackageRes> {
const path = `/packageData/${params.id}/status`
const path = `/packageData/${params.id}/statusInfo`
await pauseFor(2000)
setTimeout(async () => {
const patch2: ReplaceOperation<T.MainStatus & { main: 'running' }>[] = [
const patch2: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path,
value: {
main: 'running',
error: null,
desired: { main: 'running' },
started: new Date().toISOString(),
health: {
'ephemeral-health-check': {
@@ -1118,14 +1119,14 @@ export class MockApiService extends ApiService {
this.mockRevision(patch2)
}, 2000)
const originalPatch: ReplaceOperation<
T.MainStatus & { main: 'starting' }
>[] = [
const originalPatch: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path,
value: {
main: 'starting',
desired: { main: 'running' },
started: null,
error: null,
health: {},
},
},
@@ -1140,15 +1141,16 @@ export class MockApiService extends ApiService {
params: RR.RestartPackageReq,
): Promise<RR.RestartPackageRes> {
await pauseFor(2000)
const path = `/packageData/${params.id}/status`
const path = `/packageData/${params.id}/statusInfo`
setTimeout(async () => {
const patch2: ReplaceOperation<T.MainStatus & { main: 'running' }>[] = [
const patch2: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path,
value: {
main: 'running',
desired: { main: 'running' },
error: null,
started: new Date().toISOString(),
health: {
'ephemeral-health-check': {
@@ -1183,12 +1185,15 @@ export class MockApiService extends ApiService {
this.mockRevision(patch2)
}, this.revertTime)
const patch: ReplaceOperation<T.MainStatus & { main: 'restarting' }>[] = [
const patch: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path,
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> {
await pauseFor(2000)
const path = `/packageData/${params.id}/status`
const path = `/packageData/${params.id}/statusInfo`
setTimeout(() => {
const patch2: ReplaceOperation<T.MainStatus & { main: 'stopped' }>[] = [
const patch2: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
path: path,
value: { main: 'stopped' },
value: {
desired: { main: 'stopped' },
error: null,
health: {},
started: null,
},
},
]
this.mockRevision(patch2)
}, this.revertTime)
const patch: ReplaceOperation<T.MainStatus & { main: 'stopping' }>[] = [
const patch: ReplaceOperation<T.StatusInfo>[] = [
{
op: PatchOp.REPLACE,
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',
icon: '/assets/img/service-icons/bitcoin-core.svg',
lastBackup: new Date(new Date().valueOf() - 604800001).toISOString(),
status: {
main: 'stopped',
statusInfo: {
desired: { main: 'stopped' },
error: null,
health: {},
started: null,
},
// status: {
// main: 'error',
@@ -518,8 +521,11 @@ export const mockPatchData: DataModel = {
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/lnd.png',
lastBackup: null,
status: {
main: 'stopped',
statusInfo: {
desired: { main: 'stopped' },
error: null,
health: {},
started: null,
},
actions: {
config: {

View File

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

View File

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