Bugfix/sdk misc (#2847)

* misc sdk fixes

* version bump

* formatting

* add missing dependency to root

* alpha.16 and beta.17

* beta.18
This commit is contained in:
Aiden McClelland
2025-03-16 09:04:10 -06:00
committed by GitHub
parent e662b2f393
commit 05162ca350
50 changed files with 756 additions and 442 deletions

View File

@@ -36,7 +36,7 @@ let hostSystemId = 0
export type EffectContext = { export type EffectContext = {
procedureId: string | null procedureId: string | null
callbacks?: CallbackHolder callbacks?: CallbackHolder
constRetry: () => void constRetry?: () => void
} }
const rpcRoundFor = const rpcRoundFor =

View File

@@ -306,7 +306,6 @@ export class RpcListener {
const effects = makeEffects({ const effects = makeEffects({
procedureId: null, procedureId: null,
callbacks, callbacks,
constRetry: () => {},
}) })
return handleRpc( return handleRpc(
id, id,
@@ -337,7 +336,6 @@ export class RpcListener {
this.callbacks = new CallbackHolder( this.callbacks = new CallbackHolder(
makeEffects({ makeEffects({
procedureId: null, procedureId: null,
constRetry: () => {},
}), }),
) )
const callbacks = this.callbackHolderFor("containerInit") const callbacks = this.callbackHolderFor("containerInit")
@@ -345,7 +343,6 @@ export class RpcListener {
makeEffects({ makeEffects({
procedureId: null, procedureId: null,
callbacks, callbacks,
constRetry: () => {},
}), }),
) )
this._system = system this._system = system
@@ -427,7 +424,6 @@ export class RpcListener {
const effects = makeEffects({ const effects = makeEffects({
procedureId, procedureId,
callbacks, callbacks,
constRetry: () => {},
}) })
return (async () => { return (async () => {

View File

@@ -62,7 +62,7 @@ export class DockerProcedureContainer {
) )
} else if (volumeMount.type === "assets") { } else if (volumeMount.type === "assets") {
await subcontainer.mount( await subcontainer.mount(
{ type: "assets", id: mount, subpath: null }, { type: "assets", subpath: mount },
mounts[mount], mounts[mount],
) )
} else if (volumeMount.type === "certificate") { } else if (volumeMount.type === "certificate") {

View File

@@ -170,7 +170,7 @@ export const polyfillEffects = (
{ {
mounts: [ mounts: [
{ {
path: "/drive", mountpoint: "/drive",
options: { options: {
type: "volume", type: "volume",
id: input.volumeId, id: input.volumeId,
@@ -212,7 +212,7 @@ export const polyfillEffects = (
{ {
mounts: [ mounts: [
{ {
path: "/drive", mountpoint: "/drive",
options: { options: {
type: "volume", type: "volume",
id: input.volumeId, id: input.volumeId,

2
core/Cargo.lock generated
View File

@@ -5952,7 +5952,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]] [[package]]
name = "start-os" name = "start-os"
version = "0.3.6-alpha.15" version = "0.3.6-alpha.16"
dependencies = [ dependencies = [
"aes 0.7.5", "aes 0.7.5",
"async-acme", "async-acme",

View File

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

View File

@@ -1021,6 +1021,15 @@ impl ListenerMap {
fn poll_accept(&self, cx: &mut std::task::Context<'_>) -> Poll<Result<Accepted, Error>> { fn poll_accept(&self, cx: &mut std::task::Context<'_>) -> Poll<Result<Accepted, Error>> {
for (bind_addr, listener) in self.listeners.iter() { for (bind_addr, listener) in self.listeners.iter() {
if let Poll::Ready((stream, addr)) = listener.0.poll_accept(cx)? { if let Poll::Ready((stream, addr)) = listener.0.poll_accept(cx)? {
if let Err(e) = socket2::SockRef::from(&stream).set_tcp_keepalive(
&socket2::TcpKeepalive::new()
.with_time(Duration::from_secs(900))
.with_interval(Duration::from_secs(60))
.with_retries(5),
) {
tracing::error!("Failed to set tcp keepalive: {e}");
tracing::debug!("{e:?}");
}
return Poll::Ready(Ok(Accepted { return Poll::Ready(Ok(Accepted {
stream, stream,
peer: addr, peer: addr,

View File

@@ -258,16 +258,6 @@ impl VHostServer {
} }
} }
if let Err(e) = socket2::SockRef::from(&accepted.stream).set_tcp_keepalive(
&socket2::TcpKeepalive::new()
.with_time(Duration::from_secs(900))
.with_interval(Duration::from_secs(60))
.with_retries(5),
) {
tracing::error!("Failed to set tcp keepalive: {e}");
tracing::debug!("{e:?}");
}
tokio::spawn(async move { tokio::spawn(async move {
let bind = accepted.bind; let bind = accepted.bind;
if let Err(e) = if let Err(e) =

View File

@@ -36,6 +36,25 @@ impl<'a, T: Clone> Expected<'a, T> {
)) ))
} }
} }
pub fn check_dir(&mut self, path: impl AsRef<Path>) -> Result<(), Error> {
if let Some(dir) = self
.dir
.get_path(path.as_ref())
.and_then(|e| e.as_directory())
{
for entry in dir.file_paths(path.as_ref()) {
if !entry.to_string_lossy().ends_with("/") {
self.keep.insert_path(entry, Entry::file(()))?;
}
}
Ok(())
} else {
Err(Error::new(
eyre!("directory {} missing from archive", path.as_ref().display()),
ErrorKind::ParseS9pk,
))
}
}
pub fn check_stem( pub fn check_stem(
&mut self, &mut self,
path: impl AsRef<Path>, path: impl AsRef<Path>,

View File

@@ -129,25 +129,16 @@ impl S9pk<TmpSource<PackSource>> {
tokio_tar::Archive::new(reader.assets().await?) tokio_tar::Archive::new(reader.assets().await?)
.unpack(&asset_dir) .unpack(&asset_dir)
.await?; .await?;
for (asset_id, _) in manifest let sqfs_path = asset_dir.with_extension("squashfs");
.volumes Command::new("mksquashfs")
.iter() .arg(&asset_dir)
.filter(|(_, v)| v.get("type").and_then(|v| v.as_str()) == Some("assets")) .arg(&sqfs_path)
{ .invoke(ErrorKind::Filesystem)
let assets_path = asset_dir.join(&asset_id); .await?;
let sqfs_path = assets_path.with_extension("squashfs"); archive.insert_path(
Command::new("mksquashfs") "assets.squashfs",
.arg(&assets_path) Entry::file(TmpSource::new(tmp_dir.clone(), PackSource::File(sqfs_path))),
.arg(&sqfs_path) )?;
.invoke(ErrorKind::Filesystem)
.await?;
archive.insert_path(
Path::new("assets")
.join(&asset_id)
.with_extension("squashfs"),
Entry::file(TmpSource::new(tmp_dir.clone(), PackSource::File(sqfs_path))),
)?;
}
// javascript // javascript
let js_dir = tmp_dir.join("javascript"); let js_dir = tmp_dir.join("javascript");
@@ -217,12 +208,6 @@ impl TryFrom<ManifestV1> for Manifest {
donation_url: value.donation_url, donation_url: value.donation_url,
description: value.description, description: value.description,
images: BTreeMap::new(), images: BTreeMap::new(),
assets: value
.volumes
.iter()
.filter(|(_, v)| v.get("type").and_then(|v| v.as_str()) == Some("assets"))
.map(|(id, _)| id.clone())
.collect(),
volumes: value volumes: value
.volumes .volumes
.iter() .iter()

View File

@@ -53,7 +53,6 @@ pub struct Manifest {
pub donation_url: Option<Url>, pub donation_url: Option<Url>,
pub description: Description, pub description: Description,
pub images: BTreeMap<ImageId, ImageConfig>, pub images: BTreeMap<ImageId, ImageConfig>,
pub assets: BTreeSet<VolumeId>, // TODO: AssetsId
pub volumes: BTreeSet<VolumeId>, pub volumes: BTreeSet<VolumeId>,
#[serde(default)] #[serde(default)]
pub alerts: Alerts, pub alerts: Alerts,
@@ -93,8 +92,11 @@ impl Manifest {
.map_or(false, |mime| mime.starts_with("image/")) .map_or(false, |mime| mime.starts_with("image/"))
}); });
} }
for assets in &self.assets { if let Err(e) = expected.check_file(Path::new("assets.squashfs")) {
expected.check_file(Path::new("assets").join(assets).with_extension("squashfs"))?; // backwards compatibility for alpha s9pks - remove eventually
if expected.check_dir("assets").is_err() {
return Err(e);
}
} }
for (image_id, config) in &self.images { for (image_id, config) in &self.images {
let mut check_arch = |arch: &str| { let mut check_arch = |arch: &str| {

View File

@@ -60,7 +60,7 @@ fn priority(s: &str) -> Option<usize> {
"instructions.md" => Some(3), "instructions.md" => Some(3),
"dependencies" => Some(4), "dependencies" => Some(4),
"javascript.squashfs" => Some(5), "javascript.squashfs" => Some(5),
"assets" => Some(6), "assets.squashfs" => Some(6),
"images" => Some(7), "images" => Some(7),
_ => None, _ => None,
} }

View File

@@ -694,18 +694,13 @@ pub async fn pack(ctx: CliContext, params: PackParams) -> Result<(), Error> {
.await?; .await?;
let assets_dir = params.assets(); let assets_dir = params.assets();
for assets in s9pk.as_manifest().assets.clone() { s9pk.as_archive_mut().contents_mut().insert_path(
s9pk.as_archive_mut().contents_mut().insert_path( "assets.squashfs",
Path::new("assets").join(&assets).with_extension("squashfs"), Entry::file(TmpSource::new(
Entry::file(TmpSource::new( tmp_dir.clone(),
tmp_dir.clone(), PackSource::Squashfs(Arc::new(SqfsDir::new(assets_dir, tmp_dir.clone()))),
PackSource::Squashfs(Arc::new(SqfsDir::new( )),
assets_dir.join(&assets), )?;
tmp_dir.clone(),
))),
)),
)?;
}
s9pk.load_images(tmp_dir.clone()).await?; s9pk.load_images(tmp_dir.clone()).await?;
@@ -816,9 +811,7 @@ pub async fn list_ingredients(_: CliContext, params: PackParams) -> Result<Vec<P
} }
let assets_dir = params.assets(); let assets_dir = params.assets();
for assets in manifest.assets { ingredients.push(assets_dir);
ingredients.push(assets_dir.join(assets));
}
for image in manifest.images.values() { for image in manifest.images.values() {
ingredients.extend(image.source.ingredients()); ingredients.extend(image.source.ingredients());

View File

@@ -106,7 +106,7 @@ pub struct PersistentContainer {
// procedures: Mutex<Vec<(ProcedureName, ProcedureId)>>, // procedures: Mutex<Vec<(ProcedureName, ProcedureId)>>,
js_mount: MountGuard, js_mount: MountGuard,
volumes: BTreeMap<VolumeId, MountGuard>, volumes: BTreeMap<VolumeId, MountGuard>,
assets: BTreeMap<VolumeId, MountGuard>, assets: Vec<MountGuard>,
pub(super) images: BTreeMap<ImageId, Arc<MountGuard>>, pub(super) images: BTreeMap<ImageId, Arc<MountGuard>>,
pub(super) subcontainers: Arc<Mutex<BTreeMap<Guid, Subcontainer>>>, pub(super) subcontainers: Arc<Mutex<BTreeMap<Guid, Subcontainer>>>,
pub(super) state: Arc<watch::Sender<ServiceState>>, pub(super) state: Arc<watch::Sender<ServiceState>>,
@@ -168,35 +168,63 @@ impl PersistentContainer {
.await?; .await?;
volumes.insert(volume.clone(), mount); volumes.insert(volume.clone(), mount);
} }
let mut assets = BTreeMap::new();
for asset in &s9pk.as_manifest().assets { let mountpoint = lxc_container.rootfs_dir().join("media/startos/assets");
let mountpoint = lxc_container tokio::fs::create_dir_all(&mountpoint).await?;
.rootfs_dir() Command::new("chown")
.join("media/startos/assets") .arg("100000:100000")
.join(asset); .arg(&mountpoint)
tokio::fs::create_dir_all(&mountpoint).await?; .invoke(crate::ErrorKind::Filesystem)
Command::new("chown") .await?;
.arg("100000:100000") let assets = if let Some(sqfs) = s9pk
.arg(&mountpoint) .as_archive()
.invoke(crate::ErrorKind::Filesystem) .contents()
.await?; .get_path("assets.squashfs")
let s9pk_asset_path = Path::new("assets").join(asset).with_extension("squashfs"); .and_then(|e| e.as_file())
let sqfs = s9pk {
.as_archive() vec![
.contents()
.get_path(&s9pk_asset_path)
.and_then(|e| e.as_file())
.or_not_found(s9pk_asset_path.display())?;
assets.insert(
asset.clone(),
MountGuard::mount( MountGuard::mount(
&IdMapped::new(LoopDev::from(&**sqfs), 0, 100000, 65536), &IdMapped::new(LoopDev::from(&**sqfs), 0, 100000, 65536),
mountpoint, mountpoint,
MountType::ReadWrite, MountType::ReadWrite,
) )
.await?, .await?,
); ]
} } else if let Some(dir) = s9pk
.as_archive()
.contents()
.get_path("assets")
.and_then(|e| e.as_directory())
{
// backwards compatibility for alpha s9pks - remove eventually
let mut assets = Vec::new();
for (asset, entry) in &**dir {
let mountpoint = lxc_container
.rootfs_dir()
.join("media/startos/assets")
.join(Path::new(asset).with_extension(""));
tokio::fs::create_dir_all(&mountpoint).await?;
Command::new("chown")
.arg("100000:100000")
.arg(&mountpoint)
.invoke(crate::ErrorKind::Filesystem)
.await?;
let Some(sqfs) = entry.as_file() else {
continue;
};
assets.push(
MountGuard::mount(
&IdMapped::new(LoopDev::from(&**sqfs), 0, 100000, 65536),
mountpoint,
MountType::ReadWrite,
)
.await?,
);
}
assets
} else {
Vec::new()
};
let mut images = BTreeMap::new(); let mut images = BTreeMap::new();
let image_path = lxc_container.rootfs_dir().join("media/startos/images"); let image_path = lxc_container.rootfs_dir().join("media/startos/images");
@@ -432,7 +460,7 @@ impl PersistentContainer {
for (_, volume) in volumes { for (_, volume) in volumes {
errs.handle(volume.unmount(true).await); errs.handle(volume.unmount(true).await);
} }
for (_, assets) in assets { for assets in assets {
errs.handle(assets.unmount(true).await); errs.handle(assets.unmount(true).await);
} }
for (_, overlay) in std::mem::take(&mut *subcontainers.lock().await) { for (_, overlay) in std::mem::take(&mut *subcontainers.lock().await) {

View File

@@ -35,8 +35,9 @@ mod v0_3_6_alpha_12;
mod v0_3_6_alpha_13; mod v0_3_6_alpha_13;
mod v0_3_6_alpha_14; mod v0_3_6_alpha_14;
mod v0_3_6_alpha_15; mod v0_3_6_alpha_15;
mod v0_3_6_alpha_16;
pub type Current = v0_3_6_alpha_15::Version; // VERSION_BUMP pub type Current = v0_3_6_alpha_16::Version; // VERSION_BUMP
impl Current { impl Current {
#[instrument(skip(self, db))] #[instrument(skip(self, db))]
@@ -133,7 +134,8 @@ enum Version {
V0_3_6_alpha_12(Wrapper<v0_3_6_alpha_12::Version>), V0_3_6_alpha_12(Wrapper<v0_3_6_alpha_12::Version>),
V0_3_6_alpha_13(Wrapper<v0_3_6_alpha_13::Version>), V0_3_6_alpha_13(Wrapper<v0_3_6_alpha_13::Version>),
V0_3_6_alpha_14(Wrapper<v0_3_6_alpha_14::Version>), V0_3_6_alpha_14(Wrapper<v0_3_6_alpha_14::Version>),
V0_3_6_alpha_15(Wrapper<v0_3_6_alpha_15::Version>), // VERSION_BUMP V0_3_6_alpha_15(Wrapper<v0_3_6_alpha_15::Version>),
V0_3_6_alpha_16(Wrapper<v0_3_6_alpha_16::Version>), // VERSION_BUMP
Other(exver::Version), Other(exver::Version),
} }
@@ -172,7 +174,8 @@ impl Version {
Self::V0_3_6_alpha_12(v) => DynVersion(Box::new(v.0)), Self::V0_3_6_alpha_12(v) => DynVersion(Box::new(v.0)),
Self::V0_3_6_alpha_13(v) => DynVersion(Box::new(v.0)), Self::V0_3_6_alpha_13(v) => DynVersion(Box::new(v.0)),
Self::V0_3_6_alpha_14(v) => DynVersion(Box::new(v.0)), Self::V0_3_6_alpha_14(v) => DynVersion(Box::new(v.0)),
Self::V0_3_6_alpha_15(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP Self::V0_3_6_alpha_15(v) => DynVersion(Box::new(v.0)),
Self::V0_3_6_alpha_16(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
Self::Other(v) => { Self::Other(v) => {
return Err(Error::new( return Err(Error::new(
eyre!("unknown version {v}"), eyre!("unknown version {v}"),
@@ -203,7 +206,8 @@ impl Version {
Version::V0_3_6_alpha_12(Wrapper(x)) => x.semver(), Version::V0_3_6_alpha_12(Wrapper(x)) => x.semver(),
Version::V0_3_6_alpha_13(Wrapper(x)) => x.semver(), Version::V0_3_6_alpha_13(Wrapper(x)) => x.semver(),
Version::V0_3_6_alpha_14(Wrapper(x)) => x.semver(), Version::V0_3_6_alpha_14(Wrapper(x)) => x.semver(),
Version::V0_3_6_alpha_15(Wrapper(x)) => x.semver(), // VERSION_BUMP Version::V0_3_6_alpha_15(Wrapper(x)) => x.semver(),
Version::V0_3_6_alpha_16(Wrapper(x)) => x.semver(), // VERSION_BUMP
Version::Other(x) => x.clone(), Version::Other(x) => x.clone(),
} }
} }

View File

@@ -0,0 +1,36 @@
use exver::{PreReleaseSegment, VersionRange};
use super::v0_3_5::V0_3_0_COMPAT;
use super::{v0_3_6_alpha_15, VersionT};
use crate::prelude::*;
lazy_static::lazy_static! {
static ref V0_3_6_alpha_16: exver::Version = exver::Version::new(
[0, 3, 6],
[PreReleaseSegment::String("alpha".into()), 16.into()]
);
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Version;
impl VersionT for Version {
type Previous = v0_3_6_alpha_15::Version;
type PreUpRes = ();
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
Ok(())
}
fn semver(self) -> exver::Version {
V0_3_6_alpha_16.clone()
}
fn compat(self) -> &'static VersionRange {
&V0_3_0_COMPAT
}
fn up(self, _db: &mut Value, _: Self::PreUpRes) -> Result<(), Error> {
Ok(())
}
fn down(self, _db: &mut Value) -> Result<(), Error> {
Ok(())
}
}

View File

@@ -58,11 +58,16 @@ check:
fmt: package/node_modules base/node_modules fmt: package/node_modules base/node_modules
npx prettier . "**/*.ts" --write npx prettier . "**/*.ts" --write
package/package-lock.json: package/package.json
cd package && npm i
package/node_modules: package/package.json base/package-lock.json: base/package.json
cd base && npm i
package/node_modules: package/package-lock.json
cd package && npm ci cd package && npm ci
base/node_modules: base/package.json base/node_modules: base/package-lock.json
cd base && npm ci cd base && npm ci
node_modules: package/node_modules base/node_modules node_modules: package/node_modules base/node_modules

View File

@@ -28,7 +28,7 @@ import { UrlString } from "./util/getServiceInterface"
/** Used to reach out from the pure js runtime */ /** Used to reach out from the pure js runtime */
export type Effects = { export type Effects = {
constRetry: () => void constRetry?: () => void
clearCallbacks: ( clearCallbacks: (
options: { only: number[] } | { except: number[] }, options: { only: number[] } | { except: number[] },
) => Promise<null> ) => Promise<null>

View File

@@ -1,4 +1,4 @@
import { DeepMap } from "deep-equality-data-structures"; import { DeepMap } from "deep-equality-data-structures"
import * as P from "./exver" import * as P from "./exver"
// prettier-ignore // prettier-ignore
@@ -45,16 +45,16 @@ type Not = {
} }
type Flavor = { type Flavor = {
type: "Flavor", type: "Flavor"
flavor: string | null, flavor: string | null
} }
type FlavorNot = { type FlavorNot = {
type: "FlavorNot", type: "FlavorNot"
flavors: Set<string | null>, flavors: Set<string | null>
} }
type FlavorAtom = Flavor | FlavorNot; type FlavorAtom = Flavor | FlavorNot
/** /**
* Splits a number line of versions in half, so that every possible semver is either to the left or right. * Splits a number line of versions in half, so that every possible semver is either to the left or right.
@@ -65,66 +65,75 @@ type FlavorAtom = Flavor | FlavorNot;
* for side=+1 the point is like `1.2.3.0.0.**.1` (that is, 1.2.3.0.0.** is less). * for side=+1 the point is like `1.2.3.0.0.**.1` (that is, 1.2.3.0.0.** is less).
*/ */
type VersionRangePoint = { type VersionRangePoint = {
upstream: Version, upstream: Version
downstream: Version, downstream: Version
side: -1 | 1; side: -1 | 1
} }
function compareVersionRangePoints(a: VersionRangePoint, b: VersionRangePoint): -1 | 0 | 1 { function compareVersionRangePoints(
let up = a.upstream.compareForSort(b.upstream); a: VersionRangePoint,
b: VersionRangePoint,
): -1 | 0 | 1 {
let up = a.upstream.compareForSort(b.upstream)
if (up != 0) { if (up != 0) {
return up; return up
} }
let down = a.upstream.compareForSort(b.upstream); let down = a.upstream.compareForSort(b.upstream)
if (down != 0) { if (down != 0) {
return down; return down
} }
if (a.side < b.side) { if (a.side < b.side) {
return -1; return -1
} else if (a.side > b.side) { } else if (a.side > b.side) {
return 1; return 1
} else { } else {
return 0; return 0
} }
} }
function adjacentVersionRangePoints(a: VersionRangePoint, b: VersionRangePoint): boolean { function adjacentVersionRangePoints(
let up = a.upstream.compareForSort(b.upstream); a: VersionRangePoint,
b: VersionRangePoint,
): boolean {
let up = a.upstream.compareForSort(b.upstream)
if (up != 0) { if (up != 0) {
return false; return false
} }
let down = a.upstream.compareForSort(b.upstream); let down = a.upstream.compareForSort(b.upstream)
if (down != 0) { if (down != 0) {
return false; return false
} }
return a.side == -1 && b.side == 1; return a.side == -1 && b.side == 1
} }
function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null { function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null {
if (a.type == 'Flavor') { if (a.type == "Flavor") {
if (b.type == 'Flavor') { if (b.type == "Flavor") {
if (a.flavor == b.flavor) { if (a.flavor == b.flavor) {
return a; return a
} else { } else {
return null; return null
} }
} else { } else {
if (b.flavors.has(a.flavor)) { if (b.flavors.has(a.flavor)) {
return null; return null
} else { } else {
return a; return a
} }
} }
} else { } else {
if (b.type == 'Flavor') { if (b.type == "Flavor") {
if (a.flavors.has(b.flavor)) { if (a.flavors.has(b.flavor)) {
return null; return null
} else { } else {
return b; return b
} }
} else { } else {
// TODO: use Set.union if targeting esnext or later // TODO: use Set.union if targeting esnext or later
return { type: 'FlavorNot', flavors: new Set([...a.flavors, ...b.flavors]) }; return {
type: "FlavorNot",
flavors: new Set([...a.flavors, ...b.flavors]),
}
} }
} }
} }
@@ -134,62 +143,69 @@ function flavorAnd(a: FlavorAtom, b: FlavorAtom): FlavorAtom | null {
* is quite straightforward. But in order to exhaustively enumerate the boolean values of every * is quite straightforward. But in order to exhaustively enumerate the boolean values of every
* combination of flavors and versions we also need tables for flavor negations. * combination of flavors and versions we also need tables for flavor negations.
*/ */
type VersionRangeTables = DeepMap<FlavorAtom, VersionRangeTable> | boolean; type VersionRangeTables = DeepMap<FlavorAtom, VersionRangeTable> | boolean
/** /**
* A truth table for version numbers. This is easiest to picture as a number line, cut up into * A truth table for version numbers. This is easiest to picture as a number line, cut up into
* ranges of versions between version points. * ranges of versions between version points.
*/ */
class VersionRangeTable { class VersionRangeTable {
private constructor(protected points: Array<VersionRangePoint>, protected values: boolean[]) {} private constructor(
protected points: Array<VersionRangePoint>,
protected values: boolean[],
) {}
static zip(a: VersionRangeTable, b: VersionRangeTable, func: (a: boolean, b: boolean) => boolean): VersionRangeTable { static zip(
let c = new VersionRangeTable([], []); a: VersionRangeTable,
let i = 0; b: VersionRangeTable,
let j = 0; func: (a: boolean, b: boolean) => boolean,
): VersionRangeTable {
let c = new VersionRangeTable([], [])
let i = 0
let j = 0
while (true) { while (true) {
let next = func(a.values[i], b.values[j]); let next = func(a.values[i], b.values[j])
if (c.values.length > 0 && c.values[c.values.length - 1] == next) { if (c.values.length > 0 && c.values[c.values.length - 1] == next) {
// collapse automatically // collapse automatically
c.points.pop(); c.points.pop()
} else { } else {
c.values.push(next); c.values.push(next)
} }
// which point do we step over? // which point do we step over?
if (i == a.points.length) { if (i == a.points.length) {
if (j == b.points.length) { if (j == b.points.length) {
// just added the last segment, no point to jump over // just added the last segment, no point to jump over
return c; return c
} else { } else {
// i has reach the end, step over j // i has reach the end, step over j
c.points.push(b.points[j]); c.points.push(b.points[j])
j += 1; j += 1
} }
} else { } else {
if (j == b.points.length) { if (j == b.points.length) {
// j has reached the end, step over i // j has reached the end, step over i
c.points.push(a.points[i]); c.points.push(a.points[i])
i += 1; i += 1
} else { } else {
// depends on which of the next two points is lower // depends on which of the next two points is lower
switch (compareVersionRangePoints(a.points[i], b.points[j])) { switch (compareVersionRangePoints(a.points[i], b.points[j])) {
case -1: case -1:
// i is the lower point // i is the lower point
c.points.push(a.points[i]); c.points.push(a.points[i])
i += 1; i += 1
break; break
case 1: case 1:
// j is the lower point // j is the lower point
c.points.push(b.points[j]); c.points.push(b.points[j])
j += 1; j += 1
break; break
default: default:
// step over both // step over both
c.points.push(a.points[i]); c.points.push(a.points[i])
i += 1; i += 1
j += 1; j += 1
break; break
} }
} }
} }
@@ -201,98 +217,134 @@ class VersionRangeTable {
*/ */
static eqFlavor(flavor: string | null): VersionRangeTables { static eqFlavor(flavor: string | null): VersionRangeTables {
return new DeepMap([ return new DeepMap([
[{ type: 'Flavor', flavor } as FlavorAtom, new VersionRangeTable([], [true])], [
{ type: "Flavor", flavor } as FlavorAtom,
new VersionRangeTable([], [true]),
],
// make sure the truth table is exhaustive, or `not` will not work properly. // make sure the truth table is exhaustive, or `not` will not work properly.
[{ type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom, new VersionRangeTable([], [false])], [
]); { type: "FlavorNot", flavors: new Set([flavor]) } as FlavorAtom,
new VersionRangeTable([], [false]),
],
])
} }
/** /**
* Creates a version table with exactly two ranges (to the left and right of the given point) and with `false` for any other flavor. * Creates a version table with exactly two ranges (to the left and right of the given point) and with `false` for any other flavor.
* This is easiest to understand by looking at `VersionRange.tables`. * This is easiest to understand by looking at `VersionRange.tables`.
*/ */
static cmpPoint(flavor: string | null, point: VersionRangePoint, left: boolean, right: boolean): VersionRangeTables { static cmpPoint(
flavor: string | null,
point: VersionRangePoint,
left: boolean,
right: boolean,
): VersionRangeTables {
return new DeepMap([ return new DeepMap([
[{ type: 'Flavor', flavor } as FlavorAtom, new VersionRangeTable([point], [left, right])], [
{ type: "Flavor", flavor } as FlavorAtom,
new VersionRangeTable([point], [left, right]),
],
// make sure the truth table is exhaustive, or `not` will not work properly. // make sure the truth table is exhaustive, or `not` will not work properly.
[{ type: 'FlavorNot', flavors: new Set([flavor]) } as FlavorAtom, new VersionRangeTable([], [false])], [
]); { type: "FlavorNot", flavors: new Set([flavor]) } as FlavorAtom,
new VersionRangeTable([], [false]),
],
])
} }
/** /**
* Helper for `cmpPoint`. * Helper for `cmpPoint`.
*/ */
static cmp(version: ExtendedVersion, side: -1 | 1, left: boolean, right: boolean): VersionRangeTables { static cmp(
return VersionRangeTable.cmpPoint(version.flavor, { upstream: version.upstream, downstream: version.downstream, side }, left, right) version: ExtendedVersion,
side: -1 | 1,
left: boolean,
right: boolean,
): VersionRangeTables {
return VersionRangeTable.cmpPoint(
version.flavor,
{ upstream: version.upstream, downstream: version.downstream, side },
left,
right,
)
} }
static not(tables: VersionRangeTables) { static not(tables: VersionRangeTables) {
if (tables === true || tables === false) { if (tables === true || tables === false) {
return !tables; return !tables
} }
// because tables are always exhaustive, we can simply invert each range // because tables are always exhaustive, we can simply invert each range
for (let [f, t] of tables) { for (let [f, t] of tables) {
for (let i = 0; i < t.values.length; i++) { for (let i = 0; i < t.values.length; i++) {
t.values[i] = !t.values[i]; t.values[i] = !t.values[i]
} }
} }
return tables; return tables
} }
static and(a_tables: VersionRangeTables, b_tables: VersionRangeTables): VersionRangeTables { static and(
a_tables: VersionRangeTables,
b_tables: VersionRangeTables,
): VersionRangeTables {
if (a_tables === true) { if (a_tables === true) {
return b_tables; return b_tables
} }
if (b_tables === true) { if (b_tables === true) {
return a_tables; return a_tables
} }
if (a_tables === false || b_tables == false) { if (a_tables === false || b_tables == false) {
return false; return false
} }
let c_tables: VersionRangeTables = true; let c_tables: VersionRangeTables = true
for (let [f_a, a] of a_tables) { for (let [f_a, a] of a_tables) {
for (let [f_b, b] of b_tables) { for (let [f_b, b] of b_tables) {
let flavor = flavorAnd(f_a, f_b); let flavor = flavorAnd(f_a, f_b)
if (flavor == null) { if (flavor == null) {
continue; continue
} }
let c = VersionRangeTable.zip(a, b, (a, b) => a && b); let c = VersionRangeTable.zip(a, b, (a, b) => a && b)
if (c_tables === true) { if (c_tables === true) {
c_tables = new DeepMap(); c_tables = new DeepMap()
} }
let prev_c = c_tables.get(flavor); let prev_c = c_tables.get(flavor)
if (prev_c == null) { if (prev_c == null) {
c_tables.set(flavor, c); c_tables.set(flavor, c)
} else { } else {
c_tables.set(flavor, VersionRangeTable.zip(c, prev_c, (a, b) => a || b)); c_tables.set(
flavor,
VersionRangeTable.zip(c, prev_c, (a, b) => a || b),
)
} }
} }
} }
return c_tables; return c_tables
} }
static or(...in_tables: VersionRangeTables[]): VersionRangeTables { static or(...in_tables: VersionRangeTables[]): VersionRangeTables {
let out_tables: VersionRangeTables = false; let out_tables: VersionRangeTables = false
for (let tables of in_tables) { for (let tables of in_tables) {
if (tables === false) { if (tables === false) {
continue; continue
} }
if (tables === true) { if (tables === true) {
return true; return true
} }
if (out_tables === false) { if (out_tables === false) {
out_tables = new DeepMap(); out_tables = new DeepMap()
} }
for (let [flavor, table] of tables) { for (let [flavor, table] of tables) {
let prev = out_tables.get(flavor); let prev = out_tables.get(flavor)
if (prev == null) { if (prev == null) {
out_tables.set(flavor, table); out_tables.set(flavor, table)
} else { } else {
out_tables.set(flavor, VersionRangeTable.zip(table, prev, (a, b) => a || b)); out_tables.set(
flavor,
VersionRangeTable.zip(table, prev, (a, b) => a || b),
)
} }
} }
} }
return out_tables; return out_tables
} }
/** /**
@@ -300,19 +352,19 @@ class VersionRangeTable {
*/ */
static collapse(tables: VersionRangeTables): boolean | null { static collapse(tables: VersionRangeTables): boolean | null {
if (tables === true || tables === false) { if (tables === true || tables === false) {
return tables; return tables
} else { } else {
let found = null; let found = null
for (let table of tables.values()) { for (let table of tables.values()) {
for (let x of table.values) { for (let x of table.values) {
if (found == null) { if (found == null) {
found = x; found = x
} else if (found != x) { } else if (found != x) {
return null; return null
} }
} }
} }
return found; return found
} }
} }
@@ -321,65 +373,90 @@ class VersionRangeTable {
* https://en.wikipedia.org/wiki/Canonical_normal_form#Minterms * https://en.wikipedia.org/wiki/Canonical_normal_form#Minterms
*/ */
static minterms(tables: VersionRangeTables): VersionRange { static minterms(tables: VersionRangeTables): VersionRange {
let collapse = VersionRangeTable.collapse(tables); let collapse = VersionRangeTable.collapse(tables)
if (tables === true || collapse === true) { if (tables === true || collapse === true) {
return VersionRange.any() return VersionRange.any()
} }
if (tables == false || collapse === false) { if (tables == false || collapse === false) {
return VersionRange.none() return VersionRange.none()
} }
let sum_terms: VersionRange[] = []; let sum_terms: VersionRange[] = []
for (let [flavor, table] of tables) { for (let [flavor, table] of tables) {
let cmp_flavor = null; let cmp_flavor = null
if (flavor.type == 'Flavor') { if (flavor.type == "Flavor") {
cmp_flavor = flavor.flavor; cmp_flavor = flavor.flavor
} }
for (let i = 0; i < table.values.length; i++) { for (let i = 0; i < table.values.length; i++) {
let term: VersionRange[] = []; let term: VersionRange[] = []
if (!table.values[i]) { if (!table.values[i]) {
continue continue
} }
if (flavor.type == 'FlavorNot') { if (flavor.type == "FlavorNot") {
for (let not_flavor of flavor.flavors) { for (let not_flavor of flavor.flavors) {
term.push(VersionRange.flavor(not_flavor).not()); term.push(VersionRange.flavor(not_flavor).not())
} }
} }
let p = null; let p = null
let q = null; let q = null
if (i > 0) { if (i > 0) {
p = table.points[i - 1]; p = table.points[i - 1]
} }
if (i < table.points.length) { if (i < table.points.length) {
q = table.points[i]; q = table.points[i]
} }
if (p != null && q != null && adjacentVersionRangePoints(p, q)) { if (p != null && q != null && adjacentVersionRangePoints(p, q)) {
term.push(VersionRange.anchor('=', new ExtendedVersion(cmp_flavor, p.upstream, p.downstream))); term.push(
VersionRange.anchor(
"=",
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
),
)
} else { } else {
if (p != null && p.side < 0) { if (p != null && p.side < 0) {
term.push(VersionRange.anchor('>=', new ExtendedVersion(cmp_flavor, p.upstream, p.downstream))); term.push(
VersionRange.anchor(
">=",
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
),
)
} }
if (p != null && p.side >= 0) { if (p != null && p.side >= 0) {
term.push(VersionRange.anchor('>', new ExtendedVersion(cmp_flavor, p.upstream, p.downstream))); term.push(
VersionRange.anchor(
">",
new ExtendedVersion(cmp_flavor, p.upstream, p.downstream),
),
)
} }
if (q != null && q.side < 0) { if (q != null && q.side < 0) {
term.push(VersionRange.anchor('<', new ExtendedVersion(cmp_flavor, q.upstream, q.downstream))); term.push(
VersionRange.anchor(
"<",
new ExtendedVersion(cmp_flavor, q.upstream, q.downstream),
),
)
} }
if (q != null && q.side >= 0) { if (q != null && q.side >= 0) {
term.push(VersionRange.anchor('<=', new ExtendedVersion(cmp_flavor, q.upstream, q.downstream))); term.push(
VersionRange.anchor(
"<=",
new ExtendedVersion(cmp_flavor, q.upstream, q.downstream),
),
)
} }
} }
if (term.length == 0) { if (term.length == 0) {
term.push(VersionRange.flavor(cmp_flavor)); term.push(VersionRange.flavor(cmp_flavor))
} }
sum_terms.push(VersionRange.and(...term)); sum_terms.push(VersionRange.and(...term))
} }
} }
return VersionRange.or(...sum_terms); return VersionRange.or(...sum_terms)
} }
} }
@@ -387,11 +464,11 @@ export class VersionRange {
constructor(public atom: Anchor | And | Or | Not | P.Any | P.None | Flavor) {} constructor(public atom: Anchor | And | Or | Not | P.Any | P.None | Flavor) {}
toStringParens(parent: "And" | "Or" | "Not") { toStringParens(parent: "And" | "Or" | "Not") {
let needs = true; let needs = true
switch (this.atom.type) { switch (this.atom.type) {
case "And": case "And":
case "Or": case "Or":
needs = parent != this.atom.type; needs = parent != this.atom.type
break break
case "Anchor": case "Anchor":
case "Any": case "Any":
@@ -400,14 +477,14 @@ export class VersionRange {
break break
case "Not": case "Not":
case "Flavor": case "Flavor":
needs = false; needs = false
break break
} }
if (needs) { if (needs) {
return "(" + this.toString() + ")"; return "(" + this.toString() + ")"
} else { } else {
return this.toString(); return this.toString()
} }
} }
@@ -519,39 +596,39 @@ export class VersionRange {
} }
static and(...xs: Array<VersionRange>) { static and(...xs: Array<VersionRange>) {
let y = VersionRange.any(); let y = VersionRange.any()
for (let x of xs) { for (let x of xs) {
if (x.atom.type == 'Any') { if (x.atom.type == "Any") {
continue; continue
} }
if (x.atom.type == 'None') { if (x.atom.type == "None") {
return x; return x
} }
if (y.atom.type == 'Any') { if (y.atom.type == "Any") {
y = x; y = x
} else { } else {
y = new VersionRange({ type: 'And', left: y, right: x}); y = new VersionRange({ type: "And", left: y, right: x })
} }
} }
return y; return y
} }
static or(...xs: Array<VersionRange>) { static or(...xs: Array<VersionRange>) {
let y = VersionRange.none(); let y = VersionRange.none()
for (let x of xs) { for (let x of xs) {
if (x.atom.type == 'None') { if (x.atom.type == "None") {
continue; continue
} }
if (x.atom.type == 'Any') { if (x.atom.type == "Any") {
return x; return x
} }
if (y.atom.type == 'None') { if (y.atom.type == "None") {
y = x; y = x
} else { } else {
y = new VersionRange({ type: 'Or', left: y, right: x}); y = new VersionRange({ type: "Or", left: y, right: x })
} }
} }
return y; return y
} }
static any() { static any() {
@@ -567,7 +644,7 @@ export class VersionRange {
} }
tables(): VersionRangeTables { tables(): VersionRangeTables {
switch(this.atom.type) { switch (this.atom.type) {
case "Anchor": case "Anchor":
switch (this.atom.operator) { switch (this.atom.operator) {
case "=": case "=":
@@ -587,21 +664,33 @@ export class VersionRange {
case "!=": case "!=":
// `!=1.2.3` is equivalent to `!(>=1.2.3 && <=1.2.3 && #flavor)` // `!=1.2.3` is equivalent to `!(>=1.2.3 && <=1.2.3 && #flavor)`
// **not** equivalent to `(<1.2.3 || >1.2.3) && #flavor` // **not** equivalent to `(<1.2.3 || >1.2.3) && #flavor`
return VersionRangeTable.not(VersionRangeTable.and( return VersionRangeTable.not(
VersionRangeTable.cmp(this.atom.version, -1, false, true), VersionRangeTable.and(
VersionRangeTable.cmp(this.atom.version, 1, true, false), VersionRangeTable.cmp(this.atom.version, -1, false, true),
)) VersionRangeTable.cmp(this.atom.version, 1, true, false),
),
)
case "^": case "^":
// `^1.2.3` is equivalent to `>=1.2.3 && <2.0.0 && #flavor` // `^1.2.3` is equivalent to `>=1.2.3 && <2.0.0 && #flavor`
return VersionRangeTable.and( return VersionRangeTable.and(
VersionRangeTable.cmp(this.atom.version, -1, false, true), VersionRangeTable.cmp(this.atom.version, -1, false, true),
VersionRangeTable.cmp(this.atom.version.incrementMajor(), -1, true, false), VersionRangeTable.cmp(
this.atom.version.incrementMajor(),
-1,
true,
false,
),
) )
case "~": case "~":
// `~1.2.3` is equivalent to `>=1.2.3 && <1.3.0 && #flavor` // `~1.2.3` is equivalent to `>=1.2.3 && <1.3.0 && #flavor`
return VersionRangeTable.and( return VersionRangeTable.and(
VersionRangeTable.cmp(this.atom.version, -1, false, true), VersionRangeTable.cmp(this.atom.version, -1, false, true),
VersionRangeTable.cmp(this.atom.version.incrementMinor(), -1, true, false), VersionRangeTable.cmp(
this.atom.version.incrementMinor(),
-1,
true,
false,
),
) )
} }
case "Flavor": case "Flavor":
@@ -609,9 +698,15 @@ export class VersionRange {
case "Not": case "Not":
return VersionRangeTable.not(this.atom.value.tables()) return VersionRangeTable.not(this.atom.value.tables())
case "And": case "And":
return VersionRangeTable.and(this.atom.left.tables(), this.atom.right.tables()) return VersionRangeTable.and(
this.atom.left.tables(),
this.atom.right.tables(),
)
case "Or": case "Or":
return VersionRangeTable.or(this.atom.left.tables(), this.atom.right.tables()) return VersionRangeTable.or(
this.atom.left.tables(),
this.atom.right.tables(),
)
case "Any": case "Any":
return true return true
case "None": case "None":
@@ -620,15 +715,15 @@ export class VersionRange {
} }
satisfiable(): boolean { satisfiable(): boolean {
return VersionRangeTable.collapse(this.tables()) !== false; return VersionRangeTable.collapse(this.tables()) !== false
} }
intersects(other: VersionRange): boolean { intersects(other: VersionRange): boolean {
return VersionRange.and(this, other).satisfiable(); return VersionRange.and(this, other).satisfiable()
} }
normalize(): VersionRange { normalize(): VersionRange {
return VersionRangeTable.minterms(this.tables()); return VersionRangeTable.minterms(this.tables())
} }
} }

View File

@@ -26,7 +26,6 @@ export type Manifest = {
donationUrl: string | null donationUrl: string | null
description: Description description: Description
images: { [key: ImageId]: ImageConfig } images: { [key: ImageId]: ImageConfig }
assets: Array<VolumeId>
volumes: Array<VolumeId> volumes: Array<VolumeId>
alerts: Alerts alerts: Alerts
dependencies: Dependencies dependencies: Dependencies

View File

@@ -80,10 +80,15 @@ describe("ExVer", () => {
}) })
test(`VersionRange.parse("=1") invalid`, () => { test(`VersionRange.parse("=1") invalid`, () => {
expect(checker.satisfiedBy(ExtendedVersion.parse("1.0.1:0"))).toEqual(false) expect(checker.satisfiedBy(ExtendedVersion.parse("1.0.1:0"))).toEqual(
expect(checker.satisfiedBy(ExtendedVersion.parse("1.0.0:1"))).toEqual(false) false,
)
expect(checker.satisfiedBy(ExtendedVersion.parse("1.0.0:1"))).toEqual(
false,
)
}) })
} { }
{
const checker = VersionRange.parse(">=1.2.3:4") const checker = VersionRange.parse(">=1.2.3:4")
test(`VersionRange.parse(">=1.2.3:4") valid`, () => { test(`VersionRange.parse(">=1.2.3:4") valid`, () => {
expect(checker.satisfiedBy(ExtendedVersion.parse("2:0"))).toEqual(true) expect(checker.satisfiedBy(ExtendedVersion.parse("2:0"))).toEqual(true)
@@ -306,32 +311,44 @@ describe("ExVer", () => {
{ {
function testNormalization(input: string, expected: string) { function testNormalization(input: string, expected: string) {
test(`"${input}" normalizes to "${expected}"`, () => { test(`"${input}" normalizes to "${expected}"`, () => {
const checker = VersionRange.parse(input).normalize(); const checker = VersionRange.parse(input).normalize()
expect(checker.toString()).toEqual(expected); expect(checker.toString()).toEqual(expected)
}); })
} }
testNormalization("=2.0", "=2.0:0"); testNormalization("=2.0", "=2.0:0")
testNormalization("=1 && =2", "!"); testNormalization("=1 && =2", "!")
testNormalization("!(=1 && =2)", "*"); testNormalization("!(=1 && =2)", "*")
testNormalization("!=1 || !=2", "*"); testNormalization("!=1 || !=2", "*")
testNormalization("(!=#foo:1 || !=#foo:2) && #foo", "#foo"); testNormalization("(!=#foo:1 || !=#foo:2) && #foo", "#foo")
testNormalization("!=#foo:1 || !=#bar:2", "<#foo:1:0 || >#foo:1:0 || !#foo || <#bar:2:0 || >#bar:2:0 || !#bar"); testNormalization(
testNormalization("!(=1 || =2)", "<1:0 || (>1:0 && <2:0) || >2:0 || !#"); "!=#foo:1 || !=#bar:2",
testNormalization("=1 && (=2 || =3)", "!"); "<#foo:1:0 || >#foo:1:0 || !#foo || <#bar:2:0 || >#bar:2:0 || !#bar",
testNormalization("=1 && (=1 || =2)", "=1:0"); )
testNormalization("=#foo:1 && =#bar:1", "!"); testNormalization("!(=1 || =2)", "<1:0 || (>1:0 && <2:0) || >2:0 || !#")
testNormalization("!(=#foo:1) && !(=#bar:1)", "<#foo:1:0 || >#foo:1:0 || <#bar:1:0 || >#bar:1:0 || (!#foo && !#bar)"); testNormalization("=1 && (=2 || =3)", "!")
testNormalization("!(=#foo:1) && !(=#bar:1) && >2", ">2:0"); testNormalization("=1 && (=1 || =2)", "=1:0")
testNormalization("~1.2.3", ">=1.2.3:0 && <1.3.0:0"); testNormalization("=#foo:1 && =#bar:1", "!")
testNormalization("^1.2.3", ">=1.2.3:0 && <2.0.0:0"); testNormalization(
testNormalization("^1.2.3 && >=1 && >=1.2 && >=1.3", ">=1.3:0 && <2.0.0:0"); "!(=#foo:1) && !(=#bar:1)",
testNormalization("(>=1.0 && <1.1) || (>=1.1 && <1.2) || (>=1.2 && <1.3)", ">=1.0:0 && <1.3:0"); "<#foo:1:0 || >#foo:1:0 || <#bar:1:0 || >#bar:1:0 || (!#foo && !#bar)",
testNormalization(">1 || <2", "#"); )
testNormalization("!(=#foo:1) && !(=#bar:1) && >2", ">2:0")
testNormalization("~1.2.3", ">=1.2.3:0 && <1.3.0:0")
testNormalization("^1.2.3", ">=1.2.3:0 && <2.0.0:0")
testNormalization(
"^1.2.3 && >=1 && >=1.2 && >=1.3",
">=1.3:0 && <2.0.0:0",
)
testNormalization(
"(>=1.0 && <1.1) || (>=1.1 && <1.2) || (>=1.2 && <1.3)",
">=1.0:0 && <1.3:0",
)
testNormalization(">1 || <2", "#")
testNormalization("=1 && =1.2 && =1.2.3", "!"); testNormalization("=1 && =1.2 && =1.2.3", "!")
// testNormalization("=1 && =1.2 && =1.2.3", "=1.2.3:0"); TODO: should it be this instead? // testNormalization("=1 && =1.2 && =1.2.3", "=1.2.3:0"); TODO: should it be this instead?
testNormalization("=1 || =1.2 || =1.2.3", "=1:0 || =1.2:0 || =1.2.3:0"); testNormalization("=1 || =1.2 || =1.2.3", "=1:0 || =1.2:0 || =1.2.3:0")
// testNormalization("=1 || =1.2 || =1.2.3", "=1:0"); TODO: should it be this instead? // testNormalization("=1 || =1.2 || =1.2.3", "=1:0"); TODO: should it be this instead?
} }
@@ -350,8 +367,12 @@ describe("ExVer", () => {
const checker = VersionRange.parse("=1 || =2") const checker = VersionRange.parse("=1 || =2")
expect(checker.satisfiedBy(ExtendedVersion.parse("1:0"))).toEqual(true) expect(checker.satisfiedBy(ExtendedVersion.parse("1:0"))).toEqual(true)
expect(checker.satisfiedBy(ExtendedVersion.parse("1.2:0"))).toEqual(false) // really? expect(checker.satisfiedBy(ExtendedVersion.parse("1.2:0"))).toEqual(
expect(checker.satisfiedBy(ExtendedVersion.parse("1.2.3:0"))).toEqual(false) // really? false,
) // really?
expect(checker.satisfiedBy(ExtendedVersion.parse("1.2.3:0"))).toEqual(
false,
) // really?
expect(checker.satisfiedBy(ExtendedVersion.parse("2:0"))).toEqual(true) expect(checker.satisfiedBy(ExtendedVersion.parse("2:0"))).toEqual(true)
expect(checker.satisfiedBy(ExtendedVersion.parse("3:0"))).toEqual(false) expect(checker.satisfiedBy(ExtendedVersion.parse("3:0"))).toEqual(false)
}) })

View File

@@ -45,7 +45,6 @@ type EffectsTypeChecker<T extends StringObject = Effects> = {
describe("startosTypeValidation ", () => { describe("startosTypeValidation ", () => {
test(`checking the params match`, () => { test(`checking the params match`, () => {
typeEquality<EffectsTypeChecker>({ typeEquality<EffectsTypeChecker>({
constRetry: {},
clearCallbacks: {} as ClearCallbacksParams, clearCallbacks: {} as ClearCallbacksParams,
action: { action: {
clear: {} as ClearActionsParams, clear: {} as ClearActionsParams,

View File

@@ -20,10 +20,6 @@ export {
} from "./dependencies/setupDependencies" } from "./dependencies/setupDependencies"
export type ExposedStorePaths = string[] & Affine<"ExposedStorePaths"> export type ExposedStorePaths = string[] & Affine<"ExposedStorePaths">
declare const HealthProof: unique symbol
export type HealthReceipt = {
[HealthProof]: never
}
export type DaemonBuildable = { export type DaemonBuildable = {
build(): Promise<{ build(): Promise<{

View File

@@ -84,14 +84,6 @@ export type SDKManifest = {
* ``` * ```
*/ */
readonly images: Record<ImageId, SDKImageInputSpec> readonly images: Record<ImageId, SDKImageInputSpec>
/**
* @description A list of readonly asset directories that will mount to the container. Each item here must
* correspond to a directory in the /assets directory of this project.
*
* Most projects will not make use of this.
* @example []
*/
readonly assets: string[]
/** /**
* @description A list of data volumes that will mount to the container. Must contain at least one volume. * @description A list of data volumes that will mount to the container. Must contain at least one volume.
* @example ['main'] * @example ['main']

View File

@@ -9,7 +9,9 @@ export class GetSystemSmtp {
*/ */
const() { const() {
return this.effects.getSystemSmtp({ return this.effects.getSystemSmtp({
callback: () => this.effects.constRetry(), callback:
this.effects.constRetry &&
(() => this.effects.constRetry && this.effects.constRetry()),
}) })
} }
/** /**

View File

@@ -24,10 +24,7 @@ export function partialDiff<T>(
return return
} }
} else if (typeof prev === "object" && typeof next === "object") { } else if (typeof prev === "object" && typeof next === "object") {
if (prev === null) { if (prev === null || next === null) return { diff: next }
return { diff: next }
}
if (next === null) return
const res = { diff: {} as Record<keyof T, any> } const res = { diff: {} as Record<keyof T, any> }
for (let key in next) { for (let key in next) {
const diff = partialDiff(prev[key], next[key]) const diff = partialDiff(prev[key], next[key])

View File

@@ -217,7 +217,9 @@ export class GetServiceInterface {
*/ */
async const() { async const() {
const { id, packageId } = this.opts const { id, packageId } = this.opts
const callback = () => this.effects.constRetry() const callback =
this.effects.constRetry &&
(() => this.effects.constRetry && this.effects.constRetry())
const interfaceFilled = await makeInterfaceFilled({ const interfaceFilled = await makeInterfaceFilled({
effects: this.effects, effects: this.effects,
id, id,

View File

@@ -51,7 +51,9 @@ export class GetServiceInterfaces {
*/ */
async const() { async const() {
const { packageId } = this.opts const { packageId } = this.opts
const callback = () => this.effects.constRetry() const callback =
this.effects.constRetry &&
(() => this.effects.constRetry && this.effects.constRetry())
const interfaceFilled: ServiceInterfaceFilled[] = const interfaceFilled: ServiceInterfaceFilled[] =
await makeManyInterfaceFilled({ await makeManyInterfaceFilled({
effects: this.effects, effects: this.effects,

View File

@@ -19,7 +19,6 @@ import {
SyncOptions, SyncOptions,
ServiceInterfaceId, ServiceInterfaceId,
PackageId, PackageId,
HealthReceipt,
ServiceInterfaceType, ServiceInterfaceType,
Effects, Effects,
} from "../../base/lib/types" } from "../../base/lib/types"
@@ -27,7 +26,7 @@ import * as patterns from "../../base/lib/util/patterns"
import { BackupSync, Backups } from "./backup/Backups" import { BackupSync, Backups } from "./backup/Backups"
import { smtpInputSpec } from "../../base/lib/actions/input/inputSpecConstants" import { smtpInputSpec } from "../../base/lib/actions/input/inputSpecConstants"
import { CommandController, Daemons } from "./mainFn/Daemons" import { CommandController, Daemons } from "./mainFn/Daemons"
import { healthCheck, HealthCheckParams } from "./health/HealthCheck" import { HealthCheck } from "./health/HealthCheck"
import { checkPortListening } from "./health/checkFns/checkPortListening" import { checkPortListening } from "./health/checkFns/checkPortListening"
import { checkWebUrl, runHealthScript } from "./health/checkFns" import { checkWebUrl, runHealthScript } from "./health/checkFns"
import { List } from "../../base/lib/actions/input/builder/list" import { List } from "../../base/lib/actions/input/builder/list"
@@ -73,7 +72,7 @@ import * as actions from "../../base/lib/actions"
import { setupInit } from "./inits/setupInit" import { setupInit } from "./inits/setupInit"
import * as fs from "node:fs/promises" import * as fs from "node:fs/promises"
export const OSVersion = testTypeVersion("0.3.6-alpha.15") export const OSVersion = testTypeVersion("0.3.6-alpha.16")
// prettier-ignore // prettier-ignore
type AnyNeverCond<T extends any[], Then, Else> = type AnyNeverCond<T extends any[], Then, Else> =
@@ -231,7 +230,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
}, },
command: T.CommandType, command: T.CommandType,
options: CommandOptions & { options: CommandOptions & {
mounts?: { path: string; options: MountOptions }[] mounts?: { mountpoint: string; options: MountOptions }[]
}, },
/** /**
* A name to use to refer to the ephemeral subcontainer for debugging purposes * A name to use to refer to the ephemeral subcontainer for debugging purposes
@@ -420,11 +419,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
hostnames: string[], hostnames: string[],
algorithm?: T.Algorithm, algorithm?: T.Algorithm,
) => new GetSslCertificate(effects, hostnames, algorithm), ) => new GetSslCertificate(effects, hostnames, algorithm),
HealthCheck: { HealthCheck,
of(effects: T.Effects, o: Omit<HealthCheckParams, "effects">) {
return healthCheck({ effects, ...o })
},
},
healthCheck: { healthCheck: {
checkPortListening, checkPortListening,
checkWebUrl, checkWebUrl,
@@ -677,9 +672,9 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
of( of(
effects: Effects, effects: Effects,
started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>, started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>,
healthReceipts: HealthReceipt[], healthChecks: HealthCheck[],
) { ) {
return Daemons.of<Manifest>({ effects, started, healthReceipts }) return Daemons.of<Manifest>({ effects, started, healthChecks })
}, },
}, },
SubContainer: { SubContainer: {
@@ -699,7 +694,7 @@ export class StartSdk<Manifest extends T.SDKManifest, Store> {
imageId: T.ImageId & keyof Manifest["images"] imageId: T.ImageId & keyof Manifest["images"]
sharedRun?: boolean sharedRun?: boolean
}, },
mounts: { options: MountOptions; path: string }[], mounts: { options: MountOptions; mountpoint: string }[],
name: string, name: string,
fn: (subContainer: SubContainer) => Promise<T>, fn: (subContainer: SubContainer) => Promise<T>,
): Promise<T> { ): Promise<T> {
@@ -1081,7 +1076,7 @@ export async function runCommand<Manifest extends T.SDKManifest>(
image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean }, image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },
command: T.CommandType, command: T.CommandType,
options: CommandOptions & { options: CommandOptions & {
mounts?: { path: string; options: MountOptions }[] mounts?: { mountpoint: string; options: MountOptions }[]
}, },
name?: string, name?: string,
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> { ): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {

View File

@@ -1,13 +1,12 @@
import { Effects, HealthCheckId, HealthReceipt } from "../../../base/lib/types" import { Effects, HealthCheckId } from "../../../base/lib/types"
import { HealthCheckResult } from "./checkFns/HealthCheckResult" import { HealthCheckResult } from "./checkFns/HealthCheckResult"
import { Trigger } from "../trigger" import { Trigger } from "../trigger"
import { TriggerInput } from "../trigger/TriggerInput" import { TriggerInput } from "../trigger/TriggerInput"
import { defaultTrigger } from "../trigger/defaultTrigger" import { defaultTrigger } from "../trigger/defaultTrigger"
import { once, asError } from "../util" import { once, asError, Drop } from "../util"
import { object, unknown } from "ts-matches" import { object, unknown } from "ts-matches"
export type HealthCheckParams = { export type HealthCheckParams = {
effects: Effects
id: HealthCheckId id: HealthCheckId
name: string name: string
trigger?: Trigger trigger?: Trigger
@@ -16,53 +15,110 @@ export type HealthCheckParams = {
onFirstSuccess?: () => unknown | Promise<unknown> onFirstSuccess?: () => unknown | Promise<unknown>
} }
export function healthCheck(o: HealthCheckParams) { export class HealthCheck extends Drop {
new Promise(async () => { private started: number | null = null
const start = performance.now() private setStarted = (started: number | null) => {
let currentValue: TriggerInput = {} this.started = started
const getCurrentValue = () => currentValue }
const gracePeriod = o.gracePeriod ?? 5000 private exited = false
const trigger = (o.trigger ?? defaultTrigger)(getCurrentValue) private exit = () => {
const triggerFirstSuccess = once(() => this.exited = true
Promise.resolve( }
"onFirstSuccess" in o && o.onFirstSuccess private currentValue: TriggerInput = {}
? o.onFirstSuccess() private promise: Promise<void>
: undefined, private constructor(effects: Effects, o: HealthCheckParams) {
), super()
) this.promise = Promise.resolve().then(async () => {
for ( const getCurrentValue = () => this.currentValue
let res = await trigger.next(); const gracePeriod = o.gracePeriod ?? 5000
!res.done; const trigger = (o.trigger ?? defaultTrigger)(getCurrentValue)
res = await trigger.next() const triggerFirstSuccess = once(() =>
) { Promise.resolve(
try { "onFirstSuccess" in o && o.onFirstSuccess
let { result, message } = await o.fn() ? o.onFirstSuccess()
if (result === "failure" && performance.now() - start <= gracePeriod) : undefined,
result = "starting" ),
await o.effects.setHealth({ )
name: o.name, const checkStarted = () =>
id: o.id, [
result, this.started,
message: message || "", new Promise<void>((resolve) => {
}) this.setStarted = (started: number | null) => {
currentValue.lastResult = result this.started = started
await triggerFirstSuccess().catch((err) => { resolve()
console.error(asError(err)) }
}) this.exit = () => {
} catch (e) { this.exited = true
await o.effects.setHealth({ resolve()
name: o.name, }
id: o.id, }),
result: ] as const
performance.now() - start <= gracePeriod ? "starting" : "failure", let triggered = false
message: asMessage(e) || "", while (!this.exited) {
}) const [started, changed] = checkStarted()
currentValue.lastResult = "failure" let race:
| [Promise<void>]
| [Promise<void>, Promise<IteratorResult<unknown, unknown>>] = [
changed,
]
if (started) {
race = [...race, trigger.next()]
if (triggered) {
try {
let { result, message } = await o.fn()
if (
result === "failure" &&
performance.now() - started <= gracePeriod
)
result = "starting"
await effects.setHealth({
name: o.name,
id: o.id,
result,
message: message || "",
})
this.currentValue.lastResult = result
await triggerFirstSuccess().catch((err) => {
console.error(asError(err))
})
} catch (e) {
await effects.setHealth({
name: o.name,
id: o.id,
result:
performance.now() - started <= gracePeriod
? "starting"
: "failure",
message: asMessage(e) || "",
})
this.currentValue.lastResult = "failure"
}
}
} else triggered = false
const raced = await Promise.race(race)
if (raced) {
if (raced.done) break
triggered = true
}
} }
} })
}) }
return {} as HealthReceipt static of(effects: Effects, options: HealthCheckParams): HealthCheck {
return new HealthCheck(effects, options)
}
start() {
if (this.started) return
this.setStarted(performance.now())
}
stop() {
if (!this.started) return
this.setStarted(null)
}
onDrop(): void {
this.exit()
}
} }
function asMessage(e: unknown) { function asMessage(e: unknown) {
if (object({ message: unknown }).test(e)) return String(e.message) if (object({ message: unknown }).test(e)) return String(e.message)
const value = String(e) const value = String(e)

View File

@@ -1 +1,3 @@
import "./checkFns" import "./checkFns"
export { HealthCheck } from "./HealthCheck"

View File

@@ -7,7 +7,6 @@ import {
ISB, ISB,
IST, IST,
types, types,
T,
matches, matches,
utils, utils,
} from "../../base/lib" } from "../../base/lib"
@@ -21,10 +20,10 @@ export {
ISB, ISB,
IST, IST,
types, types,
T,
matches, matches,
utils, utils,
} }
export * as T from "./types"
export { Daemons } from "./mainFn/Daemons" export { Daemons } from "./mainFn/Daemons"
export { SubContainer } from "./util/SubContainer" export { SubContainer } from "./util/SubContainer"
export { StartSdk } from "./StartSdk" export { StartSdk } from "./StartSdk"

View File

@@ -7,18 +7,20 @@ import {
SubContainerHandle, SubContainerHandle,
SubContainer, SubContainer,
} from "../util/SubContainer" } from "../util/SubContainer"
import { splitCommand } from "../util" import { Drop, splitCommand } from "../util"
import * as cp from "child_process" import * as cp from "child_process"
import * as fs from "node:fs/promises" import * as fs from "node:fs/promises"
export class CommandController { export class CommandController extends Drop {
private constructor( private constructor(
readonly runningAnswer: Promise<unknown>, readonly runningAnswer: Promise<unknown>,
private state: { exited: boolean }, private state: { exited: boolean },
private readonly subcontainer: SubContainer, private readonly subcontainer: SubContainer,
private process: cp.ChildProcess, private process: cp.ChildProcess,
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT, readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
) {} ) {
super()
}
static of<Manifest extends T.SDKManifest>() { static of<Manifest extends T.SDKManifest>() {
return async <A extends string>( return async <A extends string>(
effects: T.Effects, effects: T.Effects,
@@ -33,7 +35,7 @@ export class CommandController {
subcontainerName?: string subcontainerName?: string
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms // Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
sigtermTimeout?: number sigtermTimeout?: number
mounts?: { path: string; options: MountOptions }[] mounts?: { mountpoint: string; options: MountOptions }[]
runAsInit?: boolean runAsInit?: boolean
env?: env?:
| { | {
@@ -68,7 +70,7 @@ export class CommandController {
) )
try { try {
for (let mount of options.mounts || []) { for (let mount of options.mounts || []) {
await subc.mount(mount.options, mount.path) await subc.mount(mount.options, mount.mountpoint)
} }
return subc return subc
} catch (e) { } catch (e) {
@@ -135,37 +137,42 @@ export class CommandController {
return new SubContainerHandle(this.subcontainer) return new SubContainerHandle(this.subcontainer)
} }
async wait({ timeout = NO_TIMEOUT } = {}) { async wait({ timeout = NO_TIMEOUT } = {}) {
const self = this.weak()
if (timeout > 0) if (timeout > 0)
setTimeout(() => { setTimeout(() => {
this.term() self.term()
}, timeout) }, timeout)
try { try {
return await this.runningAnswer return await self.runningAnswer
} finally { } finally {
if (!this.state.exited) { if (!self.state.exited) {
this.process.kill("SIGKILL") self.process.kill("SIGKILL")
} }
await this.subcontainer.destroy().catch((_) => {}) await self.subcontainer.destroy().catch((_) => {})
} }
} }
async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) { async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) {
const self = this.weak()
try { try {
if (!this.state.exited) { if (!self.state.exited) {
if (signal !== "SIGKILL") { if (signal !== "SIGKILL") {
setTimeout(() => { setTimeout(() => {
if (!this.state.exited) this.process.kill("SIGKILL") if (!self.state.exited) self.process.kill("SIGKILL")
}, timeout) }, timeout)
} }
if (!this.process.kill(signal)) { if (!self.process.kill(signal)) {
console.error( console.error(
`failed to send signal ${signal} to pid ${this.process.pid}`, `failed to send signal ${signal} to pid ${this.process.pid}`,
) )
} }
} }
await this.runningAnswer await self.runningAnswer
} finally { } finally {
await this.subcontainer.destroy() await self.subcontainer.destroy()
} }
} }
onDrop(): void {
this.term().catch(console.error)
}
} }

View File

@@ -29,7 +29,7 @@ export class Daemon {
command: T.CommandType, command: T.CommandType,
options: { options: {
subcontainerName?: string subcontainerName?: string
mounts?: { path: string; options: MountOptions }[] mounts?: { mountpoint: string; options: MountOptions }[]
env?: env?:
| { | {
[variable: string]: string [variable: string]: string

View File

@@ -1,4 +1,4 @@
import { HealthReceipt, Signals } from "../../../base/lib/types" import { Signals } from "../../../base/lib/types"
import { HealthCheckResult } from "../health/checkFns" import { HealthCheckResult } from "../health/checkFns"
@@ -15,6 +15,7 @@ export { CommandController } from "./CommandController"
import { HealthDaemon } from "./HealthDaemon" import { HealthDaemon } from "./HealthDaemon"
import { Daemon } from "./Daemon" import { Daemon } from "./Daemon"
import { CommandController } from "./CommandController" import { CommandController } from "./CommandController"
import { HealthCheck } from "../health/HealthCheck"
export const cpExec = promisify(CP.exec) export const cpExec = promisify(CP.exec)
export const cpExecFile = promisify(CP.execFile) export const cpExecFile = promisify(CP.execFile)
@@ -115,6 +116,7 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
readonly daemons: Promise<Daemon>[], readonly daemons: Promise<Daemon>[],
readonly ids: Ids[], readonly ids: Ids[],
readonly healthDaemons: HealthDaemon[], readonly healthDaemons: HealthDaemon[],
readonly healthChecks: HealthCheck[],
) {} ) {}
/** /**
* Returns an empty new Daemons class with the provided inputSpec. * Returns an empty new Daemons class with the provided inputSpec.
@@ -129,7 +131,7 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
static of<Manifest extends T.SDKManifest>(options: { static of<Manifest extends T.SDKManifest>(options: {
effects: T.Effects effects: T.Effects
started: (onTerm: () => PromiseLike<void>) => PromiseLike<null> started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>
healthReceipts: HealthReceipt[] healthChecks: HealthCheck[]
}) { }) {
return new Daemons<Manifest, never>( return new Daemons<Manifest, never>(
options.effects, options.effects,
@@ -137,6 +139,7 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
[], [],
[], [],
[], [],
options.healthChecks,
) )
} }
/** /**
@@ -187,28 +190,33 @@ export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
daemons, daemons,
ids, ids,
healthDaemons, healthDaemons,
this.healthChecks,
) )
} }
async build() { async term() {
const built = { try {
term: async () => { this.healthChecks.forEach((health) => health.stop())
try { for (let result of await Promise.allSettled(
for (let result of await Promise.allSettled( this.healthDaemons.map((x) => x.term({ timeout: x.sigtermTimeout })),
this.healthDaemons.map((x) => )) {
x.term({ timeout: x.sigtermTimeout }), if (result.status === "rejected") {
), console.error(result.reason)
)) {
if (result.status === "rejected") {
console.error(result.reason)
}
}
} finally {
this.effects.setMainStatus({ status: "stopped" })
} }
}, }
} finally {
this.effects.setMainStatus({ status: "stopped" })
} }
this.started(() => built.term()) }
return built
async build() {
for (const daemon of this.healthDaemons) {
await daemon.updateStatus()
}
for (const health of this.healthChecks) {
health.start()
}
this.started(() => this.term())
return this
} }
} }

View File

@@ -39,7 +39,6 @@ export class HealthDaemon {
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT, readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
) { ) {
this.readyPromise = new Promise((resolve) => (this.resolveReady = resolve)) this.readyPromise = new Promise((resolve) => (this.resolveReady = resolve))
this.updateStatus()
this.dependencies.forEach((d) => d.addWatcher(() => this.updateStatus())) this.dependencies.forEach((d) => d.addWatcher(() => this.updateStatus()))
} }
@@ -166,8 +165,8 @@ export class HealthDaemon {
} as SetHealth) } as SetHealth)
} }
private async updateStatus() { async updateStatus() {
const healths = this.dependencies.map((d) => d._health) const healths = this.dependencies.map((d) => d.running && d._health)
this.changeRunning(healths.every((x) => x.result === "success")) this.changeRunning(healths.every((x) => x && x.result === "success"))
} }
} }

View File

@@ -1,7 +1,7 @@
import * as T from "../../../base/lib/types" import * as T from "../../../base/lib/types"
import { MountOptions } from "../util/SubContainer" import { MountOptions } from "../util/SubContainer"
type MountArray = { path: string; options: MountOptions }[] type MountArray = { mountpoint: string; options: MountOptions }[]
export class Mounts<Manifest extends T.SDKManifest> { export class Mounts<Manifest extends T.SDKManifest> {
private constructor( private constructor(
@@ -12,7 +12,6 @@ export class Mounts<Manifest extends T.SDKManifest> {
readonly: boolean readonly: boolean
}[], }[],
readonly assets: { readonly assets: {
id: Manifest["assets"][number]
subpath: string | null subpath: string | null
mountpoint: string mountpoint: string
}[], }[],
@@ -49,15 +48,12 @@ export class Mounts<Manifest extends T.SDKManifest> {
} }
addAssets( addAssets(
/** The ID of the asset directory to mount. This is typically the same as the folder name in your assets directory */
id: Manifest["assets"][number],
/** The path within the asset directory to mount. Use `null` to mount the entire volume */ /** The path within the asset directory to mount. Use `null` to mount the entire volume */
subpath: string | null, subpath: string | null,
/** Where to mount the asset. e.g. /asset */ /** Where to mount the asset. e.g. /asset */
mountpoint: string, mountpoint: string,
) { ) {
this.assets.push({ this.assets.push({
id,
subpath, subpath,
mountpoint, mountpoint,
}) })
@@ -102,7 +98,7 @@ export class Mounts<Manifest extends T.SDKManifest> {
return ([] as MountArray) return ([] as MountArray)
.concat( .concat(
this.volumes.map((v) => ({ this.volumes.map((v) => ({
path: v.mountpoint, mountpoint: v.mountpoint,
options: { options: {
type: "volume", type: "volume",
id: v.id, id: v.id,
@@ -113,17 +109,16 @@ export class Mounts<Manifest extends T.SDKManifest> {
) )
.concat( .concat(
this.assets.map((a) => ({ this.assets.map((a) => ({
path: a.mountpoint, mountpoint: a.mountpoint,
options: { options: {
type: "assets", type: "assets",
id: a.id,
subpath: a.subpath, subpath: a.subpath,
}, },
})), })),
) )
.concat( .concat(
this.dependencies.map((d) => ({ this.dependencies.map((d) => ({
path: d.mountpoint, mountpoint: d.mountpoint,
options: { options: {
type: "pointer", type: "pointer",
packageId: d.dependencyId, packageId: d.dependencyId,

View File

@@ -16,10 +16,8 @@ import { execSync } from "child_process"
export function setupManifest< export function setupManifest<
Id extends string, Id extends string,
VolumesTypes extends VolumeId, VolumesTypes extends VolumeId,
AssetTypes extends VolumeId,
Manifest extends { Manifest extends {
id: Id id: Id
assets: AssetTypes[]
volumes: VolumesTypes[] volumes: VolumesTypes[]
} & SDKManifest, } & SDKManifest,
>(manifest: Manifest & SDKManifest): Manifest { >(manifest: Manifest & SDKManifest): Manifest {
@@ -31,12 +29,10 @@ export function buildManifest<
Version extends string, Version extends string,
Dependencies extends Record<string, unknown>, Dependencies extends Record<string, unknown>,
VolumesTypes extends VolumeId, VolumesTypes extends VolumeId,
AssetTypes extends VolumeId,
ImagesTypes extends ImageId, ImagesTypes extends ImageId,
Manifest extends { Manifest extends {
dependencies: Dependencies dependencies: Dependencies
id: Id id: Id
assets: AssetTypes[]
images: Record<ImagesTypes, SDKImageInputSpec> images: Record<ImagesTypes, SDKImageInputSpec>
volumes: VolumesTypes[] volumes: VolumesTypes[]
}, },

View File

@@ -18,7 +18,9 @@ export class GetStore<Store, StoreValue> {
return this.effects.store.get<Store, StoreValue>({ return this.effects.store.get<Store, StoreValue>({
...this.options, ...this.options,
path: extractJsonPath(this.path), path: extractJsonPath(this.path),
callback: () => this.effects.constRetry(), callback:
this.effects.constRetry &&
(() => this.effects.constRetry && this.effects.constRetry()),
}) })
} }
/** /**

2
sdk/package/lib/types.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "../../base/lib/types"
export { HealthCheck } from "./health"

View File

@@ -0,0 +1,26 @@
export abstract class Drop {
private static weak: { [id: number]: Drop } = {}
private static registry = new FinalizationRegistry((id: number) => {
Drop.weak[id].drop()
})
private static idCtr: number = 0
private id: number
private ref: { id: number } | WeakRef<{ id: number }>
protected constructor() {
this.id = Drop.idCtr++
this.ref = { id: this.id }
Drop.weak[this.id] = this.weak()
Drop.registry.register(this, this.id, this)
}
protected weak(): this {
const weak = Object.assign(Object.create(Object.getPrototypeOf(this)), this)
weak.ref = new WeakRef(this.ref)
return weak
}
abstract onDrop(): void
drop(): void {
this.onDrop()
Drop.registry.unregister(this)
delete Drop.weak[this.id]
}
}

View File

@@ -15,7 +15,9 @@ export class GetSslCertificate {
return this.effects.getSslCertificate({ return this.effects.getSslCertificate({
hostnames: this.hostnames, hostnames: this.hostnames,
algorithm: this.algorithm, algorithm: this.algorithm,
callback: () => this.effects.constRetry(), callback:
this.effects.constRetry &&
(() => this.effects.constRetry && this.effects.constRetry()),
}) })
} }
/** /**

View File

@@ -130,14 +130,14 @@ export class SubContainer implements ExecSpawnable {
static async with<T>( static async with<T>(
effects: T.Effects, effects: T.Effects,
image: { imageId: T.ImageId; sharedRun?: boolean }, image: { imageId: T.ImageId; sharedRun?: boolean },
mounts: { options: MountOptions; path: string }[], mounts: { options: MountOptions; mountpoint: string }[],
name: string, name: string,
fn: (subContainer: SubContainer) => Promise<T>, fn: (subContainer: SubContainer) => Promise<T>,
): Promise<T> { ): Promise<T> {
const subContainer = await SubContainer.of(effects, image, name) const subContainer = await SubContainer.of(effects, image, name)
try { try {
for (let mount of mounts) { for (let mount of mounts) {
await subContainer.mount(mount.options, mount.path) await subContainer.mount(mount.options, mount.mountpoint)
} }
return await fn(subContainer) return await fn(subContainer)
} finally { } finally {
@@ -166,7 +166,7 @@ export class SubContainer implements ExecSpawnable {
? options.subpath ? options.subpath
: `/${options.subpath}` : `/${options.subpath}`
: "/" : "/"
const from = `/media/startos/assets/${options.id}${subpath}` const from = `/media/startos/assets/${subpath}`
await fs.mkdir(from, { recursive: true }) await fs.mkdir(from, { recursive: true })
await fs.mkdir(path, { recursive: true }) await fs.mkdir(path, { recursive: true })
@@ -449,7 +449,6 @@ export type MountOptionsVolume = {
export type MountOptionsAssets = { export type MountOptionsAssets = {
type: "assets" type: "assets"
id: string
subpath: string | null subpath: string | null
} }

View File

@@ -3,7 +3,7 @@ import * as YAML from "yaml"
import * as TOML from "@iarna/toml" import * as TOML from "@iarna/toml"
import * as T from "../../../base/lib/types" import * as T from "../../../base/lib/types"
import * as fs from "node:fs/promises" import * as fs from "node:fs/promises"
import { asError } from "../../../base/lib/util" import { asError, partialDiff } from "../../../base/lib/util"
const previousPath = /(.+?)\/([^/]*)$/ const previousPath = /(.+?)\/([^/]*)$/
@@ -101,6 +101,7 @@ function fileMerge(...args: any[]): any {
* ``` * ```
*/ */
export class FileHelper<A> { export class FileHelper<A> {
private consts: (() => void)[] = []
protected constructor( protected constructor(
readonly path: string, readonly path: string,
readonly writeData: (dataIn: A) => string, readonly writeData: (dataIn: A) => string,
@@ -108,27 +109,37 @@ export class FileHelper<A> {
readonly validate: (value: unknown) => A, readonly validate: (value: unknown) => A,
) {} ) {}
/** private async writeFileRaw(data: string): Promise<null> {
* Accepts structured data and overwrites the existing file on disk.
*/
private async writeFile(data: A): Promise<null> {
const parent = previousPath.exec(this.path) const parent = previousPath.exec(this.path)
if (parent) { if (parent) {
await fs.mkdir(parent[1], { recursive: true }) await fs.mkdir(parent[1], { recursive: true })
} }
await fs.writeFile(this.path, this.writeData(data)) await fs.writeFile(this.path, data)
return null return null
} }
private async readFile(): Promise<unknown> { /**
* Accepts structured data and overwrites the existing file on disk.
*/
private async writeFile(data: A): Promise<null> {
return await this.writeFileRaw(this.writeData(data))
}
private async readFileRaw(): Promise<string | null> {
if (!(await exists(this.path))) { if (!(await exists(this.path))) {
return null return null
} }
return this.readData( return await fs.readFile(this.path).then((data) => data.toString("utf-8"))
await fs.readFile(this.path).then((data) => data.toString("utf-8")), }
)
private async readFile(): Promise<unknown> {
const raw = await this.readFileRaw()
if (raw === null) {
return raw
}
return this.readData(raw)
} }
/** /**
@@ -143,7 +154,14 @@ export class FileHelper<A> {
private async readConst(effects: T.Effects): Promise<A | null> { private async readConst(effects: T.Effects): Promise<A | null> {
const watch = this.readWatch() const watch = this.readWatch()
const res = await watch.next() const res = await watch.next()
watch.next().then(effects.constRetry) if (effects.constRetry) {
if (!this.consts.includes(effects.constRetry))
this.consts.push(effects.constRetry)
watch.next().then(() => {
this.consts = this.consts.filter((a) => a === effects.constRetry)
effects.constRetry && effects.constRetry()
})
}
return res.value return res.value
} }
@@ -213,17 +231,35 @@ export class FileHelper<A> {
/** /**
* Accepts full structured data and overwrites the existing file on disk if it exists. * Accepts full structured data and overwrites the existing file on disk if it exists.
*/ */
async write(data: A) { async write(effects: T.Effects, data: A) {
return await this.writeFile(this.validate(data)) await this.writeFile(this.validate(data))
if (effects.constRetry && this.consts.includes(effects.constRetry))
throw new Error(`Canceled: write after const: ${this.path}`)
return null
} }
/** /**
* Accepts partial structured data and performs a merge with the existing file on disk. * Accepts partial structured data and performs a merge with the existing file on disk.
*/ */
async merge(data: T.DeepPartial<A>) { async merge(effects: T.Effects, data: T.DeepPartial<A>) {
const fileData = (await this.readFile()) || null const fileDataRaw = await this.readFileRaw()
const mergeData = fileMerge(fileData, data) let fileData: any = fileDataRaw === null ? null : this.readData(fileDataRaw)
return await this.writeFile(this.validate(mergeData)) try {
fileData = this.validate(fileData)
} catch (_) {}
const mergeData = this.validate(fileMerge({}, fileData, data))
const toWrite = this.writeData(mergeData)
if (toWrite !== fileDataRaw) {
this.writeFile(mergeData)
if (effects.constRetry && this.consts.includes(effects.constRetry)) {
const diff = partialDiff(fileData, mergeData as any)
if (!diff) {
return null
}
throw new Error(`Canceled: write after const: ${this.path}`)
}
}
return null
} }
/** /**

View File

@@ -2,3 +2,4 @@ export * from "../../../base/lib/util"
export { GetSslCertificate } from "./GetSslCertificate" export { GetSslCertificate } from "./GetSslCertificate"
export { hostnameInfoToAddress } from "../../../base/lib/util/Hostname" export { hostnameInfoToAddress } from "../../../base/lib/util/Hostname"
export { Drop } from "./Drop"

View File

@@ -1,17 +1,18 @@
{ {
"name": "@start9labs/start-sdk", "name": "@start9labs/start-sdk",
"version": "0.3.6-beta.14", "version": "0.3.6-beta.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@start9labs/start-sdk", "name": "@start9labs/start-sdk",
"version": "0.3.6-beta.14", "version": "0.3.6-beta.18",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@noble/curves": "^1.4.0", "@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"deep-equality-data-structures": "^1.5.1",
"isomorphic-fetch": "^3.0.0", "isomorphic-fetch": "^3.0.0",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -1802,6 +1803,15 @@
"integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==",
"dev": true "dev": true
}, },
"node_modules/deep-equality-data-structures": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-1.5.1.tgz",
"integrity": "sha512-P7zsL2/AbZIGHDxbo/LLEhCp11AttRp8GvzXOXudqMT/qiGCLo/pyI4lAZvjUZyQnlIbPna3fv8DMsuRvLt4ww==",
"license": "MIT",
"dependencies": {
"object-hash": "^3.0.0"
}
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -3238,6 +3248,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@start9labs/start-sdk", "name": "@start9labs/start-sdk",
"version": "0.3.6-beta.14", "version": "0.3.6-beta.18",
"description": "Software development kit to facilitate packaging services for StartOS", "description": "Software development kit to facilitate packaging services for StartOS",
"main": "./package/lib/index.js", "main": "./package/lib/index.js",
"types": "./package/lib/index.d.ts", "types": "./package/lib/index.d.ts",
@@ -35,6 +35,7 @@
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"ts-matches": "^6.2.1", "ts-matches": "^6.2.1",
"yaml": "^2.2.2", "yaml": "^2.2.2",
"deep-equality-data-structures": "^1.5.1",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@noble/curves": "^1.4.0", "@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0" "@noble/hashes": "^1.4.0"

4
web/package-lock.json generated
View File

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

View File

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

View File

@@ -116,7 +116,6 @@ export module Mock {
emulateMissingAs: 'aarch64', emulateMissingAs: 'aarch64',
}, },
}, },
assets: [],
volumes: ['main'], volumes: ['main'],
hardwareRequirements: { hardwareRequirements: {
device: [], device: [],
@@ -173,7 +172,6 @@ export module Mock {
emulateMissingAs: 'aarch64', emulateMissingAs: 'aarch64',
}, },
}, },
assets: [],
volumes: ['main'], volumes: ['main'],
hardwareRequirements: { hardwareRequirements: {
device: [], device: [],
@@ -223,7 +221,6 @@ export module Mock {
emulateMissingAs: 'aarch64', emulateMissingAs: 'aarch64',
}, },
}, },
assets: [],
volumes: ['main'], volumes: ['main'],
hardwareRequirements: { hardwareRequirements: {
device: [], device: [],