diff --git a/Makefile b/Makefile index c0492ba55..4fb5033d0 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,6 @@ BASENAME := $(shell ./basename.sh) PLATFORM := $(shell if [ -f ./PLATFORM.txt ]; then cat ./PLATFORM.txt; else echo unknown; fi) ARCH := $(shell if [ "$(PLATFORM)" = "raspberrypi" ]; then echo aarch64; else echo $(PLATFORM) | sed 's/-nonfree$$//g'; fi) IMAGE_TYPE=$(shell if [ "$(PLATFORM)" = raspberrypi ]; then echo img; else echo iso; fi) -BINS := core/target/$(ARCH)-unknown-linux-musl/release/startbox core/target/$(ARCH)-unknown-linux-musl/release/containerbox WEB_UIS := web/dist/raw/ui web/dist/raw/setup-wizard web/dist/raw/install-wizard FIRMWARE_ROMS := ./firmware/$(PLATFORM) $(shell jq --raw-output '.[] | select(.platform[] | contains("$(PLATFORM)")) | "./firmware/$(PLATFORM)/" + .id + ".rom.gz"' build/lib/firmware.json) BUILD_SRC := $(shell git ls-files build) build/lib/depends build/lib/conflicts $(FIRMWARE_ROMS) @@ -16,7 +15,7 @@ STARTD_SRC := core/startos/startd.service $(BUILD_SRC) COMPAT_SRC := $(shell git ls-files system-images/compat/) UTILS_SRC := $(shell git ls-files system-images/utils/) BINFMT_SRC := $(shell git ls-files system-images/binfmt/) -CORE_SRC := $(shell git ls-files core) $(shell git ls-files --recurse-submodules patch-db) web/dist/static web/patchdb-ui-seed.json $(GIT_HASH_FILE) +CORE_SRC := $(shell git ls-files core) $(shell git ls-files --recurse-submodules patch-db) $(GIT_HASH_FILE) WEB_SHARED_SRC := $(shell git ls-files web/projects/shared) $(shell ls -p web/ | grep -v / | sed 's/^/web\//g') web/node_modules/.package-lock.json web/config.json patch-db/client/dist web/patchdb-ui-seed.json sdk/dist WEB_UI_SRC := $(shell git ls-files web/projects/ui) WEB_SETUP_WIZARD_SRC := $(shell git ls-files web/projects/setup-wizard) @@ -24,7 +23,7 @@ WEB_INSTALL_WIZARD_SRC := $(shell git ls-files web/projects/install-wizard) PATCH_DB_CLIENT_SRC := $(shell git ls-files --recurse-submodules patch-db/client) GZIP_BIN := $(shell which pigz || which gzip) TAR_BIN := $(shell which gtar || which tar) -COMPILED_TARGETS := $(BINS) system-images/compat/docker-images/$(ARCH).tar system-images/utils/docker-images/$(ARCH).tar system-images/binfmt/docker-images/$(ARCH).tar container-runtime/rootfs.$(ARCH).squashfs +COMPILED_TARGETS := core/target/$(ARCH)-unknown-linux-musl/release/startbox core/target/$(ARCH)-unknown-linux-musl/release/containerbox system-images/compat/docker-images/$(ARCH).tar system-images/utils/docker-images/$(ARCH).tar system-images/binfmt/docker-images/$(ARCH).tar container-runtime/rootfs.$(ARCH).squashfs ALL_TARGETS := $(STARTD_SRC) $(ENVIRONMENT_FILE) $(GIT_HASH_FILE) $(VERSION_FILE) $(COMPILED_TARGETS) cargo-deps/$(ARCH)-unknown-linux-musl/release/startos-backup-fs $(shell if [ "$(PLATFORM)" = "raspberrypi" ]; then echo cargo-deps/aarch64-unknown-linux-musl/release/pi-beep; fi) $(shell /bin/bash -c 'if [[ "${ENVIRONMENT}" =~ (^|-)unstable($$|-) ]]; then echo cargo-deps/$(ARCH)-unknown-linux-musl/release/tokio-console; fi') $(PLATFORM_FILE) ifeq ($(REMOTE),) @@ -91,7 +90,7 @@ format: cd core && cargo +nightly fmt test: $(CORE_SRC) $(ENVIRONMENT_FILE) - (cd core && cargo build && cargo test) + (cd core && cargo build --features=test && cargo test --features=test) (cd sdk && make test) cli: @@ -257,9 +256,13 @@ system-images/utils/docker-images/$(ARCH).tar: $(UTILS_SRC) system-images/binfmt/docker-images/$(ARCH).tar: $(BINFMT_SRC) cd system-images/binfmt && make docker-images/$(ARCH).tar && touch docker-images/$(ARCH).tar -$(BINS): $(CORE_SRC) $(ENVIRONMENT_FILE) - cd core && ARCH=$(ARCH) ./build-startos-bins.sh - touch $(BINS) +core/target/$(ARCH)-unknown-linux-musl/release/startbox: $(CORE_SRC) web/dist/static web/patchdb-ui-seed.json $(ENVIRONMENT_FILE) + ARCH=$(ARCH) ./core/build-startbox.sh + touch core/target/$(ARCH)-unknown-linux-musl/release/startbox + +core/target/$(ARCH)-unknown-linux-musl/release/containerbox: $(CORE_SRC) $(ENVIRONMENT_FILE) + ARCH=$(ARCH) ./core/build-containerbox.sh + touch core/target/$(ARCH)-unknown-linux-musl/release/containerbox web/node_modules/.package-lock.json: web/package.json sdk/dist npm --prefix web ci diff --git a/container-runtime/src/Adapters/EffectCreator.ts b/container-runtime/src/Adapters/EffectCreator.ts new file mode 100644 index 000000000..0ef299151 --- /dev/null +++ b/container-runtime/src/Adapters/EffectCreator.ts @@ -0,0 +1,301 @@ +import { types as T } from "@start9labs/start-sdk" +import * as net from "net" +import { object, string, number, literals, some, unknown } from "ts-matches" +import { Effects } from "../Models/Effects" + +import { CallbackHolder } from "../Models/CallbackHolder" +import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk" +const matchRpcError = object({ + error: object( + { + code: number, + message: string, + data: some( + string, + object( + { + details: string, + debug: string, + }, + ["debug"], + ), + ), + }, + ["data"], + ), +}) +const testRpcError = matchRpcError.test +const testRpcResult = object({ + result: unknown, +}).test +type RpcError = typeof matchRpcError._TYPE + +const SOCKET_PATH = "/media/startos/rpc/host.sock" +let hostSystemId = 0 + +export type EffectContext = { + procedureId: string | null + callbacks: CallbackHolder | null +} + +const rpcRoundFor = + (procedureId: string | null) => + ( + method: K, + params: Record, + ) => { + const id = hostSystemId++ + const client = net.createConnection({ path: SOCKET_PATH }, () => { + client.write( + JSON.stringify({ + id, + method, + params: { ...params, procedureId }, + }) + "\n", + ) + }) + let bufs: Buffer[] = [] + return new Promise((resolve, reject) => { + client.on("data", (data) => { + try { + bufs.push(data) + if (data.reduce((acc, x) => acc || x == 10, false)) { + const res: unknown = JSON.parse( + Buffer.concat(bufs).toString().split("\n")[0], + ) + if (testRpcError(res)) { + let message = res.error.message + console.error("Error in host RPC:", { method, params }) + if (string.test(res.error.data)) { + message += ": " + res.error.data + console.error(`Details: ${res.error.data}`) + } else { + if (res.error.data?.details) { + message += ": " + res.error.data.details + console.error(`Details: ${res.error.data.details}`) + } + if (res.error.data?.debug) { + message += "\n" + res.error.data.debug + console.error(`Debug: ${res.error.data.debug}`) + } + } + reject(new Error(`${message}@${method}`)) + } else if (testRpcResult(res)) { + resolve(res.result) + } else { + reject(new Error(`malformed response ${JSON.stringify(res)}`)) + } + } + } catch (error) { + reject(error) + } + client.end() + }) + client.on("error", (error) => { + reject(error) + }) + }) + } + +function makeEffects(context: EffectContext): Effects { + const rpcRound = rpcRoundFor(context.procedureId) + const self: Effects = { + bind(...[options]: Parameters) { + return rpcRound("bind", { + ...options, + stack: new Error().stack, + }) as ReturnType + }, + clearBindings(...[]: Parameters) { + return rpcRound("clearBindings", {}) as ReturnType< + T.Effects["clearBindings"] + > + }, + clearServiceInterfaces( + ...[]: Parameters + ) { + return rpcRound("clearServiceInterfaces", {}) as ReturnType< + T.Effects["clearServiceInterfaces"] + > + }, + getInstalledPackages(...[]: Parameters) { + return rpcRound("getInstalledPackages", {}) as ReturnType< + T.Effects["getInstalledPackages"] + > + }, + createOverlayedImage(options: { + imageId: string + }): Promise<[string, string]> { + return rpcRound("createOverlayedImage", options) as ReturnType< + T.Effects["createOverlayedImage"] + > + }, + destroyOverlayedImage(options: { guid: string }): Promise { + return rpcRound("destroyOverlayedImage", options) as ReturnType< + T.Effects["destroyOverlayedImage"] + > + }, + executeAction(...[options]: Parameters) { + return rpcRound("executeAction", options) as ReturnType< + T.Effects["executeAction"] + > + }, + exportAction(...[options]: Parameters) { + return rpcRound("exportAction", options) as ReturnType< + T.Effects["exportAction"] + > + }, + exportServiceInterface: (( + ...[options]: Parameters + ) => { + return rpcRound("exportServiceInterface", options) as ReturnType< + T.Effects["exportServiceInterface"] + > + }) as Effects["exportServiceInterface"], + exposeForDependents( + ...[options]: Parameters + ) { + return rpcRound("exposeForDependents", options) as ReturnType< + T.Effects["exposeForDependents"] + > + }, + getConfigured(...[]: Parameters) { + return rpcRound("getConfigured", {}) as ReturnType< + T.Effects["getConfigured"] + > + }, + getContainerIp(...[]: Parameters) { + return rpcRound("getContainerIp", {}) as ReturnType< + T.Effects["getContainerIp"] + > + }, + getHostInfo: ((...[allOptions]: Parameters) => { + const options = { + ...allOptions, + callback: context.callbacks?.addCallback(allOptions.callback) || null, + } + return rpcRound("getHostInfo", options) as ReturnType< + T.Effects["getHostInfo"] + > as any + }) as Effects["getHostInfo"], + getServiceInterface( + ...[options]: Parameters + ) { + return rpcRound("getServiceInterface", { + ...options, + callback: context.callbacks?.addCallback(options.callback) || null, + }) as ReturnType + }, + + getPrimaryUrl(...[options]: Parameters) { + return rpcRound("getPrimaryUrl", { + ...options, + callback: context.callbacks?.addCallback(options.callback) || null, + }) as ReturnType + }, + getServicePortForward( + ...[options]: Parameters + ) { + return rpcRound("getServicePortForward", options) as ReturnType< + T.Effects["getServicePortForward"] + > + }, + getSslCertificate(options: Parameters[0]) { + return rpcRound("getSslCertificate", options) as ReturnType< + T.Effects["getSslCertificate"] + > + }, + getSslKey(options: Parameters[0]) { + return rpcRound("getSslKey", options) as ReturnType< + T.Effects["getSslKey"] + > + }, + getSystemSmtp(...[options]: Parameters) { + return rpcRound("getSystemSmtp", { + ...options, + callback: context.callbacks?.addCallback(options.callback) || null, + }) as ReturnType + }, + listServiceInterfaces( + ...[options]: Parameters + ) { + return rpcRound("listServiceInterfaces", { + ...options, + callback: context.callbacks?.addCallback(options.callback) || null, + }) as ReturnType + }, + mount(...[options]: Parameters) { + return rpcRound("mount", options) as ReturnType + }, + clearActions(...[]: Parameters) { + return rpcRound("clearActions", {}) as ReturnType< + T.Effects["clearActions"] + > + }, + restart(...[]: Parameters) { + return rpcRound("restart", {}) as ReturnType + }, + setConfigured(...[configured]: Parameters) { + return rpcRound("setConfigured", { configured }) as ReturnType< + T.Effects["setConfigured"] + > + }, + setDependencies( + dependencies: Parameters[0], + ): ReturnType { + return rpcRound("setDependencies", dependencies) as ReturnType< + T.Effects["setDependencies"] + > + }, + checkDependencies( + options: Parameters[0], + ): ReturnType { + return rpcRound("checkDependencies", options) as ReturnType< + T.Effects["checkDependencies"] + > + }, + getDependencies(): ReturnType { + return rpcRound("getDependencies", {}) as ReturnType< + T.Effects["getDependencies"] + > + }, + setHealth(...[options]: Parameters) { + return rpcRound("setHealth", options) as ReturnType< + T.Effects["setHealth"] + > + }, + + setMainStatus(o: { status: "running" | "stopped" }): Promise { + return rpcRound("setMainStatus", o) as ReturnType + }, + + shutdown(...[]: Parameters) { + return rpcRound("shutdown", {}) as ReturnType + }, + store: { + get: async (options: any) => + rpcRound("getStore", { + ...options, + callback: context.callbacks?.addCallback(options.callback) || null, + }) as any, + set: async (options: any) => + rpcRound("setStore", options) as ReturnType, + } as T.Effects["store"], + } + return self +} + +export function makeProcedureEffects(procedureId: string): Effects { + return makeEffects({ procedureId, callbacks: null }) +} + +export function makeMainEffects(): MainEffects { + const rpcRound = rpcRoundFor(null) + return { + _type: "main", + clearCallbacks: () => { + return rpcRound("clearCallbacks", {}) as Promise + }, + ...makeEffects({ procedureId: null, callbacks: new CallbackHolder() }), + } +} diff --git a/container-runtime/src/Adapters/HostSystemStartOs.ts b/container-runtime/src/Adapters/HostSystemStartOs.ts deleted file mode 100644 index 1996af0fd..000000000 --- a/container-runtime/src/Adapters/HostSystemStartOs.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { types as T } from "@start9labs/start-sdk" -import * as net from "net" -import { object, string, number, literals, some, unknown } from "ts-matches" -import { Effects } from "../Models/Effects" - -import { CallbackHolder } from "../Models/CallbackHolder" -const matchRpcError = object({ - error: object( - { - code: number, - message: string, - data: some( - string, - object( - { - details: string, - debug: string, - }, - ["debug"], - ), - ), - }, - ["data"], - ), -}) -const testRpcError = matchRpcError.test -const testRpcResult = object({ - result: unknown, -}).test -type RpcError = typeof matchRpcError._TYPE - -const SOCKET_PATH = "/media/startos/rpc/host.sock" -const MAIN = "/main" as const -let hostSystemId = 0 -export const hostSystemStartOs = - (callbackHolder: CallbackHolder) => - (procedureId: null | string): Effects => { - const rpcRound = ( - method: K, - params: Record, - ) => { - const id = hostSystemId++ - const client = net.createConnection({ path: SOCKET_PATH }, () => { - client.write( - JSON.stringify({ - id, - method, - params: { ...params, procedureId: procedureId }, - }) + "\n", - ) - }) - let bufs: Buffer[] = [] - return new Promise((resolve, reject) => { - client.on("data", (data) => { - try { - bufs.push(data) - if (data.reduce((acc, x) => acc || x == 10, false)) { - const res: unknown = JSON.parse( - Buffer.concat(bufs).toString().split("\n")[0], - ) - if (testRpcError(res)) { - let message = res.error.message - console.error({ method, params, hostSystemStartOs: true }) - if (string.test(res.error.data)) { - message += ": " + res.error.data - console.error(res.error.data) - } else { - if (res.error.data?.details) { - message += ": " + res.error.data.details - console.error(res.error.data.details) - } - if (res.error.data?.debug) { - message += "\n" + res.error.data.debug - console.error("Debug: " + res.error.data.debug) - } - } - reject(new Error(`${message}@${method}`)) - } else if (testRpcResult(res)) { - resolve(res.result) - } else { - reject(new Error(`malformed response ${JSON.stringify(res)}`)) - } - } - } catch (error) { - reject(error) - } - client.end() - }) - client.on("error", (error) => { - reject(error) - }) - }) - } - const self: Effects = { - bind(...[options]: Parameters) { - return rpcRound("bind", { - ...options, - stack: new Error().stack, - }) as ReturnType - }, - clearBindings(...[]: Parameters) { - return rpcRound("clearBindings", {}) as ReturnType< - T.Effects["clearBindings"] - > - }, - clearServiceInterfaces( - ...[]: Parameters - ) { - return rpcRound("clearServiceInterfaces", {}) as ReturnType< - T.Effects["clearServiceInterfaces"] - > - }, - createOverlayedImage(options: { - imageId: string - }): Promise<[string, string]> { - return rpcRound("createOverlayedImage", options) as ReturnType< - T.Effects["createOverlayedImage"] - > - }, - destroyOverlayedImage(options: { guid: string }): Promise { - return rpcRound("destroyOverlayedImage", options) as ReturnType< - T.Effects["destroyOverlayedImage"] - > - }, - executeAction(...[options]: Parameters) { - return rpcRound("executeAction", options) as ReturnType< - T.Effects["executeAction"] - > - }, - exists(...[packageId]: Parameters) { - return rpcRound("exists", packageId) as ReturnType - }, - exportAction(...[options]: Parameters) { - return rpcRound("exportAction", options) as ReturnType< - T.Effects["exportAction"] - > - }, - exportServiceInterface: (( - ...[options]: Parameters - ) => { - return rpcRound("exportServiceInterface", options) as ReturnType< - T.Effects["exportServiceInterface"] - > - }) as Effects["exportServiceInterface"], - exposeForDependents( - ...[options]: Parameters - ) { - return rpcRound("exposeForDependents", options) as ReturnType< - T.Effects["exposeForDependents"] - > - }, - getConfigured(...[]: Parameters) { - return rpcRound("getConfigured", {}) as ReturnType< - T.Effects["getConfigured"] - > - }, - getContainerIp(...[]: Parameters) { - return rpcRound("getContainerIp", {}) as ReturnType< - T.Effects["getContainerIp"] - > - }, - getHostInfo: ((...[allOptions]: any[]) => { - const options = { - ...allOptions, - callback: callbackHolder.addCallback(allOptions.callback), - } - return rpcRound("getHostInfo", options) as ReturnType< - T.Effects["getHostInfo"] - > as any - }) as Effects["getHostInfo"], - getServiceInterface( - ...[options]: Parameters - ) { - return rpcRound("getServiceInterface", { - ...options, - callback: callbackHolder.addCallback(options.callback), - }) as ReturnType - }, - - getPrimaryUrl(...[options]: Parameters) { - return rpcRound("getPrimaryUrl", { - ...options, - callback: callbackHolder.addCallback(options.callback), - }) as ReturnType - }, - getServicePortForward( - ...[options]: Parameters - ) { - return rpcRound("getServicePortForward", options) as ReturnType< - T.Effects["getServicePortForward"] - > - }, - getSslCertificate( - options: Parameters[0], - ) { - return rpcRound("getSslCertificate", options) as ReturnType< - T.Effects["getSslCertificate"] - > - }, - getSslKey(options: Parameters[0]) { - return rpcRound("getSslKey", options) as ReturnType< - T.Effects["getSslKey"] - > - }, - getSystemSmtp(...[options]: Parameters) { - return rpcRound("getSystemSmtp", { - ...options, - callback: callbackHolder.addCallback(options.callback), - }) as ReturnType - }, - listServiceInterfaces( - ...[options]: Parameters - ) { - return rpcRound("listServiceInterfaces", { - ...options, - callback: callbackHolder.addCallback(options.callback), - }) as ReturnType - }, - mount(...[options]: Parameters) { - return rpcRound("mount", options) as ReturnType - }, - removeAction(...[options]: Parameters) { - return rpcRound("removeAction", options) as ReturnType< - T.Effects["removeAction"] - > - }, - removeAddress(...[options]: Parameters) { - return rpcRound("removeAddress", options) as ReturnType< - T.Effects["removeAddress"] - > - }, - restart(...[]: Parameters) { - return rpcRound("restart", {}) as ReturnType - }, - running(...[packageId]: Parameters) { - return rpcRound("running", { packageId }) as ReturnType< - T.Effects["running"] - > - }, - // runRsync(...[options]: Parameters) { - // - // return rpcRound('executeAction', options) as ReturnType - // - // return rpcRound('executeAction', options) as ReturnType - // } - setConfigured(...[configured]: Parameters) { - return rpcRound("setConfigured", { configured }) as ReturnType< - T.Effects["setConfigured"] - > - }, - setDependencies( - dependencies: Parameters[0], - ): ReturnType { - return rpcRound("setDependencies", dependencies) as ReturnType< - T.Effects["setDependencies"] - > - }, - checkDependencies( - options: Parameters[0], - ): ReturnType { - return rpcRound("checkDependencies", options) as ReturnType< - T.Effects["checkDependencies"] - > - }, - getDependencies(): ReturnType { - return rpcRound("getDependencies", {}) as ReturnType< - T.Effects["getDependencies"] - > - }, - setHealth(...[options]: Parameters) { - return rpcRound("setHealth", options) as ReturnType< - T.Effects["setHealth"] - > - }, - - setMainStatus(o: { status: "running" | "stopped" }): Promise { - return rpcRound("setMainStatus", o) as ReturnType< - T.Effects["setHealth"] - > - }, - - shutdown(...[]: Parameters) { - return rpcRound("shutdown", {}) as ReturnType - }, - stopped(...[packageId]: Parameters) { - return rpcRound("stopped", { packageId }) as ReturnType< - T.Effects["stopped"] - > - }, - store: { - get: async (options: any) => - rpcRound("getStore", { - ...options, - callback: callbackHolder.addCallback(options.callback), - }) as any, - set: async (options: any) => - rpcRound("setStore", options) as ReturnType< - T.Effects["store"]["set"] - >, - } as T.Effects["store"], - } - return self - } diff --git a/container-runtime/src/Adapters/RpcListener.ts b/container-runtime/src/Adapters/RpcListener.ts index 04e9bc40f..6e8e7aac8 100644 --- a/container-runtime/src/Adapters/RpcListener.ts +++ b/container-runtime/src/Adapters/RpcListener.ts @@ -15,15 +15,16 @@ import { } from "ts-matches" import { types as T } from "@start9labs/start-sdk" -import * as CP from "child_process" -import * as Mod from "module" import * as fs from "fs" import { CallbackHolder } from "../Models/CallbackHolder" import { AllGetDependencies } from "../Interfaces/AllGetDependencies" -import { HostSystem } from "../Interfaces/HostSystem" import { jsonPath } from "../Models/JsonPath" -import { System } from "../Interfaces/System" +import { RunningMain, System } from "../Interfaces/System" +import { + MakeMainEffects, + MakeProcedureEffects, +} from "../Interfaces/MakeEffects" type MaybePromise = T | Promise export const matchRpcResult = anyOf( object({ result: any }), @@ -45,7 +46,7 @@ export const matchRpcResult = anyOf( }), ) export type RpcResult = typeof matchRpcResult._TYPE -type SocketResponse = { jsonrpc: "2.0"; id: IdType } & RpcResult +type SocketResponse = ({ jsonrpc: "2.0"; id: IdType } & RpcResult) | null const SOCKET_PARENT = "/media/startos/rpc" const SOCKET_PATH = "/media/startos/rpc/service.sock" @@ -80,7 +81,6 @@ const sandboxRunType = object({ ), }) const callbackType = object({ - id: idType, method: literal("callback"), params: object({ callback: number, @@ -91,6 +91,14 @@ const initType = object({ id: idType, method: literal("init"), }) +const startType = object({ + id: idType, + method: literal("start"), +}) +const stopType = object({ + id: idType, + method: literal("stop"), +}) const exitType = object({ id: idType, method: literal("exit"), @@ -104,33 +112,40 @@ const evalType = object({ }) const jsonParse = (x: string) => JSON.parse(x) -function reduceMethod( - methodArgs: object, - effects: HostSystem, -): (previousValue: any, currentValue: string) => any { - return (x: any, method: string) => - Promise.resolve(x) - .then((x) => x[method]) - .then((x) => - typeof x !== "function" - ? x - : x({ - ...methodArgs, - effects, - }), + +const handleRpc = (id: IdType, result: Promise) => + result + .then((result) => ({ + jsonrpc, + id, + ...result, + })) + .then((x) => { + if ( + ("result" in x && x.result === undefined) || + !("error" in x || "result" in x) ) -} + (x as any).result = null + return x + }) + .catch((error) => ({ + jsonrpc, + id, + error: { + code: 0, + message: typeof error, + data: { details: "" + error, debug: error?.stack }, + }, + })) const hasId = object({ id: idType }).test export class RpcListener { unixSocketServer = net.createServer(async (server) => {}) private _system: System | undefined - private _effects: HostSystem | undefined + private _makeProcedureEffects: MakeProcedureEffects | undefined + private _makeMainEffects: MakeMainEffects | undefined - constructor( - readonly getDependencies: AllGetDependencies, - private callbacks = new CallbackHolder(), - ) { + constructor(readonly getDependencies: AllGetDependencies) { if (!fs.existsSync(SOCKET_PARENT)) { fs.mkdirSync(SOCKET_PARENT, { recursive: true }) } @@ -165,8 +180,13 @@ export class RpcListener { code: 1, }, }) - const writeDataToSocket = (x: SocketResponse) => - new Promise((resolve) => s.write(JSON.stringify(x) + "\n", resolve)) + const writeDataToSocket = (x: SocketResponse) => { + if (x != null) { + return new Promise((resolve) => + s.write(JSON.stringify(x) + "\n", resolve), + ) + } + } s.on("data", (a) => Promise.resolve(a) .then((b) => b.toString()) @@ -181,107 +201,116 @@ export class RpcListener { }) } - private get effects() { - return this.getDependencies.hostSystem()(this.callbacks) - } - private get system() { if (!this._system) throw new Error("System not initialized") return this._system } + private get makeProcedureEffects() { + if (!this._makeProcedureEffects) { + this._makeProcedureEffects = this.getDependencies.makeProcedureEffects() + } + return this._makeProcedureEffects + } + + private get makeMainEffects() { + if (!this._makeMainEffects) { + this._makeMainEffects = this.getDependencies.makeMainEffects() + } + return this._makeMainEffects + } + private dealWithInput(input: unknown): MaybePromise { return matches(input) - .when(some(runType, sandboxRunType), async ({ id, params }) => { + .when(runType, async ({ id, params }) => { const system = this.system const procedure = jsonPath.unsafeCast(params.procedure) - return system - .execute(this.effects, { - id: params.id, + const effects = this.getDependencies.makeProcedureEffects()(params.id) + return handleRpc( + id, + system.execute(effects, { procedure, input: params.input, timeout: params.timeout, - }) - .then((result) => ({ - jsonrpc, - id, - ...result, - })) - .then((x) => { - if ( - ("result" in x && x.result === undefined) || - !("error" in x || "result" in x) - ) - (x as any).result = null - return x - }) - .catch((error) => ({ - jsonrpc, - id, - error: { - code: 0, - message: typeof error, - data: { details: "" + error, debug: error?.stack }, - }, - })) + }), + ) }) - .when(callbackType, async ({ id, params: { callback, args } }) => - Promise.resolve(this.callbacks.callCallback(callback, args)) - .then((result) => ({ - jsonrpc, - id, - result, - })) - .catch((error) => ({ - jsonrpc, - id, - - error: { - code: 0, - message: typeof error, - data: { - details: error?.message ?? String(error), - debug: error?.stack, - }, - }, - })), - ) - .when(exitType, async ({ id }) => { - if (this._system) await this._system.exit(this.effects(null)) - delete this._system - delete this._effects - - return { - jsonrpc, + .when(sandboxRunType, async ({ id, params }) => { + const system = this.system + const procedure = jsonPath.unsafeCast(params.procedure) + const effects = this.makeProcedureEffects(params.id) + return handleRpc( id, - result: null, - } + system.sandbox(effects, { + procedure, + input: params.input, + timeout: params.timeout, + }), + ) + }) + .when(callbackType, async ({ params: { callback, args } }) => { + this.system.callCallback(callback, args) + return null + }) + .when(startType, async ({ id }) => { + return handleRpc( + id, + this.system + .start(this.makeMainEffects()) + .then((result) => ({ result })), + ) + }) + .when(stopType, async ({ id }) => { + return handleRpc( + id, + this.system.stop().then((result) => ({ result })), + ) + }) + .when(exitType, async ({ id }) => { + return handleRpc( + id, + (async () => { + if (this._system) await this._system.exit() + })().then((result) => ({ result })), + ) }) .when(initType, async ({ id }) => { - this._system = await this.getDependencies.system() - - return { - jsonrpc, + return handleRpc( id, - result: null, - } + (async () => { + if (!this._system) { + const system = await this.getDependencies.system() + await system.init() + this._system = system + } + })().then((result) => ({ result })), + ) }) .when(evalType, async ({ id, params }) => { - const result = await new Function( - `return (async () => { return (${params.script}) }).call(this)`, - ).call({ - listener: this, - require: require, - }) - return { - jsonrpc, + return handleRpc( id, - result: !["string", "number", "boolean", "null", "object"].includes( - typeof result, - ) - ? null - : result, - } + (async () => { + const result = await new Function( + `return (async () => { return (${params.script}) }).call(this)`, + ).call({ + listener: this, + require: require, + }) + return { + jsonrpc, + id, + result: ![ + "string", + "number", + "boolean", + "null", + "object", + ].includes(typeof result) + ? null + : result, + } + })(), + ) }) .when(shape({ id: idType, method: string }), ({ id, method }) => ({ jsonrpc, diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts index c06395a17..5db0b6245 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts @@ -14,6 +14,7 @@ export class DockerProcedureContainer { // } static async of( effects: T.Effects, + packageId: string, data: DockerProcedure, volumes: { [id: VolumeId]: Volume }, ) { @@ -38,16 +39,25 @@ export class DockerProcedureContainer { mounts[mount], ) } else if (volumeMount.type === "certificate") { - volumeMount + const hostnames = [ + `${packageId}.embassy`, + ...new Set( + Object.values( + ( + await effects.getHostInfo({ + hostId: volumeMount["interface-id"], + }) + )?.hostnameInfo || {}, + ) + .flatMap((h) => h) + .flatMap((h) => (h.kind === "onion" ? [h.hostname.value] : [])), + ).values(), + ] const certChain = await effects.getSslCertificate({ - packageId: null, - hostId: volumeMount["interface-id"], - algorithm: null, + hostnames, }) const key = await effects.getSslKey({ - packageId: null, - hostId: volumeMount["interface-id"], - algorithm: null, + hostnames, }) await fs.writeFile( `${path}/${volumeMount["interface-id"]}.cert.pem`, diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts index f6e7614ed..f8f0a2d6e 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/MainLoop.ts @@ -1,10 +1,10 @@ import { polyfillEffects } from "./polyfillEffects" import { DockerProcedureContainer } from "./DockerProcedureContainer" import { SystemForEmbassy } from "." -import { hostSystemStartOs } from "../../HostSystemStartOs" -import { Daemons, T, daemons, utils } from "@start9labs/start-sdk" +import { T, utils } from "@start9labs/start-sdk" import { Daemon } from "@start9labs/start-sdk/cjs/lib/mainFn/Daemon" import { Effects } from "../../../Models/Effects" +import { off } from "node:process" const EMBASSY_HEALTH_INTERVAL = 15 * 1000 const EMBASSY_PROPERTIES_LOOP = 30 * 1000 @@ -14,24 +14,28 @@ const EMBASSY_PROPERTIES_LOOP = 30 * 1000 * Also, this has an ability to clean itself up too if need be. */ export class MainLoop { - private healthLoops: - | { - name: string - interval: NodeJS.Timeout - }[] - | undefined + private healthLoops?: { + name: string + interval: NodeJS.Timeout + }[] - private mainEvent: - | Promise<{ - daemon: Daemon - }> - | undefined - constructor( + private mainEvent?: { + daemon: Daemon + } + + private constructor( readonly system: SystemForEmbassy, readonly effects: Effects, - ) { - this.healthLoops = this.constructHealthLoops() - this.mainEvent = this.constructMainEvent() + ) {} + + static async of( + system: SystemForEmbassy, + effects: Effects, + ): Promise { + const res = new MainLoop(system, effects) + res.healthLoops = res.constructHealthLoops() + res.mainEvent = await res.constructMainEvent() + return res } private async constructMainEvent() { @@ -46,6 +50,7 @@ export class MainLoop { const jsMain = (this.system.moduleCode as any)?.jsMain const dockerProcedureContainer = await DockerProcedureContainer.of( effects, + this.system.manifest.id, this.system.manifest.main, this.system.manifest.volumes, ) @@ -135,6 +140,7 @@ export class MainLoop { if (actionProcedure.type === "docker") { const container = await DockerProcedureContainer.of( effects, + manifest.id, actionProcedure, manifest.volumes, ) diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts index be8a4d163..2e0836bb0 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts @@ -3,8 +3,8 @@ import * as fs from "fs/promises" import { polyfillEffects } from "./polyfillEffects" import { Duration, duration, fromDuration } from "../../../Models/Duration" -import { System } from "../../../Interfaces/System" -import { matchManifest, Manifest, Procedure } from "./matchManifest" +import { System, Procedure } from "../../../Interfaces/System" +import { matchManifest, Manifest } from "./matchManifest" import * as childProcess from "node:child_process" import { DockerProcedureContainer } from "./DockerProcedureContainer" import { promisify } from "node:util" @@ -27,7 +27,6 @@ import { Parser, array, } from "ts-matches" -import { hostSystemStartOs } from "../../HostSystemStartOs" import { JsonPath, unNestPath } from "../../../Models/JsonPath" import { RpcResult, matchRpcResult } from "../../RpcListener" import { CT } from "@start9labs/start-sdk" @@ -48,6 +47,7 @@ import { transformConfigSpec, transformOldConfigToNew, } from "./transformConfigSpec" +import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk" type Optional = A | undefined | null function todo(): never { @@ -203,16 +203,39 @@ export class SystemForEmbassy implements System { readonly manifest: Manifest, readonly moduleCode: Partial, ) {} + + async init(): Promise {} + + async exit(): Promise { + if (this.currentRunning) await this.currentRunning.clean() + delete this.currentRunning + } + + async start(effects: MainEffects): Promise { + if (!!this.currentRunning) return + + this.currentRunning = await MainLoop.of(this, effects) + } + callCallback(_callback: number, _args: any[]): void {} + async stop(): Promise { + const { currentRunning } = this + this.currentRunning?.clean() + delete this.currentRunning + if (currentRunning) { + await currentRunning.clean({ + timeout: fromDuration(this.manifest.main["sigterm-timeout"] || "30s"), + }) + } + } + async execute( - effectCreator: ReturnType, + effects: Effects, options: { - id: string procedure: JsonPath input: unknown timeout?: number | undefined }, ): Promise { - const effects = effectCreator(options.id) return this._execute(effects, options) .then((x) => matches(x) @@ -267,10 +290,6 @@ export class SystemForEmbassy implements System { } }) } - async exit(): Promise { - if (this.currentRunning) await this.currentRunning.clean() - delete this.currentRunning - } async _execute( effects: Effects, options: { @@ -294,7 +313,7 @@ export class SystemForEmbassy implements System { case "/actions/metadata": return todo() case "/init": - return this.init( + return this.initProcedure( effects, string.optional().unsafeCast(input), options.timeout || null, @@ -305,10 +324,6 @@ export class SystemForEmbassy implements System { string.optional().unsafeCast(input), options.timeout || null, ) - case "/main/start": - return this.mainStart(effects, options.timeout || null) - case "/main/stop": - return this.mainStop(effects, options.timeout || null) default: const procedures = unNestPath(options.procedure) switch (true) { @@ -345,7 +360,14 @@ export class SystemForEmbassy implements System { } throw new Error(`Could not find the path for ${options.procedure}`) } - private async init( + async sandbox( + effects: Effects, + options: { procedure: Procedure; input: unknown; timeout?: number }, + ): Promise { + return this.execute(effects, options) + } + + private async initProcedure( effects: Effects, previousVersion: Optional, timeoutMs: number | null, @@ -470,42 +492,22 @@ export class SystemForEmbassy implements System { // TODO Do a migration down if the version exists await effects.setMainStatus({ status: "stopped" }) } - private async mainStart( - effects: Effects, - timeoutMs: number | null, - ): Promise { - if (!!this.currentRunning) return - this.currentRunning = new MainLoop(this, effects) - } - private async mainStop( - effects: Effects, - timeoutMs: number | null, - ): Promise { - try { - const { currentRunning } = this - this.currentRunning?.clean() - delete this.currentRunning - if (currentRunning) { - await currentRunning.clean({ - timeout: utils.inMs(this.manifest.main["sigterm-timeout"]), - }) - } - return - } finally { - await effects.setMainStatus({ status: "stopped" }) - } - } private async createBackup( effects: Effects, timeoutMs: number | null, ): Promise { const backup = this.manifest.backup.create if (backup.type === "docker") { - const container = await DockerProcedureContainer.of(effects, backup, { - ...this.manifest.volumes, - BACKUP: { type: "backup", readonly: false }, - }) + const container = await DockerProcedureContainer.of( + effects, + this.manifest.id, + backup, + { + ...this.manifest.volumes, + BACKUP: { type: "backup", readonly: false }, + }, + ) await container.execFail([backup.entrypoint, ...backup.args], timeoutMs) } else { const moduleCode = await this.moduleCode @@ -520,6 +522,7 @@ export class SystemForEmbassy implements System { if (restoreBackup.type === "docker") { const container = await DockerProcedureContainer.of( effects, + this.manifest.id, restoreBackup, { ...this.manifest.volumes, @@ -552,6 +555,7 @@ export class SystemForEmbassy implements System { if (config.type === "docker") { const container = await DockerProcedureContainer.of( effects, + this.manifest.id, config, this.manifest.volumes, ) @@ -594,6 +598,7 @@ export class SystemForEmbassy implements System { if (setConfigValue.type === "docker") { const container = await DockerProcedureContainer.of( effects, + this.manifest.id, setConfigValue, this.manifest.volumes, ) @@ -702,6 +707,7 @@ export class SystemForEmbassy implements System { if (procedure.type === "docker") { const container = await DockerProcedureContainer.of( effects, + this.manifest.id, procedure, this.manifest.volumes, ) @@ -744,6 +750,7 @@ export class SystemForEmbassy implements System { if (setConfigValue.type === "docker") { const container = await DockerProcedureContainer.of( effects, + this.manifest.id, setConfigValue, this.manifest.volumes, ) @@ -785,6 +792,7 @@ export class SystemForEmbassy implements System { if (actionProcedure.type === "docker") { const container = await DockerProcedureContainer.of( effects, + this.manifest.id, actionProcedure, this.manifest.volumes, ) @@ -825,6 +833,7 @@ export class SystemForEmbassy implements System { if (actionProcedure.type === "docker") { const container = await DockerProcedureContainer.of( effects, + this.manifest.id, actionProcedure, this.manifest.volumes, ) @@ -1039,15 +1048,15 @@ async function updateConfig( } } const url: string = - filled === null + filled === null || filled.addressInfo === null ? "" : catchFn(() => utils.hostnameInfoToAddress( specValue.target === "lan-address" - ? filled.addressInfo.localHostnames[0] || - filled.addressInfo.onionHostnames[0] - : filled.addressInfo.onionHostnames[0] || - filled.addressInfo.localHostnames[0], + ? filled.addressInfo!.localHostnames[0] || + filled.addressInfo!.onionHostnames[0] + : filled.addressInfo!.onionHostnames[0] || + filled.addressInfo!.localHostnames[0], ), ) || "" mutConfigValue[key] = url diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/polyfillEffects.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/polyfillEffects.ts index 2b7363cbf..6481a7a56 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/polyfillEffects.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/polyfillEffects.ts @@ -126,6 +126,7 @@ export const polyfillEffects = ( } { const dockerProcedureContainer = DockerProcedureContainer.of( effects, + manifest.id, manifest.main, manifest.volumes, ) diff --git a/container-runtime/src/Adapters/Systems/SystemForStartOs.ts b/container-runtime/src/Adapters/Systems/SystemForStartOs.ts index 2c455dc42..78c21b7c7 100644 --- a/container-runtime/src/Adapters/Systems/SystemForStartOs.ts +++ b/container-runtime/src/Adapters/Systems/SystemForStartOs.ts @@ -1,43 +1,84 @@ -import { ExecuteResult, System } from "../../Interfaces/System" +import { ExecuteResult, Procedure, System } from "../../Interfaces/System" import { unNestPath } from "../../Models/JsonPath" import matches, { any, number, object, string, tuple } from "ts-matches" -import { hostSystemStartOs } from "../HostSystemStartOs" import { Effects } from "../../Models/Effects" import { RpcResult, matchRpcResult } from "../RpcListener" import { duration } from "../../Models/Duration" import { T } from "@start9labs/start-sdk" -import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk" import { Volume } from "../../Models/Volume" +import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk" +import { CallbackHolder } from "../../Models/CallbackHolder" + export const STARTOS_JS_LOCATION = "/usr/lib/startos/package/index.js" + +type RunningMain = { + effects: MainEffects + stop: () => Promise + callbacks: CallbackHolder +} + export class SystemForStartOs implements System { - private onTerm: (() => Promise) | undefined + private runningMain: RunningMain | undefined + static of() { return new SystemForStartOs(require(STARTOS_JS_LOCATION)) } + constructor(readonly abi: T.ABI) {} + + async init(): Promise {} + + async exit(): Promise {} + + async start(effects: MainEffects): Promise { + if (this.runningMain) await this.stop() + let mainOnTerm: () => Promise | undefined + const started = async (onTerm: () => Promise) => { + await effects.setMainStatus({ status: "running" }) + mainOnTerm = onTerm + } + const daemons = await ( + await this.abi.main({ + effects: effects as MainEffects, + started, + }) + ).build() + this.runningMain = { + effects, + stop: async () => { + if (mainOnTerm) await mainOnTerm() + await daemons.term() + }, + callbacks: new CallbackHolder(), + } + } + + callCallback(callback: number, args: any[]): void { + if (this.runningMain) { + this.runningMain.callbacks + .callCallback(callback, args) + .catch((error) => console.error(`callback ${callback} failed`, error)) + } else { + console.warn(`callback ${callback} ignored because system is not running`) + } + } + + async stop(): Promise { + if (this.runningMain) { + await this.runningMain.stop() + await this.runningMain.effects.clearCallbacks() + this.runningMain = undefined + } + } + async execute( - effectCreator: ReturnType, + effects: Effects, options: { - id: string - procedure: - | "/init" - | "/uninit" - | "/main/start" - | "/main/stop" - | "/config/set" - | "/config/get" - | "/backup/create" - | "/backup/restore" - | "/actions/metadata" - | `/actions/${string}/get` - | `/actions/${string}/run` - | `/dependencies/${string}/query` - | `/dependencies/${string}/update` + procedure: Procedure input: unknown timeout?: number | undefined }, ): Promise { - const effects = effectCreator(options.id) return this._execute(effects, options) .then((x) => matches(x) @@ -93,22 +134,9 @@ export class SystemForStartOs implements System { }) } async _execute( - effects: Effects, + effects: Effects | MainEffects, options: { - procedure: - | "/init" - | "/uninit" - | "/main/start" - | "/main/stop" - | "/config/set" - | "/config/get" - | "/backup/create" - | "/backup/restore" - | "/actions/metadata" - | `/actions/${string}/get` - | `/actions/${string}/run` - | `/dependencies/${string}/query` - | `/dependencies/${string}/update` + procedure: Procedure input: unknown timeout?: number | undefined }, @@ -123,30 +151,15 @@ export class SystemForStartOs implements System { const nextVersion = string.optional().unsafeCast(options.input) || null return this.abi.uninit({ effects, nextVersion }) } - case "/main/start": { - if (this.onTerm) await this.onTerm() - const started = async (onTerm: () => Promise) => { - await effects.setMainStatus({ status: "running" }) - this.onTerm = onTerm - } - const daemons = await ( - await this.abi.main({ - effects: { ...effects, _type: "main" }, - started, - }) - ).build() - this.onTerm = daemons.term - return - } - case "/main/stop": { - try { - if (this.onTerm) await this.onTerm() - delete this.onTerm - return - } finally { - await effects.setMainStatus({ status: "stopped" }) - } - } + // case "/main/start": { + // + // } + // case "/main/stop": { + // if (this.onTerm) await this.onTerm() + // await effects.setMainStatus({ status: "stopped" }) + // delete this.onTerm + // return duration(30, "s") + // } case "/config/set": { const input = options.input as any // TODO return this.abi.setConfig({ effects, input }) @@ -169,6 +182,9 @@ export class SystemForStartOs implements System { case "/actions/metadata": { return this.abi.actionsMetadata({ effects }) } + case "/properties": { + throw new Error("TODO") + } default: const procedures = unNestPath(options.procedure) const id = procedures[2] @@ -199,9 +215,12 @@ export class SystemForStartOs implements System { } return } - throw new Error(`Method ${options.procedure} not implemented.`) } - async exit(effects: Effects): Promise { - return void null + + async sandbox( + effects: Effects, + options: { procedure: Procedure; input: unknown; timeout?: number }, + ): Promise { + return this.execute(effects, options) } } diff --git a/container-runtime/src/Interfaces/AllGetDependencies.ts b/container-runtime/src/Interfaces/AllGetDependencies.ts index 88a200900..ca5c43585 100644 --- a/container-runtime/src/Interfaces/AllGetDependencies.ts +++ b/container-runtime/src/Interfaces/AllGetDependencies.ts @@ -1,6 +1,7 @@ import { GetDependency } from "./GetDependency" import { System } from "./System" -import { GetHostSystem, HostSystem } from "./HostSystem" +import { MakeMainEffects, MakeProcedureEffects } from "./MakeEffects" export type AllGetDependencies = GetDependency<"system", Promise> & - GetDependency<"hostSystem", GetHostSystem> + GetDependency<"makeProcedureEffects", MakeProcedureEffects> & + GetDependency<"makeMainEffects", MakeMainEffects> diff --git a/container-runtime/src/Interfaces/HostSystem.ts b/container-runtime/src/Interfaces/HostSystem.ts deleted file mode 100644 index 4ba986e3b..000000000 --- a/container-runtime/src/Interfaces/HostSystem.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { types as T } from "@start9labs/start-sdk" - -import { CallbackHolder } from "../Models/CallbackHolder" -import { Effects } from "../Models/Effects" -export type HostSystem = Effects -export type GetHostSystem = ( - callbackHolder: CallbackHolder, -) => (procedureId: null | string) => Effects diff --git a/container-runtime/src/Interfaces/MakeEffects.ts b/container-runtime/src/Interfaces/MakeEffects.ts new file mode 100644 index 000000000..3b25f8180 --- /dev/null +++ b/container-runtime/src/Interfaces/MakeEffects.ts @@ -0,0 +1,4 @@ +import { Effects } from "../Models/Effects" +import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk" +export type MakeProcedureEffects = (procedureId: string) => Effects +export type MakeMainEffects = () => MainEffects diff --git a/container-runtime/src/Interfaces/System.ts b/container-runtime/src/Interfaces/System.ts index 58e045356..01fd3c5ff 100644 --- a/container-runtime/src/Interfaces/System.ts +++ b/container-runtime/src/Interfaces/System.ts @@ -1,33 +1,54 @@ import { types as T } from "@start9labs/start-sdk" -import { JsonPath } from "../Models/JsonPath" import { RpcResult } from "../Adapters/RpcListener" -import { hostSystemStartOs } from "../Adapters/HostSystemStartOs" +import { Effects } from "../Models/Effects" +import { CallbackHolder } from "../Models/CallbackHolder" +import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk" + +export type Procedure = + | "/init" + | "/uninit" + | "/config/set" + | "/config/get" + | "/backup/create" + | "/backup/restore" + | "/actions/metadata" + | "/properties" + | `/actions/${string}/get` + | `/actions/${string}/run` + | `/dependencies/${string}/query` + | `/dependencies/${string}/update` + export type ExecuteResult = | { ok: unknown } | { err: { code: number; message: string } } export type System = { - // init(effects: Effects): Promise - // exit(effects: Effects): Promise - // start(effects: Effects): Promise - // stop(effects: Effects, options: { timeout: number, signal?: number }): Promise + init(): Promise + + start(effects: MainEffects): Promise + callCallback(callback: number, args: any[]): void + stop(): Promise execute( - effectCreator: ReturnType, + effects: Effects, options: { - id: string - procedure: JsonPath + procedure: Procedure + input: unknown + timeout?: number + }, + ): Promise + sandbox( + effects: Effects, + options: { + procedure: Procedure input: unknown timeout?: number }, ): Promise - // sandbox( - // effects: Effects, - // options: { - // procedure: JsonPath - // input: unknown - // timeout?: number - // }, - // ): Promise - exit(effects: T.Effects): Promise + exit(): Promise +} + +export type RunningMain = { + callbacks: CallbackHolder + stop(): Promise } diff --git a/container-runtime/src/Models/CallbackHolder.ts b/container-runtime/src/Models/CallbackHolder.ts index 6539dda88..b51af0bee 100644 --- a/container-runtime/src/Models/CallbackHolder.ts +++ b/container-runtime/src/Models/CallbackHolder.ts @@ -1,12 +1,14 @@ export class CallbackHolder { constructor() {} - private root = (Math.random() + 1).toString(36).substring(7) private inc = 0 private callbacks = new Map() private newId() { return this.inc++ } - addCallback(callback: Function) { + addCallback(callback?: Function) { + if (!callback) { + return + } const id = this.newId() this.callbacks.set(id, callback) return id diff --git a/container-runtime/src/Models/JsonPath.ts b/container-runtime/src/Models/JsonPath.ts index 314019154..95a2b3a00 100644 --- a/container-runtime/src/Models/JsonPath.ts +++ b/container-runtime/src/Models/JsonPath.ts @@ -28,8 +28,6 @@ export const jsonPath = some( literals( "/init", "/uninit", - "/main/start", - "/main/stop", "/config/set", "/config/get", "/backup/create", diff --git a/container-runtime/src/index.ts b/container-runtime/src/index.ts index 74be5b73a..5454bee3d 100644 --- a/container-runtime/src/index.ts +++ b/container-runtime/src/index.ts @@ -1,12 +1,13 @@ import { RpcListener } from "./Adapters/RpcListener" import { SystemForEmbassy } from "./Adapters/Systems/SystemForEmbassy" -import { hostSystemStartOs } from "./Adapters/HostSystemStartOs" +import { makeMainEffects, makeProcedureEffects } from "./Adapters/EffectCreator" import { AllGetDependencies } from "./Interfaces/AllGetDependencies" import { getSystem } from "./Adapters/Systems" const getDependencies: AllGetDependencies = { system: getSystem, - hostSystem: () => hostSystemStartOs, + makeProcedureEffects: () => makeProcedureEffects, + makeMainEffects: () => makeMainEffects, } new RpcListener(getDependencies) diff --git a/core/build-startos-bins.sh b/core/build-containerbox.sh similarity index 83% rename from core/build-startos-bins.sh rename to core/build-containerbox.sh index f81ddb093..f988d2de3 100755 --- a/core/build-startos-bins.sh +++ b/core/build-containerbox.sh @@ -28,9 +28,6 @@ set +e fail= echo "FEATURES=\"$FEATURES\"" echo "RUSTFLAGS=\"$RUSTFLAGS\"" -if ! rust-musl-builder sh -c "(cd core && cargo build --release --no-default-features --features cli,daemon,$FEATURES --locked --bin startbox --target=$ARCH-unknown-linux-musl)"; then - fail=true -fi if ! rust-musl-builder sh -c "(cd core && cargo build --release --no-default-features --features container-runtime,$FEATURES --locked --bin containerbox --target=$ARCH-unknown-linux-musl)"; then fail=true fi diff --git a/core/build-startbox.sh b/core/build-startbox.sh new file mode 100755 index 000000000..0604ba5da --- /dev/null +++ b/core/build-startbox.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +cd "$(dirname "${BASH_SOURCE[0]}")" + +set -e +shopt -s expand_aliases + +if [ -z "$ARCH" ]; then + ARCH=$(uname -m) +fi + +USE_TTY= +if tty -s; then + USE_TTY="-it" +fi + +cd .. +FEATURES="$(echo $ENVIRONMENT | sed 's/-/,/g')" +RUSTFLAGS="" + +if [[ "${ENVIRONMENT}" =~ (^|-)unstable($|-) ]]; then + RUSTFLAGS="--cfg tokio_unstable" +fi + +alias 'rust-musl-builder'='docker run $USE_TTY --rm -e "RUSTFLAGS=$RUSTFLAGS" -v "$HOME/.cargo/registry":/root/.cargo/registry -v "$HOME/.cargo/git":/root/.cargo/git -v "$(pwd)":/home/rust/src -w /home/rust/src -P messense/rust-musl-cross:$ARCH-musl' + +set +e +fail= +echo "FEATURES=\"$FEATURES\"" +echo "RUSTFLAGS=\"$RUSTFLAGS\"" +if ! rust-musl-builder sh -c "(cd core && cargo build --release --no-default-features --features cli,daemon,$FEATURES --locked --bin startbox --target=$ARCH-unknown-linux-musl)"; then + fail=true +fi +set -e +cd core + +sudo chown -R $USER target +sudo chown -R $USER ~/.cargo + +if [ -n "$fail" ]; then + exit 1 +fi diff --git a/core/models/src/id/package.rs b/core/models/src/id/package.rs index d2665e59a..6e22b9d51 100644 --- a/core/models/src/id/package.rs +++ b/core/models/src/id/package.rs @@ -61,6 +61,11 @@ impl Borrow for PackageId { self.0.as_ref() } } +impl<'a> Borrow for &'a PackageId { + fn borrow(&self) -> &str { + self.0.as_ref() + } +} impl AsRef for PackageId { fn as_ref(&self) -> &Path { self.0.as_ref().as_ref() diff --git a/core/models/src/procedure_name.rs b/core/models/src/procedure_name.rs index c8ae8c3a8..466835818 100644 --- a/core/models/src/procedure_name.rs +++ b/core/models/src/procedure_name.rs @@ -4,8 +4,6 @@ use crate::{ActionId, PackageId}; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ProcedureName { - StartMain, - StopMain, GetConfig, SetConfig, CreateBackup, @@ -25,8 +23,6 @@ impl ProcedureName { match self { ProcedureName::Init => "/init".to_string(), ProcedureName::Uninit => "/uninit".to_string(), - ProcedureName::StartMain => "/main/start".to_string(), - ProcedureName::StopMain => "/main/stop".to_string(), ProcedureName::SetConfig => "/config/set".to_string(), ProcedureName::GetConfig => "/config/get".to_string(), ProcedureName::CreateBackup => "/backup/create".to_string(), diff --git a/core/startos/src/auth.rs b/core/startos/src/auth.rs index d33320b78..03005998f 100644 --- a/core/startos/src/auth.rs +++ b/core/startos/src/auth.rs @@ -319,6 +319,7 @@ fn display_sessions(params: WithIoFormat, arg: SessionList) { pub struct ListParams { #[arg(skip)] #[ts(skip)] + #[serde(rename = "__auth_session")] // from Auth middleware session: InternedString, } diff --git a/core/startos/src/backup/os.rs b/core/startos/src/backup/os.rs index 7c8119e79..6f08c5f43 100644 --- a/core/startos/src/backup/os.rs +++ b/core/startos/src/backup/os.rs @@ -1,3 +1,4 @@ +use imbl_value::InternedString; use openssl::pkey::{PKey, Private}; use openssl::x509::X509; use patch_db::Value; @@ -97,7 +98,7 @@ impl OsBackupV0 { #[serde(rename = "kebab-case")] struct OsBackupV1 { server_id: String, // uuidv4 - hostname: String, // embassy-- + hostname: InternedString, // embassy-- net_key: Base64<[u8; 32]>, // Ed25519 Secret Key root_ca_key: Pem>, // PEM Encoded OpenSSL Key root_ca_cert: Pem, // PEM Encoded OpenSSL X509 Certificate @@ -127,7 +128,7 @@ impl OsBackupV1 { struct OsBackupV2 { server_id: String, // uuidv4 - hostname: String, // - + hostname: InternedString, // - root_ca_key: Pem>, // PEM Encoded OpenSSL Key root_ca_cert: Pem, // PEM Encoded OpenSSL X509 Certificate ssh_key: Pem, // PEM Encoded OpenSSH Key diff --git a/core/startos/src/bins/container_cli.rs b/core/startos/src/bins/container_cli.rs index a33a99131..db7cbd36a 100644 --- a/core/startos/src/bins/container_cli.rs +++ b/core/startos/src/bins/container_cli.rs @@ -15,7 +15,7 @@ pub fn main(args: impl IntoIterator) { EmbassyLogger::init(); if let Err(e) = CliApp::new( |cfg: ContainerClientConfig| Ok(ContainerCliContext::init(cfg)), - crate::service::service_effect_handler::service_effect_handler(), + crate::service::effects::handler(), ) .run(args) { diff --git a/core/startos/src/context/rpc.rs b/core/startos/src/context/rpc.rs index 4920a6223..ba1ab3fbb 100644 --- a/core/startos/src/context/rpc.rs +++ b/core/startos/src/context/rpc.rs @@ -29,6 +29,7 @@ use crate::net::wifi::WpaCli; use crate::prelude::*; use crate::progress::{FullProgressTracker, PhaseProgressTrackerHandle}; use crate::rpc_continuations::{OpenAuthedContinuations, RpcContinuations}; +use crate::service::effects::callbacks::ServiceCallbacks; use crate::service::ServiceMap; use crate::shutdown::Shutdown; use crate::system::get_mem_info; @@ -52,6 +53,7 @@ pub struct RpcContextSeed { pub lxc_manager: Arc, pub open_authed_continuations: OpenAuthedContinuations, pub rpc_continuations: RpcContinuations, + pub callbacks: ServiceCallbacks, pub wifi_manager: Option>>, pub current_secret: Arc, pub client: Client, @@ -225,6 +227,7 @@ impl RpcContext { lxc_manager: Arc::new(LxcManager::new()), open_authed_continuations: OpenAuthedContinuations::new(), rpc_continuations: RpcContinuations::new(), + callbacks: Default::default(), wifi_manager: wifi_interface .clone() .map(|i| Arc::new(RwLock::new(WpaCli::init(i)))), diff --git a/core/startos/src/context/setup.rs b/core/startos/src/context/setup.rs index 6041f49b9..de8dced07 100644 --- a/core/startos/src/context/setup.rs +++ b/core/startos/src/context/setup.rs @@ -5,6 +5,7 @@ use std::time::Duration; use futures::{Future, StreamExt}; use helpers::NonDetachingJoinHandle; +use imbl_value::InternedString; use josekit::jwk::Jwk; use patch_db::PatchDb; use rpc_toolkit::Context; @@ -40,7 +41,8 @@ lazy_static::lazy_static! { #[ts(export)] pub struct SetupResult { pub tor_address: String, - pub lan_address: String, + #[ts(type = "string")] + pub lan_address: InternedString, pub root_ca: String, } impl TryFrom<&AccountInfo> for SetupResult { diff --git a/core/startos/src/db/model/public.rs b/core/startos/src/db/model/public.rs index 725475602..b20693a90 100644 --- a/core/startos/src/db/model/public.rs +++ b/core/startos/src/db/model/public.rs @@ -20,6 +20,7 @@ use crate::db::model::package::AllPackageData; use crate::net::utils::{get_iface_ipv4_addr, get_iface_ipv6_addr}; use crate::prelude::*; use crate::progress::FullProgress; +use crate::system::SmtpValue; use crate::util::cpupower::Governor; use crate::version::{Current, VersionT}; use crate::{ARCH, PLATFORM}; @@ -107,7 +108,8 @@ pub struct ServerInfo { #[ts(type = "string")] pub platform: InternedString, pub id: String, - pub hostname: String, + #[ts(type = "string")] + pub hostname: InternedString, #[ts(type = "string")] pub version: Version, #[ts(type = "string | null")] @@ -135,7 +137,7 @@ pub struct ServerInfo { #[serde(default)] pub zram: bool, pub governor: Option, - pub smtp: Option, + pub smtp: Option, } #[derive(Debug, Deserialize, Serialize, HasModel, TS)] diff --git a/core/startos/src/hostname.rs b/core/startos/src/hostname.rs index c4332354c..36bb5d8a4 100644 --- a/core/startos/src/hostname.rs +++ b/core/startos/src/hostname.rs @@ -1,3 +1,5 @@ +use imbl_value::InternedString; +use lazy_format::lazy_format; use rand::{thread_rng, Rng}; use tokio::process::Command; use tracing::instrument; @@ -5,7 +7,7 @@ use tracing::instrument; use crate::util::Invoke; use crate::{Error, ErrorKind}; #[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] -pub struct Hostname(pub String); +pub struct Hostname(pub InternedString); lazy_static::lazy_static! { static ref ADJECTIVES: Vec = include_str!("./assets/adjectives.txt").lines().map(|x| x.to_string()).collect(); @@ -18,15 +20,16 @@ impl AsRef for Hostname { } impl Hostname { - pub fn lan_address(&self) -> String { - format!("https://{}.local", self.0) + pub fn lan_address(&self) -> InternedString { + InternedString::from_display(&lazy_format!("https://{}.local", self.0)) } - pub fn local_domain_name(&self) -> String { - format!("{}.local", self.0) + pub fn local_domain_name(&self) -> InternedString { + InternedString::from_display(&lazy_format!("{}.local", self.0)) } - pub fn no_dot_host_name(&self) -> String { - self.0.to_owned() + + pub fn no_dot_host_name(&self) -> InternedString { + self.0.clone() } } @@ -34,7 +37,9 @@ pub fn generate_hostname() -> Hostname { let mut rng = thread_rng(); let adjective = &ADJECTIVES[rng.gen_range(0..ADJECTIVES.len())]; let noun = &NOUNS[rng.gen_range(0..NOUNS.len())]; - Hostname(format!("{adjective}-{noun}")) + Hostname(InternedString::from_display(&lazy_format!( + "{adjective}-{noun}" + ))) } pub fn generate_id() -> String { @@ -48,12 +53,12 @@ pub async fn get_current_hostname() -> Result { .invoke(ErrorKind::ParseSysInfo) .await?; let out_string = String::from_utf8(out)?; - Ok(Hostname(out_string.trim().to_owned())) + Ok(Hostname(out_string.trim().into())) } #[instrument(skip_all)] pub async fn set_hostname(hostname: &Hostname) -> Result<(), Error> { - let hostname: &String = &hostname.0; + let hostname = &*hostname.0; Command::new("hostnamectl") .arg("--static") .arg("set-hostname") diff --git a/core/startos/src/lib.rs b/core/startos/src/lib.rs index 4882d998e..feeb5a647 100644 --- a/core/startos/src/lib.rs +++ b/core/startos/src/lib.rs @@ -224,6 +224,18 @@ pub fn server() -> ParentHandler { }) .with_call_remote::(), ) + .subcommand( + "set-smtp", + from_fn_async(system::set_system_smtp) + .no_display() + .with_call_remote::(), + ) + .subcommand( + "clear-smtp", + from_fn_async(system::clear_system_smtp) + .no_display() + .with_call_remote::(), + ) } pub fn package() -> ParentHandler { diff --git a/core/startos/src/net/dns.rs b/core/startos/src/net/dns.rs index ba69b6c16..090e845b0 100644 --- a/core/startos/src/net/dns.rs +++ b/core/startos/src/net/dns.rs @@ -34,7 +34,7 @@ struct Resolver { impl Resolver { async fn resolve(&self, name: &Name) -> Option> { match name.iter().next_back() { - Some(b"embassy") => { + Some(b"embassy") | Some(b"startos") => { if let Some(pkg) = name.iter().rev().skip(1).next() { if let Some(ip) = self.services.read().await.get(&Some( std::str::from_utf8(pkg) diff --git a/core/startos/src/net/host/address.rs b/core/startos/src/net/host/address.rs index d9e2f4206..9b16441ce 100644 --- a/core/startos/src/net/host/address.rs +++ b/core/startos/src/net/host/address.rs @@ -1,7 +1,13 @@ +use std::fmt; +use std::str::FromStr; + +use imbl_value::InternedString; use serde::{Deserialize, Serialize}; use torut::onion::OnionAddressV3; use ts_rs::TS; +use crate::prelude::*; + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord, TS)] #[serde(rename_all = "camelCase")] #[serde(tag = "kind")] @@ -11,4 +17,32 @@ pub enum HostAddress { #[ts(type = "string")] address: OnionAddressV3, }, + Domain { + #[ts(type = "string")] + address: InternedString, + }, +} + +impl FromStr for HostAddress { + type Err = Error; + fn from_str(s: &str) -> Result { + if let Some(addr) = s.strip_suffix(".onion") { + Ok(HostAddress::Onion { + address: addr + .parse::() + .with_kind(ErrorKind::ParseUrl)?, + }) + } else { + Ok(HostAddress::Domain { address: s.into() }) + } + } +} + +impl fmt::Display for HostAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Onion { address } => write!(f, "{address}"), + Self::Domain { address } => write!(f, "{address}"), + } + } } diff --git a/core/startos/src/net/host/mod.rs b/core/startos/src/net/host/mod.rs index 6cbb2dfd5..175fe3e83 100644 --- a/core/startos/src/net/host/mod.rs +++ b/core/startos/src/net/host/mod.rs @@ -40,6 +40,10 @@ impl Host { hostname_info: BTreeMap::new(), } } + pub fn addresses(&self) -> impl Iterator { + // TODO: handle primary + self.addresses.iter() + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)] diff --git a/core/startos/src/net/net_controller.rs b/core/startos/src/net/net_controller.rs index 270c7ca09..521888665 100644 --- a/core/startos/src/net/net_controller.rs +++ b/core/startos/src/net/net_controller.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, Weak}; use color_eyre::eyre::eyre; use imbl::OrdMap; +use imbl_value::InternedString; use models::{HostId, OptionExt, PackageId}; use torut::onion::{OnionAddressV3, TorSecretKeyV3}; use tracing::instrument; @@ -67,6 +68,14 @@ impl PreInitNetController { alpn.clone(), ) .await?; + self.vhost + .add( + Some("startos".into()), + 443, + ([127, 0, 0, 1], 80).into(), + alpn.clone(), + ) + .await?; // LAN IP self.os_bindings.push( @@ -113,7 +122,9 @@ impl PreInitNetController { self.os_bindings.push( self.vhost .add( - Some(tor_key.public().get_onion_address().to_string()), + Some(InternedString::from_display( + &tor_key.public().get_onion_address(), + )), 443, ([127, 0, 0, 1], 80).into(), alpn.clone(), @@ -189,7 +200,15 @@ impl NetController { #[derive(Default, Debug)] struct HostBinds { - lan: BTreeMap, Vec>)>, + lan: BTreeMap< + u16, + ( + LanInfo, + Option, + BTreeSet, + Vec>, + ), + >, tor: BTreeMap, Vec>)>, } @@ -234,20 +253,35 @@ impl NetService { .await?; self.update(id, host).await } + pub async fn clear_bindings(&mut self) -> Result<(), Error> { - // TODO BLUJ - Ok(()) + let ctrl = self.net_controller()?; + let mut errors = ErrorCollection::new(); + for (_, binds) in std::mem::take(&mut self.binds) { + for (_, (lan, _, _, rc)) in binds.lan { + drop(rc); + if let Some(external) = lan.assigned_ssl_port { + ctrl.vhost.gc(None, external).await?; + } + if let Some(external) = lan.assigned_port { + ctrl.forward.gc(external).await?; + } + } + for (addr, (_, rcs)) in binds.tor { + drop(rcs); + errors.handle(ctrl.tor.gc(Some(addr), None).await); + } + } + std::mem::take(&mut self.dns); + errors.handle(ctrl.dns.gc(Some(self.id.clone()), self.ip).await); + errors.into_result() } async fn update(&mut self, id: HostId, host: Host) -> Result<(), Error> { let ctrl = self.net_controller()?; let mut hostname_info = BTreeMap::new(); - let binds = { - if !self.binds.contains_key(&id) { - self.binds.insert(id.clone(), Default::default()); - } - self.binds.get_mut(&id).unwrap() - }; + let binds = self.binds.entry(id.clone()).or_default(); + let peek = ctrl.db.peek().await; // LAN @@ -256,37 +290,71 @@ impl NetService { let hostname = server_info.as_hostname().de()?; for (port, bind) in &host.bindings { let old_lan_bind = binds.lan.remove(port); - let old_lan_port = old_lan_bind.as_ref().map(|(external, _, _)| *external); let lan_bind = old_lan_bind - .filter(|(external, ssl, _)| ssl == &bind.options.add_ssl && bind.lan == *external); // only keep existing binding if relevant details match + .as_ref() + .filter(|(external, ssl, _, _)| { + ssl == &bind.options.add_ssl && bind.lan == *external + }) + .cloned(); // only keep existing binding if relevant details match if bind.lan.assigned_port.is_some() || bind.lan.assigned_ssl_port.is_some() { let new_lan_bind = if let Some(b) = lan_bind { b } else { - let mut rcs = Vec::with_capacity(2); + let mut rcs = Vec::with_capacity(2 + host.addresses.len()); + let mut hostnames = BTreeSet::new(); if let Some(ssl) = &bind.options.add_ssl { let external = bind .lan .assigned_ssl_port .or_not_found("assigned ssl port")?; + let target = (self.ip, *port).into(); + let connect_ssl = if let Some(alpn) = ssl.alpn.clone() { + Err(alpn) + } else { + if bind.options.secure.as_ref().map_or(false, |s| s.ssl) { + Ok(()) + } else { + Err(AlpnInfo::Reflect) + } + }; rcs.push( ctrl.vhost - .add( - None, - external, - (self.ip, *port).into(), - if let Some(alpn) = ssl.alpn.clone() { - Err(alpn) - } else { - if bind.options.secure.as_ref().map_or(false, |s| s.ssl) { - Ok(()) - } else { - Err(AlpnInfo::Reflect) - } - }, - ) + .add(None, external, target, connect_ssl.clone()) .await?, ); + for address in host.addresses() { + match address { + HostAddress::Onion { address } => { + let hostname = InternedString::from_display(address); + if hostnames.insert(hostname.clone()) { + rcs.push( + ctrl.vhost + .add( + Some(hostname), + external, + target, + connect_ssl.clone(), + ) + .await?, + ); + } + } + HostAddress::Domain { address } => { + if hostnames.insert(address.clone()) { + rcs.push( + ctrl.vhost + .add( + Some(address.clone()), + external, + target, + connect_ssl.clone(), + ) + .await?, + ); + } + } + } + } } if let Some(security) = bind.options.secure { if bind.options.add_ssl.is_some() && security.ssl { @@ -297,7 +365,7 @@ impl NetService { rcs.push(ctrl.forward.add(external, (self.ip, *port).into()).await?); } } - (bind.lan, bind.options.add_ssl.clone(), rcs) + (bind.lan, bind.options.add_ssl.clone(), hostnames, rcs) }; let mut bind_hostname_info: Vec = hostname_info.remove(port).unwrap_or_default(); @@ -337,9 +405,12 @@ impl NetService { hostname_info.insert(*port, bind_hostname_info); binds.lan.insert(*port, new_lan_bind); } - if let Some(lan) = old_lan_port { + if let Some((lan, _, hostnames, _)) = old_lan_bind { if let Some(external) = lan.assigned_ssl_port { ctrl.vhost.gc(None, external).await?; + for hostname in hostnames { + ctrl.vhost.gc(Some(hostname), external).await?; + } } if let Some(external) = lan.assigned_port { ctrl.forward.gc(external).await?; @@ -347,18 +418,21 @@ impl NetService { } } let mut removed = BTreeSet::new(); - binds.lan.retain(|internal, (external, _, _)| { + binds.lan.retain(|internal, (external, _, hostnames, _)| { if host.bindings.contains_key(internal) { true } else { - removed.insert(*external); + removed.insert((*external, std::mem::take(hostnames))); false } }); - for lan in removed { + for (lan, hostnames) in removed { if let Some(external) = lan.assigned_ssl_port { ctrl.vhost.gc(None, external).await?; + for hostname in hostnames { + ctrl.vhost.gc(Some(hostname), external).await?; + } } if let Some(external) = lan.assigned_port { ctrl.forward.gc(external).await?; @@ -401,48 +475,44 @@ impl NetService { ); } } + let mut keep_tor_addrs = BTreeSet::new(); - for addr in match host.kind { - HostKind::Multi => { - // itertools::Either::Left( - host.addresses.iter() - // ) - } // HostKind::Single | HostKind::Static => itertools::Either::Right(&host.primary), - } { - match addr { - HostAddress::Onion { address } => { - keep_tor_addrs.insert(address); - let old_tor_bind = binds.tor.remove(address); - let tor_bind = old_tor_bind.filter(|(ports, _)| ports == &tor_binds); - let new_tor_bind = if let Some(tor_bind) = tor_bind { - tor_bind - } else { - let key = peek - .as_private() - .as_key_store() - .as_onion() - .get_key(address)?; - let rcs = ctrl - .tor - .add(key, tor_binds.clone().into_iter().collect()) - .await?; - (tor_binds.clone(), rcs) - }; - for (internal, ports) in &tor_hostname_ports { - let mut bind_hostname_info = - hostname_info.remove(internal).unwrap_or_default(); - bind_hostname_info.push(HostnameInfo::Onion { - hostname: OnionHostname { - value: address.to_string(), - port: ports.non_ssl, - ssl_port: ports.ssl, - }, - }); - hostname_info.insert(*internal, bind_hostname_info); - } - binds.tor.insert(address.clone(), new_tor_bind); - } + for tor_addr in host.addresses().filter_map(|a| { + if let HostAddress::Onion { address } = a { + Some(address) + } else { + None } + }) { + keep_tor_addrs.insert(tor_addr); + let old_tor_bind = binds.tor.remove(tor_addr); + let tor_bind = old_tor_bind.filter(|(ports, _)| ports == &tor_binds); + let new_tor_bind = if let Some(tor_bind) = tor_bind { + tor_bind + } else { + let key = peek + .as_private() + .as_key_store() + .as_onion() + .get_key(tor_addr)?; + let rcs = ctrl + .tor + .add(key, tor_binds.clone().into_iter().collect()) + .await?; + (tor_binds.clone(), rcs) + }; + for (internal, ports) in &tor_hostname_ports { + let mut bind_hostname_info = hostname_info.remove(internal).unwrap_or_default(); + bind_hostname_info.push(HostnameInfo::Onion { + hostname: OnionHostname { + value: tor_addr.to_string(), + port: ports.non_ssl, + ssl_port: ports.ssl, + }, + }); + hostname_info.insert(*internal, bind_hostname_info); + } + binds.tor.insert(tor_addr.clone(), new_tor_bind); } for addr in binds.tor.keys() { if !keep_tor_addrs.contains(addr) { @@ -462,26 +532,8 @@ impl NetService { pub async fn remove_all(mut self) -> Result<(), Error> { self.shutdown = true; - let mut errors = ErrorCollection::new(); if let Some(ctrl) = Weak::upgrade(&self.controller) { - for (_, binds) in std::mem::take(&mut self.binds) { - for (_, (lan, _, rc)) in binds.lan { - drop(rc); - if let Some(external) = lan.assigned_ssl_port { - ctrl.vhost.gc(None, external).await?; - } - if let Some(external) = lan.assigned_port { - ctrl.forward.gc(external).await?; - } - } - for (addr, (_, rcs)) in binds.tor { - drop(rcs); - errors.handle(ctrl.tor.gc(Some(addr), None).await); - } - } - std::mem::take(&mut self.dns); - errors.handle(ctrl.dns.gc(Some(self.id.clone()), self.ip).await); - errors.into_result() + self.clear_bindings().await } else { tracing::warn!("NetService dropped after NetController is shutdown"); Err(Error::new( @@ -495,11 +547,11 @@ impl NetService { self.ip } - pub fn get_ext_port(&self, host_id: HostId, internal_port: u16) -> Result { + pub fn get_lan_port(&self, host_id: HostId, internal_port: u16) -> Result { let host_id_binds = self.binds.get_key_value(&host_id); match host_id_binds { Some((_, binds)) => { - if let Some((lan, _, _)) = binds.lan.get(&internal_port) { + if let Some((lan, _, _, _)) = binds.lan.get(&internal_port) { Ok(*lan) } else { Err(Error::new( diff --git a/core/startos/src/net/ssl.rs b/core/startos/src/net/ssl.rs index 382006072..29bcd9652 100644 --- a/core/startos/src/net/ssl.rs +++ b/core/startos/src/net/ssl.rs @@ -1,13 +1,13 @@ -use std::cmp::Ordering; +use std::cmp::{min, Ordering}; use std::collections::{BTreeMap, BTreeSet}; use std::net::IpAddr; use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use futures::FutureExt; use imbl_value::InternedString; use libc::time_t; -use openssl::asn1::{Asn1Integer, Asn1Time}; +use openssl::asn1::{Asn1Integer, Asn1Time, Asn1TimeRef}; use openssl::bn::{BigNum, MsbOption}; use openssl::ec::{EcGroup, EcKey}; use openssl::hash::MessageDigest; @@ -17,6 +17,7 @@ use openssl::x509::{X509Builder, X509Extension, X509NameBuilder, X509}; use openssl::*; use patch_db::HasModel; use serde::{Deserialize, Serialize}; +use tokio::time::Instant; use tracing::instrument; use crate::account::AccountInfo; @@ -126,12 +127,18 @@ impl Model { } } -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct CertData { pub keys: PKeyPair, pub certs: CertPair, } +impl CertData { + pub fn expiration(&self) -> Result { + self.certs.expiration() + } +} +#[derive(Debug, Clone, PartialEq, Eq)] pub struct FullchainCertData { pub root: X509, pub int: X509, @@ -144,6 +151,16 @@ impl FullchainCertData { pub fn fullchain_nistp256(&self) -> Vec<&X509> { vec![&self.leaf.certs.nistp256, &self.int, &self.root] } + pub fn expiration(&self) -> Result { + [ + asn1_time_to_system_time(self.root.not_after())?, + asn1_time_to_system_time(self.int.not_after())?, + self.leaf.expiration()?, + ] + .into_iter() + .min() + .ok_or_else(|| Error::new(eyre!("unreachable"), ErrorKind::Unknown)) + } } static CERTIFICATE_VERSION: i32 = 2; // X509 version 3 is actually encoded as '2' in the cert because fuck you. @@ -155,6 +172,26 @@ fn unix_time(time: SystemTime) -> time_t { .unwrap_or_default() } +lazy_static::lazy_static! { + static ref ASN1_UNIX_EPOCH: Asn1Time = Asn1Time::from_unix(0).unwrap(); +} + +fn asn1_time_to_system_time(time: &Asn1TimeRef) -> Result { + let diff = time.diff(&**ASN1_UNIX_EPOCH)?; + let mut res = UNIX_EPOCH; + if diff.days >= 0 { + res += Duration::from_secs(diff.days as u64 * 86400); + } else { + res -= Duration::from_secs((-1 * diff.days) as u64 * 86400); + } + if diff.secs >= 0 { + res += Duration::from_secs(diff.secs as u64); + } else { + res -= Duration::from_secs((-1 * diff.secs) as u64); + } + Ok(res) +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct PKeyPair { #[serde(with = "crate::util::serde::pem")] @@ -162,6 +199,12 @@ pub struct PKeyPair { #[serde(with = "crate::util::serde::pem")] pub nistp256: PKey, } +impl PartialEq for PKeyPair { + fn eq(&self, other: &Self) -> bool { + self.ed25519.public_eq(&other.ed25519) && self.nistp256.public_eq(&other.nistp256) + } +} +impl Eq for PKeyPair {} #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] pub struct CertPair { @@ -170,6 +213,14 @@ pub struct CertPair { #[serde(with = "crate::util::serde::pem")] pub nistp256: X509, } +impl CertPair { + pub fn expiration(&self) -> Result { + Ok(min( + asn1_time_to_system_time(self.ed25519.not_after())?, + asn1_time_to_system_time(self.nistp256.not_after())?, + )) + } +} pub async fn root_ca_start_time() -> Result { Ok(if check_time_is_synchronized().await? { diff --git a/core/startos/src/net/vhost.rs b/core/startos/src/net/vhost.rs index e6a9d5b21..cdd752709 100644 --- a/core/startos/src/net/vhost.rs +++ b/core/startos/src/net/vhost.rs @@ -46,7 +46,7 @@ impl VHostController { #[instrument(skip_all)] pub async fn add( &self, - hostname: Option, + hostname: Option, external: u16, target: SocketAddr, connect_ssl: Result<(), AlpnInfo>, // Ok: yes, connect using ssl, pass through alpn; Err: connect tcp, use provided strategy for alpn @@ -70,7 +70,7 @@ impl VHostController { Ok(rc?) } #[instrument(skip_all)] - pub async fn gc(&self, hostname: Option, external: u16) -> Result<(), Error> { + pub async fn gc(&self, hostname: Option, external: u16) -> Result<(), Error> { let mut writable = self.servers.lock().await; if let Some(server) = writable.remove(&external) { server.gc(hostname).await?; @@ -102,7 +102,7 @@ impl Default for AlpnInfo { } struct VHostServer { - mapping: Weak, BTreeMap>>>>, + mapping: Weak, BTreeMap>>>>, _thread: NonDetachingJoinHandle<()>, } impl VHostServer { @@ -179,7 +179,7 @@ impl VHostServer { } }; let target_name = - mid.client_hello().server_name().map(|s| s.to_owned()); + mid.client_hello().server_name().map(|s| s.into()); let target = { let mapping = mapping.read().await; mapping @@ -208,9 +208,7 @@ impl VHostServer { let mut tcp_stream = TcpStream::connect(target.addr).await?; let hostnames = target_name - .as_ref() .into_iter() - .map(InternedString::intern) .chain( db.peek() .await @@ -405,7 +403,11 @@ impl VHostServer { .into(), }) } - async fn add(&self, hostname: Option, target: TargetInfo) -> Result, Error> { + async fn add( + &self, + hostname: Option, + target: TargetInfo, + ) -> Result, Error> { if let Some(mapping) = Weak::upgrade(&self.mapping) { let mut writable = mapping.write().await; let mut targets = writable.remove(&hostname).unwrap_or_default(); @@ -424,7 +426,7 @@ impl VHostServer { )) } } - async fn gc(&self, hostname: Option) -> Result<(), Error> { + async fn gc(&self, hostname: Option) -> Result<(), Error> { if let Some(mapping) = Weak::upgrade(&self.mapping) { let mut writable = mapping.write().await; let mut targets = writable.remove(&hostname).unwrap_or_default(); diff --git a/core/startos/src/registry/os/asset/add.rs b/core/startos/src/registry/os/asset/add.rs index c18d05b8f..d609063ea 100644 --- a/core/startos/src/registry/os/asset/add.rs +++ b/core/startos/src/registry/os/asset/add.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use chrono::Utc; use clap::Parser; +use exver::Version; use imbl_value::InternedString; use itertools::Itertools; use rpc_toolkit::{from_fn_async, Context, HandlerArgs, HandlerExt, ParentHandler}; @@ -27,7 +28,6 @@ use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; use crate::s9pk::merkle_archive::source::ArchiveSource; use crate::util::io::open_file; use crate::util::serde::Base64; -use crate::util::VersionString; pub fn add_api() -> ParentHandler { ParentHandler::new() @@ -55,7 +55,8 @@ pub fn add_api() -> ParentHandler { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct AddAssetParams { - pub version: VersionString, + #[ts(type = "string")] + pub version: Version, #[ts(type = "string")] pub platform: InternedString, #[ts(type = "string")] @@ -154,7 +155,7 @@ pub struct CliAddAssetParams { #[arg(short = 'p', long = "platform")] pub platform: InternedString, #[arg(short = 'v', long = "version")] - pub version: VersionString, + pub version: Version, pub file: PathBuf, pub url: Url, } @@ -209,11 +210,18 @@ pub async fn cli_add_asset( hash: Base64(*blake3.as_bytes()), size, }; - let signature = Ed25519.sign_commitment(ctx.developer_key()?, &commitment, SIG_CONTEXT)?; + let signature = AnySignature::Ed25519(Ed25519.sign_commitment( + ctx.developer_key()?, + &commitment, + SIG_CONTEXT, + )?); sign_phase.complete(); verify_phase.start(); let src = HttpSource::new(ctx.client.clone(), url.clone()).await?; + if let Some(size) = src.size().await { + verify_phase.set_total(size); + } let mut writer = verify_phase.writer(VerifyingWriter::new( tokio::io::sink(), Some((blake3::Hash::from_bytes(*commitment.hash), commitment.size)), diff --git a/core/startos/src/registry/os/asset/get.rs b/core/startos/src/registry/os/asset/get.rs index b185cf6a4..ad0010dca 100644 --- a/core/startos/src/registry/os/asset/get.rs +++ b/core/startos/src/registry/os/asset/get.rs @@ -3,6 +3,7 @@ use std::panic::UnwindSafe; use std::path::{Path, PathBuf}; use clap::Parser; +use exver::Version; use helpers::AtomicFile; use imbl_value::{json, InternedString}; use itertools::Itertools; @@ -21,7 +22,6 @@ use crate::registry::signer::commitment::blake3::Blake3Commitment; use crate::registry::signer::commitment::Commitment; use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; use crate::util::io::open_file; -use crate::util::VersionString; pub fn get_api() -> ParentHandler { ParentHandler::new() @@ -37,7 +37,8 @@ pub fn get_api() -> ParentHandler { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct GetOsAssetParams { - pub version: VersionString, + #[ts(type = "string")] + pub version: Version, #[ts(type = "string")] pub platform: InternedString, } @@ -91,7 +92,7 @@ pub async fn get_squashfs( #[command(rename_all = "kebab-case")] #[serde(rename_all = "camelCase")] pub struct CliGetOsAssetParams { - pub version: VersionString, + pub version: Version, pub platform: InternedString, #[arg(long = "download", short = 'd')] pub download: Option, diff --git a/core/startos/src/registry/os/asset/sign.rs b/core/startos/src/registry/os/asset/sign.rs index 12903d8a1..18b603daf 100644 --- a/core/startos/src/registry/os/asset/sign.rs +++ b/core/startos/src/registry/os/asset/sign.rs @@ -3,6 +3,7 @@ use std::panic::UnwindSafe; use std::path::PathBuf; use clap::Parser; +use exver::Version; use imbl_value::InternedString; use itertools::Itertools; use rpc_toolkit::{from_fn_async, Context, HandlerArgs, HandlerExt, ParentHandler}; @@ -23,7 +24,6 @@ use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; use crate::s9pk::merkle_archive::source::ArchiveSource; use crate::util::io::open_file; use crate::util::serde::Base64; -use crate::util::VersionString; pub fn sign_api() -> ParentHandler { ParentHandler::new() @@ -51,7 +51,8 @@ pub fn sign_api() -> ParentHandler { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct SignAssetParams { - version: VersionString, + #[ts(type = "string")] + version: Version, #[ts(type = "string")] platform: InternedString, #[ts(skip)] @@ -137,7 +138,7 @@ pub struct CliSignAssetParams { #[arg(short = 'p', long = "platform")] pub platform: InternedString, #[arg(short = 'v', long = "version")] - pub version: VersionString, + pub version: Version, pub file: PathBuf, } @@ -189,7 +190,11 @@ pub async fn cli_sign_asset( hash: Base64(*blake3.as_bytes()), size, }; - let signature = Ed25519.sign_commitment(ctx.developer_key()?, &commitment, SIG_CONTEXT)?; + let signature = AnySignature::Ed25519(Ed25519.sign_commitment( + ctx.developer_key()?, + &commitment, + SIG_CONTEXT, + )?); sign_phase.complete(); index_phase.start(); diff --git a/core/startos/src/registry/os/index.rs b/core/startos/src/registry/os/index.rs index 0b1ca5b89..b61cb8f96 100644 --- a/core/startos/src/registry/os/index.rs +++ b/core/startos/src/registry/os/index.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use exver::VersionRange; +use exver::{Version, VersionRange}; use imbl_value::InternedString; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -10,14 +10,28 @@ use crate::registry::asset::RegistryAsset; use crate::registry::context::RegistryContext; use crate::registry::signer::commitment::blake3::Blake3Commitment; use crate::rpc_continuations::Guid; -use crate::util::VersionString; #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] #[serde(rename_all = "camelCase")] #[model = "Model"] #[ts(export)] pub struct OsIndex { - pub versions: BTreeMap, + pub versions: OsVersionInfoMap, +} + +#[derive(Debug, Default, Deserialize, Serialize, TS)] +pub struct OsVersionInfoMap( + #[ts(as = "BTreeMap::")] pub BTreeMap, +); +impl Map for OsVersionInfoMap { + type Key = Version; + type Value = OsVersionInfo; + fn key_str(key: &Self::Key) -> Result, Error> { + Ok(InternedString::from_display(key)) + } + fn key_string(key: &Self::Key) -> Result { + Ok(InternedString::from_display(key)) + } } #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] diff --git a/core/startos/src/registry/os/version/mod.rs b/core/startos/src/registry/os/version/mod.rs index 242ae28a7..4c0568a80 100644 --- a/core/startos/src/registry/os/version/mod.rs +++ b/core/startos/src/registry/os/version/mod.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use chrono::Utc; use clap::Parser; -use exver::VersionRange; +use exver::{Version, VersionRange}; use itertools::Itertools; use rpc_toolkit::{from_fn_async, Context, HandlerExt, ParentHandler}; use serde::{Deserialize, Serialize}; @@ -15,7 +15,6 @@ use crate::registry::context::RegistryContext; use crate::registry::os::index::OsVersionInfo; use crate::registry::signer::sign::AnyVerifyingKey; use crate::util::serde::{display_serializable, HandlerExtSerde, WithIoFormat}; -use crate::util::VersionString; pub mod signer; @@ -53,7 +52,8 @@ pub fn version_api() -> ParentHandler { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct AddVersionParams { - pub version: VersionString, + #[ts(type = "string")] + pub version: Version, pub headline: String, pub release_notes: String, #[ts(type = "string")] @@ -99,7 +99,8 @@ pub async fn add_version( #[serde(rename_all = "camelCase")] #[ts(export)] pub struct RemoveVersionParams { - pub version: VersionString, + #[ts(type = "string")] + pub version: Version, } pub async fn remove_version( @@ -124,7 +125,7 @@ pub async fn remove_version( pub struct GetOsVersionParams { #[ts(type = "string | null")] #[arg(long = "src")] - pub source: Option, + pub source: Option, #[ts(type = "string | null")] #[arg(long = "target")] pub target: Option, @@ -144,7 +145,7 @@ pub async fn get_version( server_id, arch, }: GetOsVersionParams, -) -> Result, Error> { +) -> Result, Error> { if let (Some(pool), Some(server_id), Some(arch)) = (&ctx.pool, server_id, arch) { let created_at = Utc::now(); @@ -176,10 +177,7 @@ pub async fn get_version( .collect() } -pub fn display_version_info( - params: WithIoFormat, - info: BTreeMap, -) { +pub fn display_version_info(params: WithIoFormat, info: BTreeMap) { use prettytable::*; if let Some(format) = params.format { @@ -197,7 +195,7 @@ pub fn display_version_info( ]); for (version, info) in &info { table.add_row(row![ - version.as_str(), + &version.to_string(), &info.headline, &info.release_notes, &info.iso.keys().into_iter().join(", "), diff --git a/core/startos/src/registry/os/version/signer.rs b/core/startos/src/registry/os/version/signer.rs index bb15860aa..51f7c6719 100644 --- a/core/startos/src/registry/os/version/signer.rs +++ b/core/startos/src/registry/os/version/signer.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use clap::Parser; +use exver::Version; use rpc_toolkit::{from_fn_async, Context, HandlerExt, ParentHandler}; use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -12,7 +13,6 @@ use crate::registry::context::RegistryContext; use crate::registry::signer::SignerInfo; use crate::rpc_continuations::Guid; use crate::util::serde::HandlerExtSerde; -use crate::util::VersionString; pub fn signer_api() -> ParentHandler { ParentHandler::new() @@ -44,7 +44,8 @@ pub fn signer_api() -> ParentHandler { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct VersionSignerParams { - pub version: VersionString, + #[ts(type = "string")] + pub version: Version, pub signer: Guid, } @@ -104,7 +105,8 @@ pub async fn remove_version_signer( #[serde(rename_all = "camelCase")] #[ts(export)] pub struct ListVersionSignersParams { - pub version: VersionString, + #[ts(type = "string")] + pub version: Version, } pub async fn list_version_signers( diff --git a/core/startos/src/service/cli.rs b/core/startos/src/service/cli.rs index f5e04999c..95add37fb 100644 --- a/core/startos/src/service/cli.rs +++ b/core/startos/src/service/cli.rs @@ -9,7 +9,7 @@ use rpc_toolkit::{call_remote_socket, yajrc, CallRemote, Context, Empty}; use tokio::runtime::Runtime; use crate::lxc::HOST_RPC_SERVER_SOCKET; -use crate::service::service_effect_handler::EffectContext; +use crate::service::effects::context::EffectContext; #[derive(Debug, Default, Parser)] pub struct ContainerClientConfig { diff --git a/core/startos/src/service/effects/action.rs b/core/startos/src/service/effects/action.rs new file mode 100644 index 000000000..4719c6d3d --- /dev/null +++ b/core/startos/src/service/effects/action.rs @@ -0,0 +1,101 @@ +use std::collections::BTreeMap; + +use models::{ActionId, PackageId}; + +use crate::action::ActionResult; +use crate::db::model::package::ActionMetadata; +use crate::rpc_continuations::Guid; +use crate::service::effects::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ExportActionParams { + #[ts(optional)] + package_id: Option, + id: ActionId, + metadata: ActionMetadata, +} +pub async fn export_action(context: EffectContext, data: ExportActionParams) -> Result<(), Error> { + let context = context.deref()?; + let package_id = context.seed.id.clone(); + context + .seed + .ctx + .db + .mutate(|db| { + let model = db + .as_public_mut() + .as_package_data_mut() + .as_idx_mut(&package_id) + .or_not_found(&package_id)? + .as_actions_mut(); + let mut value = model.de()?; + value + .insert(data.id, data.metadata) + .map(|_| ()) + .unwrap_or_default(); + model.ser(&value) + }) + .await?; + Ok(()) +} + +pub async fn clear_actions(context: EffectContext) -> Result<(), Error> { + let context = context.deref()?; + let package_id = context.seed.id.clone(); + context + .seed + .ctx + .db + .mutate(|db| { + db.as_public_mut() + .as_package_data_mut() + .as_idx_mut(&package_id) + .or_not_found(&package_id)? + .as_actions_mut() + .ser(&BTreeMap::new()) + }) + .await?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ExecuteAction { + #[serde(default)] + #[ts(skip)] + procedure_id: Guid, + #[ts(optional)] + package_id: Option, + action_id: ActionId, + #[ts(type = "any")] + input: Value, +} +pub async fn execute_action( + context: EffectContext, + ExecuteAction { + procedure_id, + package_id, + action_id, + input, + }: ExecuteAction, +) -> Result { + let context = context.deref()?; + + if let Some(package_id) = package_id { + context + .seed + .ctx + .services + .get(&package_id) + .await + .as_ref() + .or_not_found(&package_id)? + .action(procedure_id, action_id, input) + .await + } else { + context.action(procedure_id, action_id, input).await + } +} diff --git a/core/startos/src/service/effects/callbacks.rs b/core/startos/src/service/effects/callbacks.rs new file mode 100644 index 000000000..1a9250aa8 --- /dev/null +++ b/core/startos/src/service/effects/callbacks.rs @@ -0,0 +1,311 @@ +use std::cmp::min; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::{Duration, SystemTime}; + +use futures::future::join_all; +use helpers::NonDetachingJoinHandle; +use imbl::{vector, Vector}; +use imbl_value::InternedString; +use models::{HostId, PackageId, ServiceInterfaceId}; +use patch_db::json_ptr::JsonPointer; +use tracing::warn; + +use crate::net::ssl::FullchainCertData; +use crate::prelude::*; +use crate::service::effects::context::EffectContext; +use crate::service::effects::net::ssl::Algorithm; +use crate::service::rpc::CallbackHandle; +use crate::service::{Service, ServiceActorSeed}; +use crate::util::collections::EqMap; + +#[derive(Default)] +pub struct ServiceCallbacks(Mutex); + +#[derive(Default)] +struct ServiceCallbackMap { + get_service_interface: BTreeMap<(PackageId, ServiceInterfaceId), Vec>, + list_service_interfaces: BTreeMap>, + get_system_smtp: Vec, + get_host_info: BTreeMap<(PackageId, HostId), Vec>, + get_ssl_certificate: EqMap< + (BTreeSet, FullchainCertData, Algorithm), + (NonDetachingJoinHandle<()>, Vec), + >, + get_store: BTreeMap>>, +} + +impl ServiceCallbacks { + fn mutate(&self, f: impl FnOnce(&mut ServiceCallbackMap) -> T) -> T { + let mut this = self.0.lock().unwrap(); + f(&mut *this) + } + + pub fn gc(&self) { + self.mutate(|this| { + this.get_service_interface.retain(|_, v| { + v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); + !v.is_empty() + }); + this.list_service_interfaces.retain(|_, v| { + v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); + !v.is_empty() + }); + this.get_system_smtp + .retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); + this.get_host_info.retain(|_, v| { + v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); + !v.is_empty() + }); + this.get_ssl_certificate.retain(|_, (_, v)| { + v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); + !v.is_empty() + }); + this.get_store.retain(|_, v| { + v.retain(|_, v| { + v.retain(|h| h.handle.is_active() && h.seed.strong_count() > 0); + !v.is_empty() + }); + !v.is_empty() + }); + }) + } + + pub(super) fn add_get_service_interface( + &self, + package_id: PackageId, + service_interface_id: ServiceInterfaceId, + handler: CallbackHandler, + ) { + self.mutate(|this| { + this.get_service_interface + .entry((package_id, service_interface_id)) + .or_default() + .push(handler); + }) + } + + #[must_use] + pub fn get_service_interface( + &self, + id: &(PackageId, ServiceInterfaceId), + ) -> Option { + self.mutate(|this| { + Some(CallbackHandlers( + this.get_service_interface.remove(id).unwrap_or_default(), + )) + .filter(|cb| !cb.0.is_empty()) + }) + } + + pub(super) fn add_list_service_interfaces( + &self, + package_id: PackageId, + handler: CallbackHandler, + ) { + self.mutate(|this| { + this.list_service_interfaces + .entry(package_id) + .or_default() + .push(handler); + }) + } + + #[must_use] + pub fn list_service_interfaces(&self, id: &PackageId) -> Option { + self.mutate(|this| { + Some(CallbackHandlers( + this.list_service_interfaces.remove(id).unwrap_or_default(), + )) + .filter(|cb| !cb.0.is_empty()) + }) + } + + pub(super) fn add_get_system_smtp(&self, handler: CallbackHandler) { + self.mutate(|this| { + this.get_system_smtp.push(handler); + }) + } + + #[must_use] + pub fn get_system_smtp(&self) -> Option { + self.mutate(|this| { + Some(CallbackHandlers(std::mem::take(&mut this.get_system_smtp))) + .filter(|cb| !cb.0.is_empty()) + }) + } + + pub(super) fn add_get_host_info( + &self, + package_id: PackageId, + host_id: HostId, + handler: CallbackHandler, + ) { + self.mutate(|this| { + this.get_host_info + .entry((package_id, host_id)) + .or_default() + .push(handler); + }) + } + + #[must_use] + pub fn get_host_info(&self, id: &(PackageId, HostId)) -> Option { + self.mutate(|this| { + Some(CallbackHandlers( + this.get_host_info.remove(id).unwrap_or_default(), + )) + .filter(|cb| !cb.0.is_empty()) + }) + } + + pub(super) fn add_get_ssl_certificate( + &self, + ctx: EffectContext, + hostnames: BTreeSet, + cert: FullchainCertData, + algorithm: Algorithm, + handler: CallbackHandler, + ) { + self.mutate(|this| { + this.get_ssl_certificate + .entry((hostnames.clone(), cert.clone(), algorithm)) + .or_insert_with(|| { + ( + tokio::spawn(async move { + if let Err(e) = async { + loop { + match cert + .expiration() + .ok() + .and_then(|e| e.duration_since(SystemTime::now()).ok()) + { + Some(d) => { + tokio::time::sleep(min(Duration::from_secs(86400), d)) + .await + } + _ => break, + } + } + let Ok(ctx) = ctx.deref() else { + return Ok(()); + }; + + if let Some((_, callbacks)) = + ctx.seed.ctx.callbacks.mutate(|this| { + this.get_ssl_certificate + .remove(&(hostnames, cert, algorithm)) + }) + { + CallbackHandlers(callbacks).call(vector![]).await?; + } + Ok::<_, Error>(()) + } + .await + { + tracing::error!( + "Error in callback handler for getSslCertificate: {e}" + ); + tracing::debug!("{e:?}"); + } + }) + .into(), + Vec::new(), + ) + }) + .1 + .push(handler); + }) + } + + pub(super) fn add_get_store( + &self, + package_id: PackageId, + path: JsonPointer, + handler: CallbackHandler, + ) { + self.mutate(|this| { + this.get_store + .entry(package_id) + .or_default() + .entry(path) + .or_default() + .push(handler) + }) + } + + #[must_use] + pub fn get_store( + &self, + package_id: &PackageId, + path: &JsonPointer, + ) -> Option { + self.mutate(|this| { + if let Some(watched) = this.get_store.get_mut(package_id) { + let mut res = Vec::new(); + watched.retain(|ptr, cbs| { + if ptr.starts_with(path) || path.starts_with(ptr) { + res.append(cbs); + false + } else { + true + } + }); + Some(CallbackHandlers(res)) + } else { + None + } + .filter(|cb| !cb.0.is_empty()) + }) + } +} + +pub struct CallbackHandler { + handle: CallbackHandle, + seed: Weak, +} +impl CallbackHandler { + pub fn new(service: &Service, handle: CallbackHandle) -> Self { + Self { + handle, + seed: Arc::downgrade(&service.seed), + } + } + pub async fn call(mut self, args: Vector) -> Result<(), Error> { + if let Some(seed) = self.seed.upgrade() { + seed.persistent_container + .callback(self.handle.take(), args) + .await?; + } + Ok(()) + } +} +impl Drop for CallbackHandler { + fn drop(&mut self) { + if self.handle.is_active() { + warn!("Callback handler dropped while still active!"); + } + } +} + +pub struct CallbackHandlers(Vec); +impl CallbackHandlers { + pub async fn call(self, args: Vector) -> Result<(), Error> { + let mut err = ErrorCollection::new(); + for res in join_all(self.0.into_iter().map(|cb| cb.call(args.clone()))).await { + err.handle(res); + } + err.into_result() + } +} + +pub(super) fn clear_callbacks(context: EffectContext) -> Result<(), Error> { + let context = context.deref()?; + context + .seed + .persistent_container + .state + .send_if_modified(|s| !std::mem::take(&mut s.callbacks).is_empty()); + context.seed.ctx.callbacks.gc(); + Ok(()) +} diff --git a/core/startos/src/service/effects/config.rs b/core/startos/src/service/effects/config.rs new file mode 100644 index 000000000..647d3e272 --- /dev/null +++ b/core/startos/src/service/effects/config.rs @@ -0,0 +1,53 @@ +use models::PackageId; + +use crate::service::effects::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct GetConfiguredParams { + #[ts(optional)] + package_id: Option, +} +pub async fn get_configured(context: EffectContext) -> Result { + let context = context.deref()?; + let peeked = context.seed.ctx.db.peek().await; + let package_id = &context.seed.id; + peeked + .as_public() + .as_package_data() + .as_idx(package_id) + .or_not_found(package_id)? + .as_status() + .as_configured() + .de() +} + +#[derive(Debug, Clone, Serialize, Deserialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SetConfigured { + configured: bool, +} +pub async fn set_configured( + context: EffectContext, + SetConfigured { configured }: SetConfigured, +) -> Result<(), Error> { + let context = context.deref()?; + let package_id = &context.seed.id; + context + .seed + .ctx + .db + .mutate(|db| { + db.as_public_mut() + .as_package_data_mut() + .as_idx_mut(package_id) + .or_not_found(package_id)? + .as_status_mut() + .as_configured_mut() + .ser(&configured) + }) + .await?; + Ok(()) +} diff --git a/core/startos/src/service/effects/context.rs b/core/startos/src/service/effects/context.rs new file mode 100644 index 000000000..b97499332 --- /dev/null +++ b/core/startos/src/service/effects/context.rs @@ -0,0 +1,27 @@ +use std::sync::{Arc, Weak}; + +use rpc_toolkit::Context; + +use crate::prelude::*; +use crate::service::Service; + +#[derive(Clone)] +pub(in crate::service) struct EffectContext(Weak); +impl EffectContext { + pub fn new(service: Weak) -> Self { + Self(service) + } +} +impl Context for EffectContext {} +impl EffectContext { + pub(super) fn deref(&self) -> Result, Error> { + if let Some(seed) = Weak::upgrade(&self.0) { + Ok(seed) + } else { + Err(Error::new( + eyre!("Service has already been destroyed"), + ErrorKind::InvalidRequest, + )) + } + } +} diff --git a/core/startos/src/service/effects/control.rs b/core/startos/src/service/effects/control.rs new file mode 100644 index 000000000..6b3c6f8a0 --- /dev/null +++ b/core/startos/src/service/effects/control.rs @@ -0,0 +1,66 @@ +use std::str::FromStr; + +use clap::builder::ValueParserFactory; + +use crate::service::effects::prelude::*; +use crate::util::clap::FromStrParser; + +pub async fn restart( + context: EffectContext, + ProcedureId { procedure_id }: ProcedureId, +) -> Result<(), Error> { + let context = context.deref()?; + context.restart(procedure_id).await?; + Ok(()) +} + +pub async fn shutdown( + context: EffectContext, + ProcedureId { procedure_id }: ProcedureId, +) -> Result<(), Error> { + let context = context.deref()?; + context.stop(procedure_id).await?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum SetMainStatusStatus { + Running, + Stopped, +} +impl FromStr for SetMainStatusStatus { + type Err = color_eyre::eyre::Report; + fn from_str(s: &str) -> Result { + match s { + "running" => Ok(Self::Running), + "stopped" => Ok(Self::Stopped), + _ => Err(eyre!("unknown status {s}")), + } + } +} +impl ValueParserFactory for SetMainStatusStatus { + type Parser = FromStrParser; + fn value_parser() -> Self::Parser { + FromStrParser::new() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SetMainStatus { + status: SetMainStatusStatus, +} +pub async fn set_main_status( + context: EffectContext, + SetMainStatus { status }: SetMainStatus, +) -> Result<(), Error> { + let context = context.deref()?; + match status { + SetMainStatusStatus::Running => context.seed.started(), + SetMainStatusStatus::Stopped => context.seed.stopped(), + } + Ok(()) +} diff --git a/core/startos/src/service/effects/dependency.rs b/core/startos/src/service/effects/dependency.rs new file mode 100644 index 000000000..dfc8795f0 --- /dev/null +++ b/core/startos/src/service/effects/dependency.rs @@ -0,0 +1,371 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::str::FromStr; + +use clap::builder::ValueParserFactory; +use exver::VersionRange; +use itertools::Itertools; +use models::{HealthCheckId, PackageId, VolumeId}; +use patch_db::json_ptr::JsonPointer; + +use crate::db::model::package::{ + CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, ManifestPreference, +}; +use crate::rpc_continuations::Guid; +use crate::service::effects::prelude::*; +use crate::status::health_check::HealthCheckResult; +use crate::util::clap::FromStrParser; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct MountTarget { + package_id: PackageId, + volume_id: VolumeId, + subpath: Option, + readonly: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct MountParams { + location: String, + target: MountTarget, +} +pub async fn mount( + context: EffectContext, + MountParams { + location, + target: + MountTarget { + package_id, + volume_id, + subpath, + readonly, + }, + }: MountParams, +) -> Result<(), Error> { + // TODO + todo!() +} + +pub async fn get_installed_packages(context: EffectContext) -> Result, Error> { + context + .deref()? + .seed + .ctx + .db + .peek() + .await + .into_public() + .into_package_data() + .keys() +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ExposeForDependentsParams { + #[ts(type = "string[]")] + paths: Vec, +} +pub async fn expose_for_dependents( + context: EffectContext, + ExposeForDependentsParams { paths }: ExposeForDependentsParams, +) -> Result<(), Error> { + Ok(()) +} + +#[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")] +#[ts(export)] +pub enum DependencyRequirement { + Running { + id: PackageId, + health_checks: BTreeSet, + #[ts(type = "string")] + version_range: VersionRange, + }, + Exists { + id: PackageId, + #[ts(type = "string")] + version_range: VersionRange, + }, +} +// filebrowser:exists,bitcoind:running:foo+bar+baz +impl FromStr for DependencyRequirement { + type Err = Error; + fn from_str(s: &str) -> Result { + match s.split_once(':') { + Some((id, "e")) | Some((id, "exists")) => Ok(Self::Exists { + id: id.parse()?, + version_range: "*".parse()?, // TODO + }), + Some((id, rest)) => { + let health_checks = match rest.split_once(':') { + Some(("r", rest)) | Some(("running", rest)) => rest + .split('+') + .map(|id| id.parse().map_err(Error::from)) + .collect(), + Some((kind, _)) => Err(Error::new( + eyre!("unknown dependency kind {kind}"), + ErrorKind::InvalidRequest, + )), + None => match rest { + "r" | "running" => Ok(BTreeSet::new()), + kind => Err(Error::new( + eyre!("unknown dependency kind {kind}"), + ErrorKind::InvalidRequest, + )), + }, + }?; + Ok(Self::Running { + id: id.parse()?, + health_checks, + version_range: "*".parse()?, // TODO + }) + } + None => Ok(Self::Running { + id: s.parse()?, + health_checks: BTreeSet::new(), + version_range: "*".parse()?, // TODO + }), + } + } +} +impl ValueParserFactory for DependencyRequirement { + type Parser = FromStrParser; + fn value_parser() -> Self::Parser { + FromStrParser::new() + } +} +#[derive(Deserialize, Serialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[command(rename_all = "camelCase")] +#[ts(export)] +pub struct SetDependenciesParams { + #[serde(default)] + procedure_id: Guid, + dependencies: Vec, +} +pub async fn set_dependencies( + context: EffectContext, + SetDependenciesParams { + procedure_id, + dependencies, + }: SetDependenciesParams, +) -> Result<(), Error> { + let context = context.deref()?; + let id = &context.seed.id; + + let mut deps = BTreeMap::new(); + for dependency in dependencies { + let (dep_id, kind, version_range) = match dependency { + DependencyRequirement::Exists { id, version_range } => { + (id, CurrentDependencyKind::Exists, version_range) + } + DependencyRequirement::Running { + id, + health_checks, + version_range, + } => ( + id, + CurrentDependencyKind::Running { health_checks }, + version_range, + ), + }; + let config_satisfied = + if let Some(dep_service) = &*context.seed.ctx.services.get(&dep_id).await { + context + .dependency_config( + procedure_id.clone(), + dep_id.clone(), + dep_service.get_config(procedure_id.clone()).await?.config, + ) + .await? + .is_none() + } else { + true + }; + let info = CurrentDependencyInfo { + title: context + .seed + .persistent_container + .s9pk + .dependency_metadata(&dep_id) + .await? + .map(|m| m.title), + icon: context + .seed + .persistent_container + .s9pk + .dependency_icon_data_url(&dep_id) + .await?, + kind, + version_range, + config_satisfied, + }; + deps.insert(dep_id, info); + } + context + .seed + .ctx + .db + .mutate(|db| { + db.as_public_mut() + .as_package_data_mut() + .as_idx_mut(id) + .or_not_found(id)? + .as_current_dependencies_mut() + .ser(&CurrentDependencies(deps)) + }) + .await +} + +pub async fn get_dependencies(context: EffectContext) -> Result, Error> { + let context = context.deref()?; + let id = &context.seed.id; + let db = context.seed.ctx.db.peek().await; + let data = db + .as_public() + .as_package_data() + .as_idx(id) + .or_not_found(id)? + .as_current_dependencies() + .de()?; + + data.0 + .into_iter() + .map(|(id, current_dependency_info)| { + let CurrentDependencyInfo { + version_range, + kind, + .. + } = current_dependency_info; + Ok::<_, Error>(match kind { + CurrentDependencyKind::Exists => { + DependencyRequirement::Exists { id, version_range } + } + CurrentDependencyKind::Running { health_checks } => { + DependencyRequirement::Running { + id, + health_checks, + version_range, + } + } + }) + }) + .try_collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct CheckDependenciesParam { + #[ts(optional)] + package_ids: Option>, +} +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct CheckDependenciesResult { + package_id: PackageId, + is_installed: bool, + is_running: bool, + config_satisfied: bool, + health_checks: BTreeMap, + #[ts(type = "string | null")] + version: Option, +} +pub async fn check_dependencies( + context: EffectContext, + CheckDependenciesParam { package_ids }: CheckDependenciesParam, +) -> Result, Error> { + let context = context.deref()?; + let db = context.seed.ctx.db.peek().await; + let current_dependencies = db + .as_public() + .as_package_data() + .as_idx(&context.seed.id) + .or_not_found(&context.seed.id)? + .as_current_dependencies() + .de()?; + let package_ids: Vec<_> = package_ids + .unwrap_or_else(|| current_dependencies.0.keys().cloned().collect()) + .into_iter() + .filter_map(|x| { + let info = current_dependencies.0.get(&x)?; + Some((x, info)) + }) + .collect(); + let mut results = Vec::with_capacity(package_ids.len()); + + for (package_id, dependency_info) in package_ids { + let Some(package) = db.as_public().as_package_data().as_idx(&package_id) else { + results.push(CheckDependenciesResult { + package_id, + is_installed: false, + is_running: false, + config_satisfied: false, + health_checks: Default::default(), + version: None, + }); + continue; + }; + let manifest = package.as_state_info().as_manifest(ManifestPreference::New); + let installed_version = manifest.as_version().de()?.into_version(); + let satisfies = manifest.as_satisfies().de()?; + let version = Some(installed_version.clone()); + if ![installed_version] + .into_iter() + .chain(satisfies.into_iter().map(|v| v.into_version())) + .any(|v| v.satisfies(&dependency_info.version_range)) + { + results.push(CheckDependenciesResult { + package_id, + is_installed: false, + is_running: false, + config_satisfied: false, + health_checks: Default::default(), + version, + }); + continue; + } + let is_installed = true; + let status = package.as_status().as_main().de()?; + let is_running = if is_installed { + status.running() + } else { + false + }; + let health_checks = + if let CurrentDependencyKind::Running { health_checks } = &dependency_info.kind { + status + .health() + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|(id, _)| health_checks.contains(id)) + .collect() + } else { + Default::default() + }; + results.push(CheckDependenciesResult { + package_id, + is_installed, + is_running, + config_satisfied: dependency_info.config_satisfied, + health_checks, + version, + }); + } + Ok(results) +} diff --git a/core/startos/src/service/effects/health.rs b/core/startos/src/service/effects/health.rs new file mode 100644 index 000000000..c8ef8fc4e --- /dev/null +++ b/core/startos/src/service/effects/health.rs @@ -0,0 +1,46 @@ +use models::HealthCheckId; + +use crate::service::effects::prelude::*; +use crate::status::health_check::HealthCheckResult; +use crate::status::MainStatus; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SetHealth { + id: HealthCheckId, + #[serde(flatten)] + result: HealthCheckResult, +} +pub async fn set_health( + context: EffectContext, + SetHealth { id, result }: SetHealth, +) -> Result<(), Error> { + let context = context.deref()?; + + let package_id = &context.seed.id; + context + .seed + .ctx + .db + .mutate(move |db| { + db.as_public_mut() + .as_package_data_mut() + .as_idx_mut(package_id) + .or_not_found(package_id)? + .as_status_mut() + .as_main_mut() + .mutate(|main| { + match main { + &mut MainStatus::Running { ref mut health, .. } + | &mut MainStatus::BackingUp { ref mut health, .. } => { + health.insert(id, result); + } + _ => (), + } + Ok(()) + }) + }) + .await?; + Ok(()) +} diff --git a/core/startos/src/service/effects/image.rs b/core/startos/src/service/effects/image.rs new file mode 100644 index 000000000..69c516ce5 --- /dev/null +++ b/core/startos/src/service/effects/image.rs @@ -0,0 +1,163 @@ +use std::ffi::OsString; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; + +use models::ImageId; +use rpc_toolkit::Context; +use tokio::process::Command; + +use crate::disk::mount::filesystem::overlayfs::OverlayGuard; +use crate::rpc_continuations::Guid; +use crate::service::effects::prelude::*; +use crate::util::Invoke; + +#[derive(Debug, Clone, Serialize, Deserialize, Parser)] +pub struct ChrootParams { + #[arg(short = 'e', long = "env")] + env: Option, + #[arg(short = 'w', long = "workdir")] + workdir: Option, + #[arg(short = 'u', long = "user")] + user: Option, + path: PathBuf, + command: OsString, + args: Vec, +} +pub fn chroot( + _: C, + ChrootParams { + env, + workdir, + user, + path, + command, + args, + }: ChrootParams, +) -> Result<(), Error> { + let mut cmd = std::process::Command::new(command); + if let Some(env) = env { + for (k, v) in std::fs::read_to_string(env)? + .lines() + .map(|l| l.trim()) + .filter_map(|l| l.split_once("=")) + { + cmd.env(k, v); + } + } + nix::unistd::setsid().ok(); // https://stackoverflow.com/questions/25701333/os-setsid-operation-not-permitted + std::os::unix::fs::chroot(path)?; + if let Some(uid) = user.as_deref().and_then(|u| u.parse::().ok()) { + cmd.uid(uid); + } else if let Some(user) = user { + let (uid, gid) = std::fs::read_to_string("/etc/passwd")? + .lines() + .find_map(|l| { + let mut split = l.trim().split(":"); + if user != split.next()? { + return None; + } + split.next(); // throw away x + Some((split.next()?.parse().ok()?, split.next()?.parse().ok()?)) + // uid gid + }) + .or_not_found(lazy_format!("{user} in /etc/passwd"))?; + cmd.uid(uid); + cmd.gid(gid); + }; + if let Some(workdir) = workdir { + cmd.current_dir(workdir); + } + cmd.args(args); + Err(cmd.exec().into()) +} + +#[derive(Debug, Deserialize, Serialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct DestroyOverlayedImageParams { + guid: Guid, +} +#[instrument(skip_all)] +pub async fn destroy_overlayed_image( + context: EffectContext, + DestroyOverlayedImageParams { guid }: DestroyOverlayedImageParams, +) -> Result<(), Error> { + let context = context.deref()?; + if context + .seed + .persistent_container + .overlays + .lock() + .await + .remove(&guid) + .is_none() + { + tracing::warn!("Could not find a guard to remove on the destroy overlayed image; assumming that it already is removed and will be skipping"); + } + Ok(()) +} + +#[derive(Debug, Deserialize, Serialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct CreateOverlayedImageParams { + image_id: ImageId, +} +#[instrument(skip_all)] +pub async fn create_overlayed_image( + context: EffectContext, + CreateOverlayedImageParams { image_id }: CreateOverlayedImageParams, +) -> Result<(PathBuf, Guid), Error> { + let context = context.deref()?; + if let Some(image) = context + .seed + .persistent_container + .images + .get(&image_id) + .cloned() + { + let guid = Guid::new(); + let rootfs_dir = context + .seed + .persistent_container + .lxc_container + .get() + .ok_or_else(|| { + Error::new( + eyre!("PersistentContainer has been destroyed"), + ErrorKind::Incoherent, + ) + })? + .rootfs_dir(); + let mountpoint = rootfs_dir + .join("media/startos/overlays") + .join(guid.as_ref()); + tokio::fs::create_dir_all(&mountpoint).await?; + let container_mountpoint = Path::new("/").join( + mountpoint + .strip_prefix(rootfs_dir) + .with_kind(ErrorKind::Incoherent)?, + ); + tracing::info!("Mounting overlay {guid} for {image_id}"); + let guard = OverlayGuard::mount(image, &mountpoint).await?; + Command::new("chown") + .arg("100000:100000") + .arg(&mountpoint) + .invoke(ErrorKind::Filesystem) + .await?; + tracing::info!("Mounted overlay {guid} for {image_id}"); + context + .seed + .persistent_container + .overlays + .lock() + .await + .insert(guid.clone(), guard); + Ok((container_mountpoint, guid)) + } else { + Err(Error::new( + eyre!("image {image_id} not found in s9pk"), + ErrorKind::NotFound, + )) + } +} diff --git a/core/startos/src/service/effects/mod.rs b/core/startos/src/service/effects/mod.rs new file mode 100644 index 000000000..91a12a4d1 --- /dev/null +++ b/core/startos/src/service/effects/mod.rs @@ -0,0 +1,174 @@ +use rpc_toolkit::{from_fn, from_fn_async, Context, HandlerExt, ParentHandler}; + +use crate::echo; +use crate::prelude::*; +use crate::service::cli::ContainerCliContext; +use crate::service::effects::context::EffectContext; + +mod action; +pub mod callbacks; +mod config; +pub mod context; +mod control; +mod dependency; +mod health; +mod image; +mod net; +mod prelude; +mod store; +mod system; + +pub fn handler() -> ParentHandler { + ParentHandler::new() + .subcommand("gitInfo", from_fn(|_: C| crate::version::git_info())) + .subcommand( + "echo", + from_fn(echo::).with_call_remote::(), + ) + // action + .subcommand( + "executeAction", + from_fn_async(action::execute_action).no_cli(), + ) + .subcommand( + "exportAction", + from_fn_async(action::export_action).no_cli(), + ) + .subcommand( + "clearActions", + from_fn_async(action::clear_actions).no_cli(), + ) + // callbacks + .subcommand( + "clearCallbacks", + from_fn(callbacks::clear_callbacks).no_cli(), + ) + // config + .subcommand( + "getConfigured", + from_fn_async(config::get_configured).no_cli(), + ) + .subcommand( + "setConfigured", + from_fn_async(config::set_configured) + .no_display() + .with_call_remote::(), + ) + // control + .subcommand( + "restart", + from_fn_async(control::restart) + .no_display() + .with_call_remote::(), + ) + .subcommand( + "shutdown", + from_fn_async(control::shutdown) + .no_display() + .with_call_remote::(), + ) + .subcommand( + "setMainStatus", + from_fn_async(control::set_main_status) + .no_display() + .with_call_remote::(), + ) + // dependency + .subcommand( + "setDependencies", + from_fn_async(dependency::set_dependencies) + .no_display() + .with_call_remote::(), + ) + .subcommand( + "getDependencies", + from_fn_async(dependency::get_dependencies) + .no_display() + .with_call_remote::(), + ) + .subcommand( + "checkDependencies", + from_fn_async(dependency::check_dependencies) + .no_display() + .with_call_remote::(), + ) + .subcommand("mount", from_fn_async(dependency::mount).no_cli()) + .subcommand( + "getInstalledPackages", + from_fn_async(dependency::get_installed_packages).no_cli(), + ) + .subcommand( + "exposeForDependents", + from_fn_async(dependency::expose_for_dependents).no_cli(), + ) + // health + .subcommand("setHealth", from_fn_async(health::set_health).no_cli()) + // image + .subcommand( + "chroot", + from_fn(image::chroot::).no_display(), + ) + .subcommand( + "createOverlayedImage", + from_fn_async(image::create_overlayed_image) + .with_custom_display_fn(|_, (path, _)| Ok(println!("{}", path.display()))) + .with_call_remote::(), + ) + .subcommand( + "destroyOverlayedImage", + from_fn_async(image::destroy_overlayed_image).no_cli(), + ) + // net + .subcommand("bind", from_fn_async(net::bind::bind).no_cli()) + .subcommand( + "getServicePortForward", + from_fn_async(net::bind::get_service_port_forward).no_cli(), + ) + .subcommand( + "clearBindings", + from_fn_async(net::bind::clear_bindings).no_cli(), + ) + .subcommand( + "getHostInfo", + from_fn_async(net::host::get_host_info).no_cli(), + ) + .subcommand( + "getPrimaryUrl", + from_fn_async(net::host::get_primary_url).no_cli(), + ) + .subcommand( + "getContainerIp", + from_fn_async(net::info::get_container_ip).no_cli(), + ) + .subcommand( + "exportServiceInterface", + from_fn_async(net::interface::export_service_interface).no_cli(), + ) + .subcommand( + "getServiceInterface", + from_fn_async(net::interface::get_service_interface).no_cli(), + ) + .subcommand( + "listServiceInterfaces", + from_fn_async(net::interface::list_service_interfaces).no_cli(), + ) + .subcommand( + "clearServiceInterfaces", + from_fn_async(net::interface::clear_service_interfaces).no_cli(), + ) + .subcommand( + "getSslCertificate", + from_fn_async(net::ssl::get_ssl_certificate).no_cli(), + ) + .subcommand("getSslKey", from_fn_async(net::ssl::get_ssl_key).no_cli()) + // store + .subcommand("getStore", from_fn_async(store::get_store).no_cli()) + .subcommand("setStore", from_fn_async(store::set_store).no_cli()) + // system + .subcommand( + "getSystemSmtp", + from_fn_async(system::get_system_smtp).no_cli(), + ) + + // TODO Callbacks +} diff --git a/core/startos/src/service/effects/net/bind.rs b/core/startos/src/service/effects/net/bind.rs new file mode 100644 index 000000000..ba273323a --- /dev/null +++ b/core/startos/src/service/effects/net/bind.rs @@ -0,0 +1,56 @@ +use models::{HostId, PackageId}; + +use crate::net::host::binding::{BindOptions, LanInfo}; +use crate::net::host::HostKind; +use crate::service::effects::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct BindParams { + kind: HostKind, + id: HostId, + internal_port: u16, + #[serde(flatten)] + options: BindOptions, +} +pub async fn bind( + context: EffectContext, + BindParams { + kind, + id, + internal_port, + options, + }: BindParams, +) -> Result<(), Error> { + let context = context.deref()?; + let mut svc = context.seed.persistent_container.net_service.lock().await; + svc.bind(kind, id, internal_port, options).await +} + +pub async fn clear_bindings(context: EffectContext) -> Result<(), Error> { + let context = context.deref()?; + let mut svc = context.seed.persistent_container.net_service.lock().await; + svc.clear_bindings().await?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct GetServicePortForwardParams { + #[ts(optional)] + package_id: Option, + host_id: HostId, + internal_port: u32, +} +pub async fn get_service_port_forward( + context: EffectContext, + data: GetServicePortForwardParams, +) -> Result { + let internal_port = data.internal_port as u16; + + let context = context.deref()?; + let net_service = context.seed.persistent_container.net_service.lock().await; + net_service.get_lan_port(data.host_id, internal_port) +} diff --git a/core/startos/src/service/effects/net/host.rs b/core/startos/src/service/effects/net/host.rs new file mode 100644 index 000000000..d320e7fe9 --- /dev/null +++ b/core/startos/src/service/effects/net/host.rs @@ -0,0 +1,73 @@ +use models::{HostId, PackageId}; + +use crate::net::host::address::HostAddress; +use crate::net::host::Host; +use crate::service::effects::callbacks::CallbackHandler; +use crate::service::effects::prelude::*; +use crate::service::rpc::CallbackId; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct GetPrimaryUrlParams { + #[ts(optional)] + package_id: Option, + host_id: HostId, + #[ts(optional)] + callback: Option, +} +pub async fn get_primary_url( + context: EffectContext, + GetPrimaryUrlParams { + package_id, + host_id, + callback, + }: GetPrimaryUrlParams, +) -> Result, Error> { + let context = context.deref()?; + let package_id = package_id.unwrap_or_else(|| context.seed.id.clone()); + + Ok(None) // TODO +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct GetHostInfoParams { + host_id: HostId, + #[ts(optional)] + package_id: Option, + #[ts(optional)] + callback: Option, +} +pub async fn get_host_info( + context: EffectContext, + GetHostInfoParams { + host_id, + package_id, + callback, + }: GetHostInfoParams, +) -> Result, Error> { + let context = context.deref()?; + let db = context.seed.ctx.db.peek().await; + let package_id = package_id.unwrap_or_else(|| context.seed.id.clone()); + + let res = db + .as_public() + .as_package_data() + .as_idx(&package_id) + .and_then(|m| m.as_hosts().as_idx(&host_id)) + .map(|m| m.de()) + .transpose()?; + + if let Some(callback) = callback { + let callback = callback.register(&context.seed.persistent_container); + context.seed.ctx.callbacks.add_get_host_info( + package_id, + host_id, + CallbackHandler::new(&context, callback), + ); + } + + Ok(res) +} diff --git a/core/startos/src/service/effects/net/info.rs b/core/startos/src/service/effects/net/info.rs new file mode 100644 index 000000000..c33a1a81e --- /dev/null +++ b/core/startos/src/service/effects/net/info.rs @@ -0,0 +1,9 @@ +use std::net::Ipv4Addr; + +use crate::service::effects::prelude::*; + +pub async fn get_container_ip(context: EffectContext) -> Result { + let context = context.deref()?; + let net_service = context.seed.persistent_container.net_service.lock().await; + Ok(net_service.get_ip()) +} diff --git a/core/startos/src/service/effects/net/interface.rs b/core/startos/src/service/effects/net/interface.rs new file mode 100644 index 000000000..e636e9b57 --- /dev/null +++ b/core/startos/src/service/effects/net/interface.rs @@ -0,0 +1,188 @@ +use std::collections::BTreeMap; + +use imbl::vector; +use models::{PackageId, ServiceInterfaceId}; + +use crate::net::service_interface::{AddressInfo, ServiceInterface, ServiceInterfaceType}; +use crate::service::effects::callbacks::CallbackHandler; +use crate::service::effects::prelude::*; +use crate::service::rpc::CallbackId; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ExportServiceInterfaceParams { + id: ServiceInterfaceId, + name: String, + description: String, + has_primary: bool, + disabled: bool, + masked: bool, + address_info: AddressInfo, + r#type: ServiceInterfaceType, +} +pub async fn export_service_interface( + context: EffectContext, + ExportServiceInterfaceParams { + id, + name, + description, + has_primary, + disabled, + masked, + address_info, + r#type, + }: ExportServiceInterfaceParams, +) -> Result<(), Error> { + let context = context.deref()?; + let package_id = context.seed.id.clone(); + + let service_interface = ServiceInterface { + id: id.clone(), + name, + description, + has_primary, + disabled, + masked, + address_info, + interface_type: r#type, + }; + + context + .seed + .ctx + .db + .mutate(|db| { + db.as_public_mut() + .as_package_data_mut() + .as_idx_mut(&package_id) + .or_not_found(&package_id)? + .as_service_interfaces_mut() + .insert(&id, &service_interface)?; + Ok(()) + }) + .await?; + if let Some(callbacks) = context + .seed + .ctx + .callbacks + .get_service_interface(&(package_id.clone(), id)) + { + callbacks.call(vector![]).await?; + } + if let Some(callbacks) = context + .seed + .ctx + .callbacks + .list_service_interfaces(&package_id) + { + callbacks.call(vector![]).await?; + } + + Ok(()) +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct GetServiceInterfaceParams { + #[ts(optional)] + package_id: Option, + service_interface_id: ServiceInterfaceId, + #[ts(optional)] + callback: Option, +} +pub async fn get_service_interface( + context: EffectContext, + GetServiceInterfaceParams { + package_id, + service_interface_id, + callback, + }: GetServiceInterfaceParams, +) -> Result, Error> { + let context = context.deref()?; + let package_id = package_id.unwrap_or_else(|| context.seed.id.clone()); + let db = context.seed.ctx.db.peek().await; + + let interface = db + .as_public() + .as_package_data() + .as_idx(&package_id) + .and_then(|m| m.as_service_interfaces().as_idx(&service_interface_id)) + .map(|m| m.de()) + .transpose()?; + + if let Some(callback) = callback { + let callback = callback.register(&context.seed.persistent_container); + context.seed.ctx.callbacks.add_get_service_interface( + package_id, + service_interface_id, + CallbackHandler::new(&context, callback), + ); + } + + Ok(interface) +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct ListServiceInterfacesParams { + #[ts(optional)] + package_id: Option, + #[ts(optional)] + callback: Option, +} +pub async fn list_service_interfaces( + context: EffectContext, + ListServiceInterfacesParams { + package_id, + callback, + }: ListServiceInterfacesParams, +) -> Result, Error> { + let context = context.deref()?; + let package_id = package_id.unwrap_or_else(|| context.seed.id.clone()); + + let res = context + .seed + .ctx + .db + .peek() + .await + .into_public() + .into_package_data() + .into_idx(&package_id) + .map(|m| m.into_service_interfaces().de()) + .transpose()? + .unwrap_or_default(); + + if let Some(callback) = callback { + let callback = callback.register(&context.seed.persistent_container); + context + .seed + .ctx + .callbacks + .add_list_service_interfaces(package_id, CallbackHandler::new(&context, callback)); + } + + Ok(res) +} + +pub async fn clear_service_interfaces(context: EffectContext) -> Result<(), Error> { + let context = context.deref()?; + let package_id = context.seed.id.clone(); + + context + .seed + .ctx + .db + .mutate(|db| { + db.as_public_mut() + .as_package_data_mut() + .as_idx_mut(&package_id) + .or_not_found(&package_id)? + .as_service_interfaces_mut() + .ser(&Default::default()) + }) + .await +} diff --git a/core/startos/src/service/effects/net/mod.rs b/core/startos/src/service/effects/net/mod.rs new file mode 100644 index 000000000..cf13451a6 --- /dev/null +++ b/core/startos/src/service/effects/net/mod.rs @@ -0,0 +1,5 @@ +pub mod bind; +pub mod host; +pub mod info; +pub mod interface; +pub mod ssl; diff --git a/core/startos/src/service/effects/net/ssl.rs b/core/startos/src/service/effects/net/ssl.rs new file mode 100644 index 000000000..d37a2d241 --- /dev/null +++ b/core/startos/src/service/effects/net/ssl.rs @@ -0,0 +1,169 @@ +use std::collections::BTreeSet; + +use imbl_value::InternedString; +use itertools::Itertools; +use openssl::pkey::{PKey, Private}; + +use crate::service::effects::callbacks::CallbackHandler; +use crate::service::effects::prelude::*; +use crate::service::rpc::CallbackId; +use crate::util::serde::Pem; + +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, TS, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum Algorithm { + Ecdsa, + Ed25519, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct GetSslCertificateParams { + #[ts(type = "string[]")] + hostnames: BTreeSet, + #[ts(optional)] + algorithm: Option, //"ecdsa" | "ed25519" + #[ts(optional)] + callback: Option, +} +pub async fn get_ssl_certificate( + ctx: EffectContext, + GetSslCertificateParams { + hostnames, + algorithm, + callback, + }: GetSslCertificateParams, +) -> Result, Error> { + let context = ctx.deref()?; + let algorithm = algorithm.unwrap_or(Algorithm::Ecdsa); + + let cert = context + .seed + .ctx + .db + .mutate(|db| { + let errfn = |h: &str| Error::new(eyre!("unknown hostname: {h}"), ErrorKind::NotFound); + let entries = db.as_public().as_package_data().as_entries()?; + let packages = entries.iter().map(|(k, _)| k).collect::>(); + let allowed_hostnames = entries + .iter() + .map(|(_, m)| m.as_hosts().as_entries()) + .flatten_ok() + .map_ok(|(_, m)| m.as_addresses().de()) + .map(|a| a.and_then(|a| a)) + .flatten_ok() + .map_ok(|a| InternedString::from_display(&a)) + .try_collect::<_, BTreeSet<_>, _>()?; + for hostname in &hostnames { + if let Some(internal) = hostname + .strip_suffix(".embassy") + .or_else(|| hostname.strip_suffix(".startos")) + { + if !packages.contains(internal) { + return Err(errfn(&*hostname)); + } + } else { + if !allowed_hostnames.contains(hostname) { + return Err(errfn(&*hostname)); + } + } + } + db.as_private_mut() + .as_key_store_mut() + .as_local_certs_mut() + .cert_for(&hostnames) + }) + .await?; + let fullchain = match algorithm { + Algorithm::Ecdsa => cert.fullchain_nistp256(), + Algorithm::Ed25519 => cert.fullchain_ed25519(), + }; + + let res = fullchain + .into_iter() + .map(|c| c.to_pem()) + .map_ok(String::from_utf8) + .map(|a| Ok::<_, Error>(a??)) + .try_collect()?; + + if let Some(callback) = callback { + let callback = callback.register(&context.seed.persistent_container); + context.seed.ctx.callbacks.add_get_ssl_certificate( + ctx, + hostnames, + cert, + algorithm, + CallbackHandler::new(&context, callback), + ); + } + + Ok(res) +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct GetSslKeyParams { + #[ts(type = "string[]")] + hostnames: BTreeSet, + #[ts(optional)] + algorithm: Option, //"ecdsa" | "ed25519" +} +pub async fn get_ssl_key( + context: EffectContext, + GetSslKeyParams { + hostnames, + algorithm, + }: GetSslKeyParams, +) -> Result>, Error> { + let context = context.deref()?; + let package_id = &context.seed.id; + let algorithm = algorithm.unwrap_or(Algorithm::Ecdsa); + + let cert = context + .seed + .ctx + .db + .mutate(|db| { + let errfn = |h: &str| Error::new(eyre!("unknown hostname: {h}"), ErrorKind::NotFound); + let allowed_hostnames = db + .as_public() + .as_package_data() + .as_idx(package_id) + .into_iter() + .map(|m| m.as_hosts().as_entries()) + .flatten_ok() + .map_ok(|(_, m)| m.as_addresses().de()) + .map(|a| a.and_then(|a| a)) + .flatten_ok() + .map_ok(|a| InternedString::from_display(&a)) + .try_collect::<_, BTreeSet<_>, _>()?; + for hostname in &hostnames { + if let Some(internal) = hostname + .strip_suffix(".embassy") + .or_else(|| hostname.strip_suffix(".startos")) + { + if internal != &**package_id { + return Err(errfn(&*hostname)); + } + } else { + if !allowed_hostnames.contains(hostname) { + return Err(errfn(&*hostname)); + } + } + } + db.as_private_mut() + .as_key_store_mut() + .as_local_certs_mut() + .cert_for(&hostnames) + }) + .await?; + let key = match algorithm { + Algorithm::Ecdsa => cert.leaf.keys.nistp256, + Algorithm::Ed25519 => cert.leaf.keys.ed25519, + }; + + Ok(Pem(key)) +} diff --git a/core/startos/src/service/effects/prelude.rs b/core/startos/src/service/effects/prelude.rs new file mode 100644 index 000000000..2dc848c0c --- /dev/null +++ b/core/startos/src/service/effects/prelude.rs @@ -0,0 +1,16 @@ +pub use clap::Parser; +pub use serde::{Deserialize, Serialize}; +pub use ts_rs::TS; + +pub use crate::prelude::*; +use crate::rpc_continuations::Guid; +pub(super) use crate::service::effects::context::EffectContext; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ProcedureId { + #[serde(default)] + #[arg(default_value_t, long)] + pub procedure_id: Guid, +} diff --git a/core/startos/src/service/effects/store.rs b/core/startos/src/service/effects/store.rs new file mode 100644 index 000000000..ab4484ab6 --- /dev/null +++ b/core/startos/src/service/effects/store.rs @@ -0,0 +1,93 @@ +use imbl::vector; +use imbl_value::json; +use models::PackageId; +use patch_db::json_ptr::JsonPointer; + +use crate::service::effects::callbacks::CallbackHandler; +use crate::service::effects::prelude::*; +use crate::service::rpc::CallbackId; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct GetStoreParams { + #[ts(optional)] + package_id: Option, + #[ts(type = "string")] + path: JsonPointer, + #[ts(optional)] + callback: Option, +} +pub async fn get_store( + context: EffectContext, + GetStoreParams { + package_id, + path, + callback, + }: GetStoreParams, +) -> Result { + let context = context.deref()?; + let peeked = context.seed.ctx.db.peek().await; + let package_id = package_id.unwrap_or(context.seed.id.clone()); + let value = peeked + .as_private() + .as_package_stores() + .as_idx(&package_id) + .or_not_found(&package_id)? + .de()?; + + if let Some(callback) = callback { + let callback = callback.register(&context.seed.persistent_container); + context.seed.ctx.callbacks.add_get_store( + package_id, + path.clone(), + CallbackHandler::new(&context, callback), + ); + } + + Ok(path + .get(&value) + .ok_or_else(|| Error::new(eyre!("Did not find value at path"), ErrorKind::NotFound))? + .clone()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SetStoreParams { + #[ts(type = "any")] + value: Value, + #[ts(type = "string")] + path: JsonPointer, +} +pub async fn set_store( + context: EffectContext, + SetStoreParams { value, path }: SetStoreParams, +) -> Result<(), Error> { + let context = context.deref()?; + let package_id = &context.seed.id; + context + .seed + .ctx + .db + .mutate(|db| { + let model = db + .as_private_mut() + .as_package_stores_mut() + .upsert(package_id, || Ok(json!({})))?; + let mut model_value = model.de()?; + if model_value.is_null() { + model_value = json!({}); + } + path.set(&mut model_value, value, true) + .with_kind(ErrorKind::ParseDbField)?; + model.ser(&model_value) + }) + .await?; + + if let Some(callbacks) = context.seed.ctx.callbacks.get_store(package_id, &path) { + callbacks.call(vector![]).await?; + } + + Ok(()) +} diff --git a/core/startos/src/service/effects/system.rs b/core/startos/src/service/effects/system.rs new file mode 100644 index 000000000..abf0a33c6 --- /dev/null +++ b/core/startos/src/service/effects/system.rs @@ -0,0 +1,39 @@ +use crate::service::effects::callbacks::CallbackHandler; +use crate::service::effects::prelude::*; +use crate::service::rpc::CallbackId; +use crate::system::SmtpValue; + +#[derive(Debug, Clone, Serialize, Deserialize, TS, Parser)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct GetSystemSmtpParams { + #[arg(skip)] + callback: Option, +} +pub async fn get_system_smtp( + context: EffectContext, + GetSystemSmtpParams { callback }: GetSystemSmtpParams, +) -> Result, Error> { + let context = context.deref()?; + let res = context + .seed + .ctx + .db + .peek() + .await + .into_public() + .into_server_info() + .into_smtp() + .de()?; + + if let Some(callback) = callback { + let callback = callback.register(&context.seed.persistent_container); + context + .seed + .ctx + .callbacks + .add_get_system_smtp(CallbackHandler::new(&context, callback)); + } + + Ok(res) +} diff --git a/core/startos/src/service/mod.rs b/core/startos/src/service/mod.rs index 9103b70a1..efaa28d4e 100644 --- a/core/startos/src/service/mod.rs +++ b/core/startos/src/service/mod.rs @@ -27,10 +27,7 @@ use crate::progress::{NamedProgress, Progress}; use crate::rpc_continuations::Guid; use crate::s9pk::S9pk; use crate::service::service_map::InstallProgressHandles; -use crate::service::transition::TransitionKind; use crate::status::health_check::HealthCheckResult; -use crate::status::MainStatus; -use crate::util::actor::background::BackgroundJobQueue; use crate::util::actor::concurrent::ConcurrentActor; use crate::util::actor::Actor; use crate::util::io::create_file; @@ -43,11 +40,11 @@ pub mod cli; mod config; mod control; mod dependencies; +pub mod effects; pub mod persistent_container; mod properties; mod rpc; mod service_actor; -pub mod service_effect_handler; pub mod service_map; mod start_stop; mod transition; diff --git a/core/startos/src/service/persistent_container.rs b/core/startos/src/service/persistent_container.rs index e8c504a92..62d3b0975 100644 --- a/core/startos/src/service/persistent_container.rs +++ b/core/startos/src/service/persistent_container.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::sync::{Arc, Weak}; use std::time::Duration; @@ -6,6 +6,7 @@ use std::time::Duration; use futures::future::ready; use futures::{Future, FutureExt}; use helpers::NonDetachingJoinHandle; +use imbl::Vector; use models::{ImageId, ProcedureName, VolumeId}; use rpc_toolkit::{Empty, Server, ShutdownHandle}; use serde::de::DeserializeOwned; @@ -13,8 +14,6 @@ use tokio::process::Command; use tokio::sync::{oneshot, watch, Mutex, OnceCell}; use tracing::instrument; -use super::service_effect_handler::{service_effect_handler, EffectContext}; -use super::transition::{TransitionKind, TransitionState}; use crate::context::RpcContext; use crate::disk::mount::filesystem::bind::Bind; use crate::disk::mount::filesystem::idmapped::IdMapped; @@ -28,7 +27,11 @@ use crate::prelude::*; use crate::rpc_continuations::Guid; use crate::s9pk::merkle_archive::source::FileSource; use crate::s9pk::S9pk; +use crate::service::effects::context::EffectContext; +use crate::service::effects::handler; +use crate::service::rpc::{CallbackHandle, CallbackId, CallbackParams}; use crate::service::start_stop::StartStop; +use crate::service::transition::{TransitionKind, TransitionState}; use crate::service::{rpc, RunningStatus, Service}; use crate::util::io::create_file; use crate::util::rpc_client::UnixRpcClient; @@ -42,6 +45,8 @@ const RPC_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub struct ServiceState { // 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, + // This tracks references to callbacks registered by the running service: + pub(super) callbacks: BTreeSet>, /// 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) @@ -61,6 +66,7 @@ impl ServiceState { pub fn new(desired_state: StartStop) -> Self { Self { running_status: Default::default(), + callbacks: Default::default(), temp_desired_state: Default::default(), transition_state: Default::default(), desired_state, @@ -308,10 +314,7 @@ impl PersistentContainer { #[instrument(skip_all)] pub async fn init(&self, seed: Weak) -> Result<(), Error> { let socket_server_context = EffectContext::new(seed); - let server = Server::new( - move || ready(Ok(socket_server_context.clone())), - service_effect_handler(), - ); + let server = Server::new(move || ready(Ok(socket_server_context.clone())), handler()); let path = self .lxc_container .get() @@ -430,21 +433,13 @@ impl PersistentContainer { #[instrument(skip_all)] pub async fn start(&self) -> Result<(), Error> { - self.execute( - Guid::new(), - ProcedureName::StartMain, - Value::Null, - Some(Duration::from_secs(5)), // TODO - ) - .await?; + self.rpc_client.request(rpc::Start, Empty {}).await?; Ok(()) } #[instrument(skip_all)] pub async fn stop(&self) -> Result<(), Error> { - let timeout: Option = self - .execute(Guid::new(), ProcedureName::StopMain, Value::Null, None) - .await?; + self.rpc_client.request(rpc::Stop, Empty {}).await?; Ok(()) } @@ -480,6 +475,19 @@ impl PersistentContainer { .and_then(from_value) } + #[instrument(skip_all)] + pub async fn callback(&self, handle: CallbackHandle, args: Vector) -> Result<(), Error> { + let mut params = None; + self.state.send_if_modified(|s| { + params = handle.params(&mut s.callbacks, args); + params.is_some() + }); + if let Some(params) = params { + self._callback(params).await?; + } + Ok(()) + } + #[instrument(skip_all)] async fn _execute( &self, @@ -523,6 +531,12 @@ impl PersistentContainer { fut.await? }) } + + #[instrument(skip_all)] + async fn _callback(&self, params: CallbackParams) -> Result<(), Error> { + self.rpc_client.notify(rpc::Callback, params).await?; + Ok(()) + } } impl Drop for PersistentContainer { diff --git a/core/startos/src/service/rpc.rs b/core/startos/src/service/rpc.rs index eff44b2cf..25d8fb067 100644 --- a/core/startos/src/service/rpc.rs +++ b/core/startos/src/service/rpc.rs @@ -1,5 +1,8 @@ +use std::collections::BTreeSet; +use std::sync::{Arc, Weak}; use std::time::Duration; +use imbl::Vector; use imbl_value::Value; use models::ProcedureName; use rpc_toolkit::yajrc::RpcMethod; @@ -8,6 +11,8 @@ use ts_rs::TS; use crate::prelude::*; use crate::rpc_continuations::Guid; +use crate::service::persistent_container::PersistentContainer; +use crate::util::Never; #[derive(Clone)] pub struct Init; @@ -27,6 +32,42 @@ impl serde::Serialize for Init { } } +#[derive(Clone)] +pub struct Start; +impl RpcMethod for Start { + type Params = Empty; + type Response = (); + fn as_str<'a>(&'a self) -> &'a str { + "start" + } +} +impl serde::Serialize for Start { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[derive(Clone)] +pub struct Stop; +impl RpcMethod for Stop { + type Params = Empty; + type Response = (); + fn as_str<'a>(&'a self) -> &'a str { + "stop" + } +} +impl serde::Serialize for Stop { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + #[derive(Clone)] pub struct Exit; impl RpcMethod for Exit { @@ -104,3 +145,74 @@ impl serde::Serialize for Sandbox { serializer.serialize_str(self.as_str()) } } + +#[derive( + Clone, Copy, Debug, serde::Deserialize, serde::Serialize, TS, PartialEq, Eq, PartialOrd, Ord, +)] +#[ts(type = "number")] +pub struct CallbackId(u64); +impl CallbackId { + pub fn register(self, container: &PersistentContainer) -> CallbackHandle { + let this = Arc::new(self); + let res = Arc::downgrade(&this); + container + .state + .send_if_modified(|s| s.callbacks.insert(this)); + CallbackHandle(res) + } +} + +pub struct CallbackHandle(Weak); +impl CallbackHandle { + pub fn is_active(&self) -> bool { + self.0.strong_count() > 0 + } + pub fn params( + self, + registered: &mut BTreeSet>, + args: Vector, + ) -> Option { + if let Some(id) = self.0.upgrade() { + if let Some(strong) = registered.get(&id) { + if Arc::ptr_eq(strong, &id) { + registered.remove(&id); + return Some(CallbackParams::new(&*id, args)); + } + } + } + None + } + pub fn take(&mut self) -> Self { + Self(std::mem::take(&mut self.0)) + } +} + +#[derive(Clone, serde::Deserialize, serde::Serialize, TS)] +pub struct CallbackParams { + id: u64, + #[ts(type = "any[]")] + args: Vector, +} +impl CallbackParams { + fn new(id: &CallbackId, args: Vector) -> Self { + Self { id: id.0, args } + } +} + +#[derive(Clone)] +pub struct Callback; +impl RpcMethod for Callback { + type Params = CallbackParams; + type Response = Never; + fn as_str<'a>(&'a self) -> &'a str { + "callback" + } +} +impl serde::Serialize for Callback { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} diff --git a/core/startos/src/service/service_effect_handler.rs b/core/startos/src/service/service_effect_handler.rs deleted file mode 100644 index 79ca4dc42..000000000 --- a/core/startos/src/service/service_effect_handler.rs +++ /dev/null @@ -1,1431 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::ffi::OsString; -use std::net::Ipv4Addr; -use std::os::unix::process::CommandExt; -use std::path::{Path, PathBuf}; -use std::str::FromStr; -use std::sync::{Arc, Weak}; - -use clap::builder::ValueParserFactory; -use clap::Parser; -use exver::VersionRange; -use imbl_value::json; -use itertools::Itertools; -use models::{ - ActionId, DataUrl, HealthCheckId, HostId, ImageId, PackageId, ServiceInterfaceId, VolumeId, -}; -use patch_db::json_ptr::JsonPointer; -use rpc_toolkit::{from_fn, from_fn_async, Context, Empty, HandlerExt, ParentHandler}; -use serde::{Deserialize, Serialize}; -use tokio::process::Command; -use ts_rs::TS; -use url::Url; - -use crate::db::model::package::{ - ActionMetadata, CurrentDependencies, CurrentDependencyInfo, CurrentDependencyKind, - ManifestPreference, -}; -use crate::disk::mount::filesystem::overlayfs::OverlayGuard; -use crate::echo; -use crate::net::host::address::HostAddress; -use crate::net::host::binding::{BindOptions, LanInfo}; -use crate::net::host::{Host, HostKind}; -use crate::net::service_interface::{AddressInfo, ServiceInterface, ServiceInterfaceType}; -use crate::prelude::*; -use crate::rpc_continuations::Guid; -use crate::s9pk::merkle_archive::source::http::HttpSource; -use crate::s9pk::rpc::SKIP_ENV; -use crate::s9pk::S9pk; -use crate::service::cli::ContainerCliContext; -use crate::service::Service; -use crate::status::health_check::HealthCheckResult; -use crate::status::MainStatus; -use crate::util::clap::FromStrParser; -use crate::util::Invoke; - -#[derive(Clone)] -pub(super) struct EffectContext(Weak); -impl EffectContext { - pub fn new(service: Weak) -> Self { - Self(service) - } -} -impl Context for EffectContext {} -impl EffectContext { - fn deref(&self) -> Result, Error> { - if let Some(seed) = Weak::upgrade(&self.0) { - Ok(seed) - } else { - Err(Error::new( - eyre!("Service has already been destroyed"), - ErrorKind::InvalidRequest, - )) - } - } -} - -pub fn service_effect_handler() -> ParentHandler { - ParentHandler::new() - .subcommand("gitInfo", from_fn(|_: C| crate::version::git_info())) - .subcommand( - "echo", - from_fn(echo::).with_call_remote::(), - ) - .subcommand( - "chroot", - from_fn(chroot::).no_display(), - ) - .subcommand("exists", from_fn_async(exists).no_cli()) - .subcommand("executeAction", from_fn_async(execute_action).no_cli()) - .subcommand("getConfigured", from_fn_async(get_configured).no_cli()) - .subcommand( - "stopped", - from_fn_async(stopped) - .no_display() - .with_call_remote::(), - ) - .subcommand( - "running", - from_fn_async(running) - .no_display() - .with_call_remote::(), - ) - .subcommand( - "restart", - from_fn_async(restart) - .no_display() - .with_call_remote::(), - ) - .subcommand( - "shutdown", - from_fn_async(shutdown) - .no_display() - .with_call_remote::(), - ) - .subcommand( - "setConfigured", - from_fn_async(set_configured) - .no_display() - .with_call_remote::(), - ) - .subcommand( - "setMainStatus", - from_fn_async(set_main_status).with_call_remote::(), - ) - .subcommand("setHealth", from_fn_async(set_health).no_cli()) - .subcommand("getStore", from_fn_async(get_store).no_cli()) - .subcommand("setStore", from_fn_async(set_store).no_cli()) - .subcommand( - "exposeForDependents", - from_fn_async(expose_for_dependents).no_cli(), - ) - .subcommand( - "createOverlayedImage", - from_fn_async(create_overlayed_image) - .with_custom_display_fn(|_, (path, _)| Ok(println!("{}", path.display()))) - .with_call_remote::(), - ) - .subcommand( - "destroyOverlayedImage", - from_fn_async(destroy_overlayed_image).no_cli(), - ) - .subcommand( - "getSslCertificate", - from_fn_async(get_ssl_certificate).no_cli(), - ) - .subcommand("getSslKey", from_fn_async(get_ssl_key).no_cli()) - .subcommand( - "getServiceInterface", - from_fn_async(get_service_interface).no_cli(), - ) - .subcommand("clearBindings", from_fn_async(clear_bindings).no_cli()) - .subcommand("bind", from_fn_async(bind).no_cli()) - .subcommand("getHostInfo", from_fn_async(get_host_info).no_cli()) - .subcommand( - "setDependencies", - from_fn_async(set_dependencies) - .no_display() - .with_call_remote::(), - ) - .subcommand( - "getDependencies", - from_fn_async(get_dependencies) - .no_display() - .with_call_remote::(), - ) - .subcommand( - "checkDependencies", - from_fn_async(check_dependencies) - .no_display() - .with_call_remote::(), - ) - .subcommand("setSystemSmtp", from_fn_async(set_system_smtp).no_cli()) - .subcommand("getSystemSmtp", from_fn_async(get_system_smtp).no_cli()) - .subcommand("getContainerIp", from_fn_async(get_container_ip).no_cli()) - .subcommand( - "getServicePortForward", - from_fn_async(get_service_port_forward).no_cli(), - ) - .subcommand( - "clearServiceInterfaces", - from_fn_async(clear_network_interfaces).no_cli(), - ) - .subcommand( - "exportServiceInterface", - from_fn_async(export_service_interface).no_cli(), - ) - .subcommand("getPrimaryUrl", from_fn_async(get_primary_url).no_cli()) - .subcommand( - "listServiceInterfaces", - from_fn_async(list_service_interfaces).no_cli(), - ) - .subcommand("removeAddress", from_fn_async(remove_address).no_cli()) - .subcommand("exportAction", from_fn_async(export_action).no_cli()) - .subcommand("removeAction", from_fn_async(remove_action).no_cli()) - .subcommand("mount", from_fn_async(mount).no_cli()) - - // TODO Callbacks -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct GetSystemSmtpParams { - callback: Callback, -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct SetSystemSmtpParams { - smtp: String, -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct GetServicePortForwardParams { - #[ts(type = "string | null")] - package_id: Option, - internal_port: u32, - host_id: HostId, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct ExportServiceInterfaceParams { - id: ServiceInterfaceId, - name: String, - description: String, - has_primary: bool, - disabled: bool, - masked: bool, - address_info: AddressInfo, - r#type: ServiceInterfaceType, -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct GetPrimaryUrlParams { - #[ts(type = "string | null")] - package_id: Option, - service_interface_id: ServiceInterfaceId, - callback: Callback, -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct ListServiceInterfacesParams { - #[ts(type = "string | null")] - package_id: Option, - callback: Callback, -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct RemoveAddressParams { - id: ServiceInterfaceId, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct ExportActionParams { - #[ts(type = "string")] - id: ActionId, - metadata: ActionMetadata, -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct RemoveActionParams { - #[ts(type = "string")] - id: ActionId, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct MountTarget { - #[ts(type = "string")] - package_id: PackageId, - #[ts(type = "string")] - volume_id: VolumeId, - subpath: Option, - readonly: bool, -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct MountParams { - location: String, - target: MountTarget, -} -async fn set_system_smtp(context: EffectContext, data: SetSystemSmtpParams) -> Result<(), Error> { - let context = context.deref()?; - context - .seed - .ctx - .db - .mutate(|db| { - let model = db.as_public_mut().as_server_info_mut().as_smtp_mut(); - model.ser(&mut Some(data.smtp)) - }) - .await -} -async fn get_system_smtp( - context: EffectContext, - data: GetSystemSmtpParams, -) -> Result { - let context = context.deref()?; - let res = context - .seed - .ctx - .db - .peek() - .await - .into_public() - .into_server_info() - .into_smtp() - .de()?; - - match res { - Some(smtp) => Ok(smtp), - None => Err(Error::new( - eyre!("SMTP not found"), - crate::ErrorKind::NotFound, - )), - } -} -async fn get_container_ip(context: EffectContext, _: Empty) -> Result { - let context = context.deref()?; - let net_service = context.seed.persistent_container.net_service.lock().await; - Ok(net_service.get_ip()) -} -async fn get_service_port_forward( - context: EffectContext, - data: GetServicePortForwardParams, -) -> Result { - let internal_port = data.internal_port as u16; - - let context = context.deref()?; - let net_service = context.seed.persistent_container.net_service.lock().await; - net_service.get_ext_port(data.host_id, internal_port) -} -async fn clear_network_interfaces(context: EffectContext, _: Empty) -> Result<(), Error> { - let context = context.deref()?; - let package_id = context.seed.id.clone(); - - context - .seed - .ctx - .db - .mutate(|db| { - let model = db - .as_public_mut() - .as_package_data_mut() - .as_idx_mut(&package_id) - .or_not_found(&package_id)? - .as_service_interfaces_mut(); - let mut new_map = BTreeMap::new(); - model.ser(&mut new_map) - }) - .await -} -async fn export_service_interface( - context: EffectContext, - ExportServiceInterfaceParams { - id, - name, - description, - has_primary, - disabled, - masked, - address_info, - r#type, - }: ExportServiceInterfaceParams, -) -> Result<(), Error> { - let context = context.deref()?; - let package_id = context.seed.id.clone(); - - let service_interface = ServiceInterface { - id: id.clone(), - name, - description, - has_primary, - disabled, - masked, - address_info, - interface_type: r#type, - }; - let svc_interface_with_host_info = service_interface; - - context - .seed - .ctx - .db - .mutate(|db| { - db.as_public_mut() - .as_package_data_mut() - .as_idx_mut(&package_id) - .or_not_found(&package_id)? - .as_service_interfaces_mut() - .insert(&id, &svc_interface_with_host_info)?; - Ok(()) - }) - .await?; - Ok(()) -} -async fn get_primary_url( - context: EffectContext, - GetPrimaryUrlParams { - package_id, - service_interface_id, - callback, - }: GetPrimaryUrlParams, -) -> Result, Error> { - let context = context.deref()?; - let package_id = package_id.unwrap_or_else(|| context.seed.id.clone()); - - Ok(None) // TODO -} -async fn list_service_interfaces( - context: EffectContext, - ListServiceInterfacesParams { - package_id, - callback, - }: ListServiceInterfacesParams, -) -> Result, Error> { - let context = context.deref()?; - let package_id = package_id.unwrap_or_else(|| context.seed.id.clone()); - - context - .seed - .ctx - .db - .peek() - .await - .into_public() - .into_package_data() - .into_idx(&package_id) - .or_not_found(&package_id)? - .into_service_interfaces() - .de() -} -async fn remove_address(context: EffectContext, data: RemoveAddressParams) -> Result<(), Error> { - let context = context.deref()?; - let package_id = context.seed.id.clone(); - - context - .seed - .ctx - .db - .mutate(|db| { - let model = db - .as_public_mut() - .as_package_data_mut() - .as_idx_mut(&package_id) - .or_not_found(&package_id)? - .as_service_interfaces_mut(); - model.remove(&data.id) - }) - .await?; - Ok(()) -} -async fn export_action(context: EffectContext, data: ExportActionParams) -> Result<(), Error> { - let context = context.deref()?; - let package_id = context.seed.id.clone(); - context - .seed - .ctx - .db - .mutate(|db| { - let model = db - .as_public_mut() - .as_package_data_mut() - .as_idx_mut(&package_id) - .or_not_found(&package_id)? - .as_actions_mut(); - let mut value = model.de()?; - value - .insert(data.id, data.metadata) - .map(|_| ()) - .unwrap_or_default(); - model.ser(&value) - }) - .await?; - Ok(()) -} -async fn remove_action(context: EffectContext, data: RemoveActionParams) -> Result<(), Error> { - let context = context.deref()?; - let package_id = context.seed.id.clone(); - context - .seed - .ctx - .db - .mutate(|db| { - let model = db - .as_public_mut() - .as_package_data_mut() - .as_idx_mut(&package_id) - .or_not_found(&package_id)? - .as_actions_mut(); - let mut value = model.de()?; - value.remove(&data.id).map(|_| ()).unwrap_or_default(); - model.ser(&value) - }) - .await?; - Ok(()) -} -async fn mount(context: EffectContext, data: MountParams) -> Result { - // TODO - todo!() -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[ts(export)] -struct Callback(#[ts(type = "() => void")] i64); - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct GetHostInfoParams { - host_id: HostId, - #[ts(type = "string | null")] - package_id: Option, - callback: Callback, -} -async fn get_host_info( - context: EffectContext, - GetHostInfoParams { - callback, - package_id, - host_id, - }: GetHostInfoParams, -) -> Result { - let context = context.deref()?; - let db = context.seed.ctx.db.peek().await; - let package_id = package_id.unwrap_or_else(|| context.seed.id.clone()); - - db.as_public() - .as_package_data() - .as_idx(&package_id) - .or_not_found(&package_id)? - .as_hosts() - .as_idx(&host_id) - .or_not_found(&host_id)? - .de() -} - -async fn clear_bindings(context: EffectContext, _: Empty) -> Result<(), Error> { - let context = context.deref()?; - let mut svc = context.seed.persistent_container.net_service.lock().await; - svc.clear_bindings().await?; - Ok(()) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct BindParams { - kind: HostKind, - id: HostId, - internal_port: u16, - #[serde(flatten)] - options: BindOptions, -} -async fn bind(context: EffectContext, bind_params: Value) -> Result<(), Error> { - let BindParams { - kind, - id, - internal_port, - options, - } = from_value(bind_params)?; - let context = context.deref()?; - let mut svc = context.seed.persistent_container.net_service.lock().await; - svc.bind(kind, id, internal_port, options).await -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct GetServiceInterfaceParams { - #[ts(type = "string | null")] - package_id: Option, - service_interface_id: ServiceInterfaceId, - callback: Callback, -} - -async fn get_service_interface( - context: EffectContext, - GetServiceInterfaceParams { - callback, - package_id, - service_interface_id, - }: GetServiceInterfaceParams, -) -> Result { - let context = context.deref()?; - let package_id = package_id.unwrap_or_else(|| context.seed.id.clone()); - let db = context.seed.ctx.db.peek().await; - - let interface = db - .as_public() - .as_package_data() - .as_idx(&package_id) - .or_not_found(&package_id)? - .as_service_interfaces() - .as_idx(&service_interface_id) - .or_not_found(&service_interface_id)? - .de()?; - Ok(interface) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct ChrootParams { - #[arg(short = 'e', long = "env")] - env: Option, - #[arg(short = 'w', long = "workdir")] - workdir: Option, - #[arg(short = 'u', long = "user")] - user: Option, - path: PathBuf, - #[ts(type = "string")] - command: OsString, - #[ts(type = "string[]")] - args: Vec, -} -fn chroot( - _: C, - ChrootParams { - env, - workdir, - user, - path, - command, - args, - }: ChrootParams, -) -> Result<(), Error> { - let mut cmd = std::process::Command::new(command); - if let Some(env) = env { - for (k, v) in std::fs::read_to_string(env)? - .lines() - .map(|l| l.trim()) - .filter_map(|l| l.split_once("=")) - .filter(|(k, _)| !SKIP_ENV.contains(&k)) - { - cmd.env(k, v); - } - } - nix::unistd::setsid().ok(); // https://stackoverflow.com/questions/25701333/os-setsid-operation-not-permitted - std::os::unix::fs::chroot(path)?; - if let Some(uid) = user.as_deref().and_then(|u| u.parse::().ok()) { - cmd.uid(uid); - } else if let Some(user) = user { - let (uid, gid) = std::fs::read_to_string("/etc/passwd")? - .lines() - .find_map(|l| { - let mut split = l.trim().split(":"); - if user != split.next()? { - return None; - } - split.next(); // throw away x - Some((split.next()?.parse().ok()?, split.next()?.parse().ok()?)) - // uid gid - }) - .or_not_found(lazy_format!("{user} in /etc/passwd"))?; - cmd.uid(uid); - cmd.gid(gid); - }; - if let Some(workdir) = workdir { - cmd.current_dir(workdir); - } - cmd.args(args); - Err(cmd.exec().into()) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -enum Algorithm { - Ecdsa, - Ed25519, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct GetSslCertificateParams { - package_id: Option, - host_id: String, - algorithm: Option, //"ecdsa" | "ed25519" -} - -async fn get_ssl_certificate( - context: EffectContext, - GetSslCertificateParams { - package_id, - algorithm, - host_id, - }: GetSslCertificateParams, -) -> Result { - // TODO - let fake = include_str!("./fake.cert.pem"); - Ok(json!([fake, fake, fake])) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct GetSslKeyParams { - package_id: Option, - host_id: String, - algorithm: Option, -} - -async fn get_ssl_key( - context: EffectContext, - GetSslKeyParams { - package_id, - host_id, - algorithm, - }: GetSslKeyParams, -) -> Result { - // TODO - let fake = include_str!("./fake.cert.key"); - Ok(json!(fake)) -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct GetStoreParams { - #[ts(type = "string | null")] - package_id: Option, - #[ts(type = "string")] - path: JsonPointer, -} - -async fn get_store( - context: EffectContext, - GetStoreParams { package_id, path }: GetStoreParams, -) -> Result { - let context = context.deref()?; - let peeked = context.seed.ctx.db.peek().await; - let package_id = package_id.unwrap_or(context.seed.id.clone()); - let value = peeked - .as_private() - .as_package_stores() - .as_idx(&package_id) - .or_not_found(&package_id)? - .de()?; - - Ok(path - .get(&value) - .ok_or_else(|| Error::new(eyre!("Did not find value at path"), ErrorKind::NotFound))? - .clone()) -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct SetStoreParams { - #[ts(type = "any")] - value: Value, - #[ts(type = "string")] - path: JsonPointer, -} - -async fn set_store( - context: EffectContext, - SetStoreParams { value, path }: SetStoreParams, -) -> Result<(), Error> { - let context = context.deref()?; - let package_id = context.seed.id.clone(); - context - .seed - .ctx - .db - .mutate(|db| { - let model = db - .as_private_mut() - .as_package_stores_mut() - .upsert(&package_id, || Ok(json!({})))?; - let mut model_value = model.de()?; - if model_value.is_null() { - model_value = json!({}); - } - path.set(&mut model_value, value, true) - .with_kind(ErrorKind::ParseDbField)?; - model.ser(&model_value) - }) - .await?; - Ok(()) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct ExposeForDependentsParams { - #[ts(type = "string[]")] - paths: Vec, -} - -async fn expose_for_dependents( - context: EffectContext, - ExposeForDependentsParams { paths }: ExposeForDependentsParams, -) -> Result<(), Error> { - Ok(()) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] -#[ts(export)] -#[serde(rename_all = "camelCase")] -struct ParamsPackageId { - #[ts(type = "string")] - package_id: PackageId, -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] -#[serde(rename_all = "camelCase")] -#[command(rename_all = "camelCase")] -#[ts(export)] -struct ParamsMaybePackageId { - #[ts(type = "string | null")] - package_id: Option, -} - -async fn exists(context: EffectContext, params: ParamsPackageId) -> Result { - let context = context.deref()?; - let peeked = context.seed.ctx.db.peek().await; - let package = peeked - .as_public() - .as_package_data() - .as_idx(¶ms.package_id) - .is_some(); - Ok(json!(package)) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct ExecuteAction { - #[serde(default)] - procedure_id: Guid, - #[ts(type = "string | null")] - service_id: Option, - #[ts(type = "string")] - action_id: ActionId, - #[ts(type = "any")] - input: Value, -} -async fn execute_action( - context: EffectContext, - ExecuteAction { - procedure_id, - service_id, - action_id, - input, - }: ExecuteAction, -) -> Result { - let context = context.deref()?; - let package_id = service_id - .clone() - .unwrap_or_else(|| context.seed.id.clone()); - - Ok(json!(context.action(procedure_id, action_id, input).await?)) -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct FromService {} -async fn get_configured(context: EffectContext, _: Empty) -> Result { - let context = context.deref()?; - let peeked = context.seed.ctx.db.peek().await; - let package_id = &context.seed.id; - let package = peeked - .as_public() - .as_package_data() - .as_idx(package_id) - .or_not_found(package_id)? - .as_status() - .as_configured() - .de()?; - Ok(json!(package)) -} - -async fn stopped(context: EffectContext, params: ParamsMaybePackageId) -> Result { - let context = context.deref()?; - let peeked = context.seed.ctx.db.peek().await; - let package_id = params.package_id.unwrap_or_else(|| context.seed.id.clone()); - let package = peeked - .as_public() - .as_package_data() - .as_idx(&package_id) - .or_not_found(&package_id)? - .as_status() - .as_main() - .de()?; - Ok(json!(matches!(package, MainStatus::Stopped))) -} -async fn running(context: EffectContext, params: ParamsPackageId) -> Result { - let context = context.deref()?; - let peeked = context.seed.ctx.db.peek().await; - let package_id = params.package_id; - let package = peeked - .as_public() - .as_package_data() - .as_idx(&package_id) - .or_not_found(&package_id)? - .as_status() - .as_main() - .de()?; - Ok(json!(matches!(package, MainStatus::Running { .. }))) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct ProcedureId { - #[serde(default)] - #[arg(default_value_t, long)] - procedure_id: Guid, -} - -async fn restart( - context: EffectContext, - ProcedureId { procedure_id }: ProcedureId, -) -> Result<(), Error> { - let context = context.deref()?; - context.restart(procedure_id).await?; - Ok(()) -} - -async fn shutdown( - context: EffectContext, - ProcedureId { procedure_id }: ProcedureId, -) -> Result<(), Error> { - let context = context.deref()?; - context.stop(procedure_id).await?; - Ok(()) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] -#[serde(rename_all = "camelCase")] -#[command(rename_all = "camelCase")] -#[ts(export)] -struct SetConfigured { - configured: bool, -} -async fn set_configured(context: EffectContext, params: SetConfigured) -> Result { - let context = context.deref()?; - let package_id = &context.seed.id; - context - .seed - .ctx - .db - .mutate(|db| { - db.as_public_mut() - .as_package_data_mut() - .as_idx_mut(package_id) - .or_not_found(package_id)? - .as_status_mut() - .as_configured_mut() - .ser(¶ms.configured) - }) - .await?; - Ok(json!(())) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -enum SetMainStatusStatus { - Running, - Stopped, -} -impl FromStr for SetMainStatusStatus { - type Err = color_eyre::eyre::Report; - fn from_str(s: &str) -> Result { - match s { - "running" => Ok(Self::Running), - "stopped" => Ok(Self::Stopped), - _ => Err(eyre!("unknown status {s}")), - } - } -} -impl ValueParserFactory for SetMainStatusStatus { - type Parser = FromStrParser; - fn value_parser() -> Self::Parser { - FromStrParser::new() - } -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] -#[serde(rename_all = "camelCase")] -#[command(rename_all = "camelCase")] -#[ts(export)] -struct SetMainStatus { - status: SetMainStatusStatus, -} -async fn set_main_status(context: EffectContext, params: SetMainStatus) -> Result { - let context = context.deref()?; - match params.status { - SetMainStatusStatus::Running => context.seed.started(), - SetMainStatusStatus::Stopped => context.seed.stopped(), - } - Ok(Value::Null) -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct SetHealth { - id: HealthCheckId, - #[serde(flatten)] - result: HealthCheckResult, -} - -async fn set_health( - context: EffectContext, - SetHealth { id, result }: SetHealth, -) -> Result { - let context = context.deref()?; - - let package_id = &context.seed.id; - context - .seed - .ctx - .db - .mutate(move |db| { - db.as_public_mut() - .as_package_data_mut() - .as_idx_mut(package_id) - .or_not_found(package_id)? - .as_status_mut() - .as_main_mut() - .mutate(|main| { - match main { - &mut MainStatus::Running { ref mut health, .. } - | &mut MainStatus::BackingUp { ref mut health, .. } => { - health.insert(id, result); - } - _ => (), - } - Ok(()) - }) - }) - .await?; - Ok(json!(())) -} -#[derive(serde::Deserialize, serde::Serialize, Parser, TS)] -#[serde(rename_all = "camelCase")] -#[command(rename_all = "camelCase")] -#[ts(export)] -pub struct DestroyOverlayedImageParams { - guid: Guid, -} - -#[instrument(skip_all)] -pub async fn destroy_overlayed_image( - context: EffectContext, - DestroyOverlayedImageParams { guid }: DestroyOverlayedImageParams, -) -> Result<(), Error> { - let context = context.deref()?; - if context - .seed - .persistent_container - .overlays - .lock() - .await - .remove(&guid) - .is_none() - { - tracing::warn!("Could not find a guard to remove on the destroy overlayed image; assumming that it already is removed and will be skipping"); - } - Ok(()) -} -#[derive(serde::Deserialize, serde::Serialize, Parser, TS)] -#[serde(rename_all = "camelCase")] -#[command(rename_all = "camelCase")] -#[ts(export)] -pub struct CreateOverlayedImageParams { - image_id: ImageId, -} - -#[instrument(skip_all)] -pub async fn create_overlayed_image( - context: EffectContext, - CreateOverlayedImageParams { image_id }: CreateOverlayedImageParams, -) -> Result<(PathBuf, Guid), Error> { - let context = context.deref()?; - if let Some(image) = context - .seed - .persistent_container - .images - .get(&image_id) - .cloned() - { - let guid = Guid::new(); - let rootfs_dir = context - .seed - .persistent_container - .lxc_container - .get() - .ok_or_else(|| { - Error::new( - eyre!("PersistentContainer has been destroyed"), - ErrorKind::Incoherent, - ) - })? - .rootfs_dir(); - let mountpoint = rootfs_dir - .join("media/startos/overlays") - .join(guid.as_ref()); - tokio::fs::create_dir_all(&mountpoint).await?; - let container_mountpoint = Path::new("/").join( - mountpoint - .strip_prefix(rootfs_dir) - .with_kind(ErrorKind::Incoherent)?, - ); - tracing::info!("Mounting overlay {guid} for {image_id}"); - let guard = OverlayGuard::mount(image, &mountpoint).await?; - Command::new("chown") - .arg("100000:100000") - .arg(&mountpoint) - .invoke(ErrorKind::Filesystem) - .await?; - tracing::info!("Mounted overlay {guid} for {image_id}"); - context - .seed - .persistent_container - .overlays - .lock() - .await - .insert(guid.clone(), guard); - Ok((container_mountpoint, guid)) - } else { - Err(Error::new( - eyre!("image {image_id} not found in s9pk"), - ErrorKind::NotFound, - )) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -enum DependencyKind { - Exists, - Running, -} - -#[derive(Debug, Clone, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase", tag = "kind")] -#[ts(export)] -enum DependencyRequirement { - #[serde(rename_all = "camelCase")] - Running { - #[ts(type = "string")] - id: PackageId, - #[ts(type = "string[]")] - health_checks: BTreeSet, - #[ts(type = "string")] - version_range: VersionRange, - }, - #[serde(rename_all = "camelCase")] - Exists { - #[ts(type = "string")] - id: PackageId, - #[ts(type = "string")] - version_range: VersionRange, - }, -} -// filebrowser:exists,bitcoind:running:foo+bar+baz -impl FromStr for DependencyRequirement { - type Err = Error; - fn from_str(s: &str) -> Result { - match s.split_once(':') { - Some((id, "e")) | Some((id, "exists")) => Ok(Self::Exists { - id: id.parse()?, - version_range: "*".parse()?, // TODO - }), - Some((id, rest)) => { - let health_checks = match rest.split_once(':') { - Some(("r", rest)) | Some(("running", rest)) => rest - .split('+') - .map(|id| id.parse().map_err(Error::from)) - .collect(), - Some((kind, _)) => Err(Error::new( - eyre!("unknown dependency kind {kind}"), - ErrorKind::InvalidRequest, - )), - None => match rest { - "r" | "running" => Ok(BTreeSet::new()), - kind => Err(Error::new( - eyre!("unknown dependency kind {kind}"), - ErrorKind::InvalidRequest, - )), - }, - }?; - Ok(Self::Running { - id: id.parse()?, - health_checks, - version_range: "*".parse()?, // TODO - }) - } - None => Ok(Self::Running { - id: s.parse()?, - health_checks: BTreeSet::new(), - version_range: "*".parse()?, // TODO - }), - } - } -} -impl ValueParserFactory for DependencyRequirement { - type Parser = FromStrParser; - fn value_parser() -> Self::Parser { - FromStrParser::new() - } -} - -#[derive(Deserialize, Serialize, Parser, TS)] -#[serde(rename_all = "camelCase")] -#[command(rename_all = "camelCase")] -#[ts(export)] -struct SetDependenciesParams { - #[serde(default)] - procedure_id: Guid, - dependencies: Vec, -} - -async fn set_dependencies( - context: EffectContext, - SetDependenciesParams { - procedure_id, - dependencies, - }: SetDependenciesParams, -) -> Result<(), Error> { - let context = context.deref()?; - let id = &context.seed.id; - - let mut deps = BTreeMap::new(); - for dependency in dependencies { - let (dep_id, kind, version_range) = match dependency { - DependencyRequirement::Exists { id, version_range } => { - (id, CurrentDependencyKind::Exists, version_range) - } - DependencyRequirement::Running { - id, - health_checks, - version_range, - } => ( - id, - CurrentDependencyKind::Running { health_checks }, - version_range, - ), - }; - let config_satisfied = - if let Some(dep_service) = &*context.seed.ctx.services.get(&dep_id).await { - context - .dependency_config( - procedure_id.clone(), - dep_id.clone(), - dep_service.get_config(procedure_id.clone()).await?.config, - ) - .await? - .is_none() - } else { - true - }; - let info = CurrentDependencyInfo { - title: context - .seed - .persistent_container - .s9pk - .dependency_metadata(&dep_id) - .await? - .map(|m| m.title), - icon: context - .seed - .persistent_container - .s9pk - .dependency_icon_data_url(&dep_id) - .await?, - kind, - version_range, - config_satisfied, - }; - deps.insert(dep_id, info); - } - context - .seed - .ctx - .db - .mutate(|db| { - db.as_public_mut() - .as_package_data_mut() - .as_idx_mut(id) - .or_not_found(id)? - .as_current_dependencies_mut() - .ser(&CurrentDependencies(deps)) - }) - .await -} - -async fn get_dependencies(context: EffectContext) -> Result, Error> { - let context = context.deref()?; - let id = &context.seed.id; - let db = context.seed.ctx.db.peek().await; - let data = db - .as_public() - .as_package_data() - .as_idx(id) - .or_not_found(id)? - .as_current_dependencies() - .de()?; - - data.0 - .into_iter() - .map(|(id, current_dependency_info)| { - let CurrentDependencyInfo { - version_range, - kind, - .. - } = current_dependency_info; - Ok::<_, Error>(match kind { - CurrentDependencyKind::Exists => { - DependencyRequirement::Exists { id, version_range } - } - CurrentDependencyKind::Running { health_checks } => { - DependencyRequirement::Running { - id, - health_checks, - version_range, - } - } - }) - }) - .try_collect() -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] -#[serde(rename_all = "camelCase")] -#[command(rename_all = "camelCase")] -#[ts(export)] -struct CheckDependenciesParam { - package_ids: Option>, -} -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -struct CheckDependenciesResult { - package_id: PackageId, - is_installed: bool, - is_running: bool, - config_satisfied: bool, - health_checks: BTreeMap, - #[ts(type = "string | null")] - version: Option, -} - -async fn check_dependencies( - context: EffectContext, - CheckDependenciesParam { package_ids }: CheckDependenciesParam, -) -> Result, Error> { - let context = context.deref()?; - let db = context.seed.ctx.db.peek().await; - let current_dependencies = db - .as_public() - .as_package_data() - .as_idx(&context.seed.id) - .or_not_found(&context.seed.id)? - .as_current_dependencies() - .de()?; - let package_ids: Vec<_> = package_ids - .unwrap_or_else(|| current_dependencies.0.keys().cloned().collect()) - .into_iter() - .filter_map(|x| { - let info = current_dependencies.0.get(&x)?; - Some((x, info)) - }) - .collect(); - let mut results = Vec::with_capacity(package_ids.len()); - - for (package_id, dependency_info) in package_ids { - let Some(package) = db.as_public().as_package_data().as_idx(&package_id) else { - results.push(CheckDependenciesResult { - package_id, - is_installed: false, - is_running: false, - config_satisfied: false, - health_checks: Default::default(), - version: None, - }); - continue; - }; - let manifest = package.as_state_info().as_manifest(ManifestPreference::New); - let installed_version = manifest.as_version().de()?.into_version(); - let satisfies = manifest.as_satisfies().de()?; - let version = Some(installed_version.clone()); - if ![installed_version] - .into_iter() - .chain(satisfies.into_iter().map(|v| v.into_version())) - .any(|v| v.satisfies(&dependency_info.version_range)) - { - results.push(CheckDependenciesResult { - package_id, - is_installed: false, - is_running: false, - config_satisfied: false, - health_checks: Default::default(), - version, - }); - continue; - } - let is_installed = true; - let status = package.as_status().as_main().de()?; - let is_running = if is_installed { - status.running() - } else { - false - }; - let health_checks = - if let CurrentDependencyKind::Running { health_checks } = &dependency_info.kind { - status - .health() - .cloned() - .unwrap_or_default() - .into_iter() - .filter(|(id, _)| health_checks.contains(id)) - .collect() - } else { - Default::default() - }; - results.push(CheckDependenciesResult { - package_id, - is_installed, - is_running, - config_satisfied: dependency_info.config_satisfied, - health_checks, - version, - }); - } - Ok(results) -} diff --git a/core/startos/src/system.rs b/core/startos/src/system.rs index 7a8cb9afe..7af94588b 100644 --- a/core/startos/src/system.rs +++ b/core/startos/src/system.rs @@ -5,6 +5,7 @@ use chrono::Utc; use clap::Parser; use color_eyre::eyre::eyre; use futures::FutureExt; +use imbl::vector; use rpc_toolkit::{from_fn_async, Context, Empty, HandlerExt, ParentHandler}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use tokio::process::Command; @@ -824,6 +825,51 @@ async fn get_disk_info() -> Result { }) } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Parser, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct SmtpValue { + #[arg(long)] + pub server: String, + #[arg(long)] + pub port: u16, + #[arg(long)] + pub from: String, + #[arg(long)] + pub login: String, + #[arg(long)] + pub password: Option, +} +pub async fn set_system_smtp(ctx: RpcContext, smtp: SmtpValue) -> Result<(), Error> { + let smtp = Some(smtp); + ctx.db + .mutate(|db| { + db.as_public_mut() + .as_server_info_mut() + .as_smtp_mut() + .ser(&smtp) + }) + .await?; + if let Some(callbacks) = ctx.callbacks.get_system_smtp() { + callbacks.call(vector![to_value(&smtp)?]).await?; + } + Ok(()) +} +pub async fn clear_system_smtp(ctx: RpcContext) -> Result<(), Error> { + ctx.db + .mutate(|db| { + db.as_public_mut() + .as_server_info_mut() + .as_smtp_mut() + .ser(&None) + }) + .await?; + if let Some(callbacks) = ctx.callbacks.get_system_smtp() { + callbacks.call(vector![Value::Null]).await?; + } + Ok(()) +} + #[tokio::test] #[ignore] pub async fn test_get_temp() { diff --git a/core/startos/src/util/collections/eq_map.rs b/core/startos/src/util/collections/eq_map.rs new file mode 100644 index 000000000..5078866a5 --- /dev/null +++ b/core/startos/src/util/collections/eq_map.rs @@ -0,0 +1,1213 @@ +use std::borrow::Borrow; +use std::fmt; +use std::ops::{Index, IndexMut}; + +pub struct EqMap(Vec<(K, V)>); +impl Default for EqMap { + fn default() -> Self { + Self(Default::default()) + } +} +impl EqMap { + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.0.clear() + } + + /// Returns the key-value pair corresponding to the supplied key as a borrowed tuple. + /// + /// The supplied key may be any borrowed form of the map's key type, but the equality + /// on the borrowed form *must* match the equality on the key type. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// map.insert(1, "a"); + /// assert_eq!(map.get_key_value(&1), Some((&1, &"a"))); + /// assert_eq!(map.get_key_value(&2), None); + /// ``` + pub fn get_key_value_ref(&self, key: &Q) -> Option<&(K, V)> + where + K: Borrow + Eq, + Q: Eq, + { + self.0.iter().find(|(k, _)| k.borrow() == key) + } + + /// Returns the key-value pair corresponding to the supplied key as a mutably borrowed tuple. + /// + /// The supplied key may be any borrowed form of the map's key type, but the equality + /// on the borrowed form *must* match the equality on the key type. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// map.insert(1, "a"); + /// assert_eq!(map.get_key_value(&1), Some((&1, &"a"))); + /// assert_eq!(map.get_key_value(&2), None); + /// ``` + pub fn get_key_value_mut(&mut self, key: &Q) -> Option<&mut (K, V)> + where + K: Borrow + Eq, + Q: Eq, + { + self.0.iter_mut().find(|(k, _)| k.borrow() == key) + } + + /// Returns a reference to the value corresponding to the key. + /// + /// The key may be any borrowed form of the map's key type, but the equality + /// on the borrowed form *must* match the equality on the key type. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// map.insert(1, "a"); + /// assert_eq!(map.get(&1), Some(&"a")); + /// assert_eq!(map.get(&2), None); + /// ``` + pub fn get(&self, key: &Q) -> Option<&V> + where + K: Borrow + Eq, + Q: Eq, + { + self.get_key_value_ref(key).map(|(_, v)| v) + } + + /// Returns the key-value pair corresponding to the supplied key. + /// + /// The supplied key may be any borrowed form of the map's key type, but the equality + /// on the borrowed form *must* match the equality on the key type. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// map.insert(1, "a"); + /// assert_eq!(map.get_key_value(&1), Some((&1, &"a"))); + /// assert_eq!(map.get_key_value(&2), None); + /// ``` + pub fn get_key_value(&self, key: &Q) -> Option<(&K, &V)> + where + K: Borrow + Eq, + Q: Eq, + { + self.get_key_value_ref(key).map(|(k, v)| (k, v)) + } + + /// Removes and returns an element in the map. + /// There is no guarantee about which element this might be + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// map.insert(1, "a"); + /// map.insert(2, "b"); + /// while let Some((_key, _val)) = map.pop() { } + /// assert!(map.is_empty()); + /// ``` + pub fn pop(&mut self) -> Option<(K, V)> + where + K: Eq, + { + self.0.pop() + } + + /// Returns `true` if the map contains a value for the specified key. + /// + /// The key may be any borrowed form of the map's key type, but the equality + /// on the borrowed form *must* match the equality on the key type. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// map.insert(1, "a"); + /// assert_eq!(map.contains_key(&1), true); + /// assert_eq!(map.contains_key(&2), false); + /// ``` + pub fn contains_key(&self, key: &Q) -> bool + where + K: Borrow + Eq, + Q: Eq, + { + self.get(key).is_some() + } + + /// Returns a mutable reference to the value corresponding to the key. + /// + /// The key may be any borrowed form of the map's key type, but the equality + /// on the borrowed form *must* match the equality on the key type. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// map.insert(1, "a"); + /// if let Some(x) = map.get_mut(&1) { + /// *x = "b"; + /// } + /// assert_eq!(map[&1], "b"); + /// ``` + // See `get` for implementation notes, this is basically a copy-paste with mut's added + pub fn get_mut(&mut self, key: &Q) -> Option<&mut V> + where + K: Borrow + Eq, + Q: Eq, + { + self.get_key_value_mut(key).map(|(_, v)| v) + } + + /// Inserts a key-value pair into the map. + /// + /// If the map did not have this key present, `None` is returned. + /// + /// If the map did have this key present, the value is updated, and the old + /// value is returned. The key is not updated, though; this matters for + /// types that can be `==` without being identical. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// assert_eq!(map.insert(37, "a"), None); + /// assert_eq!(map.is_empty(), false); + /// + /// map.insert(37, "b"); + /// assert_eq!(map.insert(37, "c"), Some("b")); + /// assert_eq!(map[&37], "c"); + /// ``` + pub fn insert(&mut self, key: K, value: V) -> Option + where + K: Eq, + { + match self.entry(key) { + Occupied(mut entry) => Some(entry.insert(value)), + Vacant(entry) => { + entry.insert(value); + None + } + } + } + + /// Tries to insert a key-value pair into the map, and returns + /// a mutable reference to the value in the entry. + /// + /// If the map already had this key present, nothing is updated, and + /// an error containing the occupied entry and the value is returned. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// assert_eq!(map.try_insert(37, "a").unwrap(), &"a"); + /// + /// let err = map.try_insert(37, "b").unwrap_err(); + /// assert_eq!(err.entry.key(), &37); + /// assert_eq!(err.entry.get(), &"a"); + /// assert_eq!(err.value, "b"); + /// ``` + pub fn try_insert(&mut self, key: K, value: V) -> Result<&mut V, OccupiedError<'_, K, V>> + where + K: Eq, + { + match self.entry(key) { + Occupied(entry) => Err(OccupiedError { entry, value }), + Vacant(entry) => Ok(entry.insert(value)), + } + } + + /// Removes a key from the map, returning the value at the key if the key + /// was previously in the map. + /// + /// The key may be any borrowed form of the map's key type, but the equality + /// on the borrowed form *must* match the equality on the key type. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// map.insert(1, "a"); + /// assert_eq!(map.remove(&1), Some("a")); + /// assert_eq!(map.remove(&1), None); + /// ``` + pub fn remove(&mut self, key: &Q) -> Option + where + K: Borrow + Eq, + Q: Eq, + { + self.remove_entry(key).map(|(_, v)| v) + } + + /// Removes a key from the map, returning the stored key and value if the key + /// was previously in the map. + /// + /// The key may be any borrowed form of the map's key type, but the equality + /// on the borrowed form *must* match the equality on the key type. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// map.insert(1, "a"); + /// assert_eq!(map.remove_entry(&1), Some((1, "a"))); + /// assert_eq!(map.remove_entry(&1), None); + /// ``` + pub fn remove_entry(&mut self, key: &Q) -> Option<(K, V)> + where + K: Borrow + Eq, + Q: Eq, + { + self.0 + .iter() + .enumerate() + .find(|(_, (k, _))| k.borrow() == key) + .map(|(idx, _)| idx) + .map(|idx| self.0.swap_remove(idx)) + } + + /// Retains only the elements specified by the predicate. + /// + /// In other words, remove all pairs `(k, v)` for which `f(&k, &mut v)` returns `false`. + /// The elements are visited in ascending key order. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map: EqMap = (0..8).map(|x| (x, x*10)).collect(); + /// // Keep only the elements with even-numbered keys. + /// map.retain(|&k, _| k % 2 == 0); + /// assert!(map.into_iter().eq(vec![(0, 0), (2, 20), (4, 40), (6, 60)])); + /// ``` + #[inline] + pub fn retain(&mut self, mut f: F) + where + K: Eq, + F: FnMut(&K, &mut V) -> bool, + { + self.0.retain_mut(|(k, v)| f(k, v)) + } + + /// Moves all elements from `other` into `self`, leaving `other` empty. + /// + /// If a key from `other` is already present in `self`, the respective + /// value from `self` will be overwritten with the respective value from `other`. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut a = EqMap::new(); + /// a.insert(1, "a"); + /// a.insert(2, "b"); + /// a.insert(3, "c"); // Note: Key (3) also present in b. + /// + /// let mut b = EqMap::new(); + /// b.insert(3, "d"); // Note: Key (3) also present in a. + /// b.insert(4, "e"); + /// b.insert(5, "f"); + /// + /// a.append(&mut b); + /// + /// assert_eq!(a.len(), 5); + /// assert_eq!(b.len(), 0); + /// + /// assert_eq!(a[&1], "a"); + /// assert_eq!(a[&2], "b"); + /// assert_eq!(a[&3], "d"); // Note: "c" has been overwritten. + /// assert_eq!(a[&4], "e"); + /// assert_eq!(a[&5], "f"); + /// ``` + pub fn append(&mut self, other: &mut Self) + where + K: Eq, + { + for k in other.keys() { + self.remove(k); + } + self.0.append(&mut other.0) + } + + /// Gets the given key's corresponding entry in the map for in-place manipulation. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut count: EqMap<&str, usize> = EqMap::new(); + /// + /// // count the number of occurrences of letters in the vec + /// for x in ["a", "b", "a", "c", "a", "b"] { + /// count.entry(x).and_modify(|curr| *curr += 1).or_insert(1); + /// } + /// + /// assert_eq!(count["a"], 3); + /// assert_eq!(count["b"], 2); + /// assert_eq!(count["c"], 1); + /// ``` + pub fn entry(&mut self, key: K) -> Entry<'_, K, V> + where + K: Eq, + { + match self.0.iter().enumerate().find(|(_, (k, _))| k == &key) { + Some((idx, _)) => Occupied(OccupiedEntry { map: self, idx }), + None => Vacant(VacantEntry { key, map: self }), + } + } + + // /// Creates an iterator that visits all elements (key-value pairs) and + // /// uses a closure to determine if an element should be removed. If the + // /// closure returns `true`, the element is removed from the map and yielded. + // /// If the closure returns `false`, or panics, the element remains in the map + // /// and will not be yielded. + // /// + // /// The iterator also lets you mutate the value of each element in the + // /// closure, regardless of whether you choose to keep or remove it. + // /// + // /// If the returned `ExtractIf` is not exhausted, e.g. because it is dropped without iterating + // /// or the iteration short-circuits, then the remaining elements will be retained. + // /// Use [`retain`] with a negated predicate if you do not need the returned iterator. + // /// + // /// [`retain`]: EqMap::retain + // /// + // /// # Examples + // /// + // /// Splitting a map into even and odd keys, reusing the original map: + // /// + // /// ``` + // /// use startos::util::collections::EqMap; + // /// + // /// let mut map: EqMap = (0..8).map(|x| (x, x)).collect(); + // /// let evens: EqMap<_, _> = map.extract_if(|k, _v| k % 2 == 0).collect(); + // /// let odds = map; + // /// assert_eq!(evens.keys().copied().collect::>(), [0, 2, 4, 6]); + // /// assert_eq!(odds.keys().copied().collect::>(), [1, 3, 5, 7]); + // /// ``` + // pub fn extract_if(&mut self, pred: F) -> ExtractIf<'_, K, V, F> + // where + // K: Eq, + // F: FnMut(&K, &mut V) -> bool, + // { + // let (inner, alloc) = self.extract_if_inner(); + // ExtractIf { pred, inner, alloc } + // } + + /// Creates a consuming iterator visiting all the keys. + /// The map cannot be used after calling this. + /// The iterator element type is `K`. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut a = EqMap::new(); + /// a.insert(2, "b"); + /// a.insert(1, "a"); + /// + /// let keys: Vec = a.into_keys().collect(); + /// assert_eq!(keys, [2, 1]); + /// ``` + #[inline] + pub fn into_keys(self) -> IntoKeys { + IntoKeys(self.0.into_iter()) + } + + /// Creates a consuming iterator visiting all the values. + /// The map cannot be used after calling this. + /// The iterator element type is `V`. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut a = EqMap::new(); + /// a.insert(1, "hello"); + /// a.insert(2, "goodbye"); + /// + /// let values: Vec<&str> = a.into_values().collect(); + /// assert_eq!(values, ["hello", "goodbye"]); + /// ``` + #[inline] + pub fn into_values(self) -> IntoValues { + IntoValues(self.0.into_iter()) + } + + pub fn iter_ref(&self) -> std::slice::Iter<'_, (K, V)> { + self.0.iter() + } + + /// Gets an iterator over the entries of the map, in no particular order. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::new(); + /// map.insert(3, "c"); + /// map.insert(2, "b"); + /// map.insert(1, "a"); + /// + /// for (key, value) in map.iter() { + /// println!("{key}: {value}"); + /// } + /// + /// let (first_key, first_value) = map.iter().next().unwrap(); + /// assert_eq!((*first_key, *first_value), (3, "c")); + /// ``` + pub fn iter(&self) -> std::iter::Map, fn(&(K, V)) -> (&K, &V)> { + self.0.iter().map(|(k, v)| (k, v)) + } + + /// Gets a mutable iterator over the entries of the map, in no particular order. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map = EqMap::from([ + /// ("a", 1), + /// ("b", 2), + /// ("c", 3), + /// ]); + /// + /// // add 10 to the value if the key isn't "a" + /// for (key, value) in map.iter_mut() { + /// if key != &"a" { + /// *value += 10; + /// } + /// } + /// ``` + pub fn iter_mut( + &mut self, + ) -> std::iter::Map, fn(&mut (K, V)) -> (&K, &mut V)> { + self.0.iter_mut().map(|(k, v)| (&*k, v)) + } + + /// Gets an iterator over the keys of the map. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut a = EqMap::new(); + /// a.insert(2, "b"); + /// a.insert(1, "a"); + /// + /// let keys: Vec<_> = a.keys().cloned().collect(); + /// assert_eq!(keys, [2, 1]); + /// ``` + pub fn keys(&self) -> std::iter::Map, fn(&(K, V)) -> &K> { + self.0.iter().map(|(k, _)| k) + } + + /// Gets an iterator over the values of the map. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut a = EqMap::new(); + /// a.insert(1, "hello"); + /// a.insert(2, "goodbye"); + /// + /// let values: Vec<&str> = a.values().cloned().collect(); + /// assert_eq!(values, ["hello", "goodbye"]); + /// ``` + pub fn values(&self) -> std::iter::Map, fn(&(K, V)) -> &V> { + self.0.iter().map(|(_, v)| v) + } + + /// Gets a mutable iterator over the values of the map. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut a = EqMap::new(); + /// a.insert(1, String::from("hello")); + /// a.insert(2, String::from("goodbye")); + /// + /// for value in a.values_mut() { + /// value.push_str("!"); + /// } + /// + /// let values: Vec = a.values().cloned().collect(); + /// assert_eq!(values, [String::from("hello!"), + /// String::from("goodbye!")]); + /// ``` + pub fn values_mut( + &mut self, + ) -> std::iter::Map, fn(&mut (K, V)) -> &mut V> { + self.0.iter_mut().map(|(_, v)| v) + } + + /// Returns the number of elements in the map. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut a = EqMap::new(); + /// assert_eq!(a.len(), 0); + /// a.insert(1, "a"); + /// assert_eq!(a.len(), 1); + /// ``` + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } + + /// Returns `true` if the map contains no elements. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut a = EqMap::new(); + /// assert!(a.is_empty()); + /// a.insert(1, "a"); + /// assert!(!a.is_empty()); + /// ``` + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl fmt::Debug for EqMap { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_map().entries(self.iter()).finish() + } +} + +impl Index<&Q> for EqMap +where + K: Borrow + Eq, + Q: Eq, +{ + type Output = V; + + /// Returns a reference to the value corresponding to the supplied key. + /// + /// # Panics + /// + /// Panics if the key is not present in the `BTreeMap`. + #[inline] + fn index(&self, key: &Q) -> &V { + self.get(key).expect("no entry found for key") + } +} + +impl IndexMut<&Q> for EqMap +where + K: Borrow + Eq, + Q: Eq, +{ + /// Returns a reference to the value corresponding to the supplied key. + /// + /// # Panics + /// + /// Panics if the key is not present in the `BTreeMap`. + #[inline] + fn index_mut(&mut self, key: &Q) -> &mut V { + self.get_mut(key).expect("no entry found for key") + } +} + +impl IntoIterator for EqMap { + type IntoIter = std::vec::IntoIter<(K, V)>; + type Item = (K, V); + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl Extend<(K, V)> for EqMap { + fn extend>(&mut self, iter: T) { + self.0.extend(iter) + } +} + +impl FromIterator<(K, V)> for EqMap { + fn from_iter>(iter: T) -> Self { + Self(Vec::from_iter(iter)) + } +} + +impl From<[(K, V); N]> for EqMap { + /// Converts a `[(K, V); N]` into a `EqMap<(K, V)>`. + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let map1 = EqMap::from([(1, 2), (3, 4)]); + /// let map2: EqMap<_, _> = [(1, 2), (3, 4)].into(); + /// assert_eq!(map1, map2); + /// ``` + fn from(arr: [(K, V); N]) -> Self { + EqMap(Vec::from(arr)) + } +} + +impl PartialEq for EqMap { + fn eq(&self, other: &Self) -> bool { + self.len() == other.len() && self.iter().all(|(k, v)| other.get(k) == Some(v)) + } +} +impl Eq for EqMap {} + +use Entry::*; + +/// A view into a single entry in a map, which may either be vacant or occupied. +/// +/// This `enum` is constructed from the [`entry`] method on [`EqMap`]. +/// +/// [`entry`]: EqMap::entry +pub enum Entry<'a, K: Eq + 'a, V: 'a> { + Vacant(VacantEntry<'a, K, V>), + + /// An occupied entry. + Occupied(OccupiedEntry<'a, K, V>), +} + +impl fmt::Debug for Entry<'_, K, V> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Vacant(ref v) => f.debug_tuple("Entry").field(v).finish(), + Occupied(ref o) => f.debug_tuple("Entry").field(o).finish(), + } + } +} + +/// A view into a vacant entry in a `EqMap`. +/// It is part of the [`Entry`] enum. +pub struct VacantEntry<'a, K: Eq, V> { + key: K, + map: &'a mut EqMap, +} + +impl fmt::Debug for VacantEntry<'_, K, V> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("VacantEntry").field(self.key()).finish() + } +} + +/// A view into an occupied entry in a `EqMap`. +/// It is part of the [`Entry`] enum. +pub struct OccupiedEntry<'a, K: Eq, V> { + map: &'a mut EqMap, + idx: usize, +} + +impl fmt::Debug for OccupiedEntry<'_, K, V> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OccupiedEntry") + .field("key", self.key()) + .field("value", self.get()) + .finish() + } +} + +/// The error returned by [`try_insert`](EqMap::try_insert) when the key already exists. +/// +/// Contains the occupied entry, and the value that was not inserted. +pub struct OccupiedError<'a, K: Eq + 'a, V: 'a> { + /// The entry in the map that was already occupied. + pub entry: OccupiedEntry<'a, K, V>, + /// The value which was not inserted, because the entry was already occupied. + pub value: V, +} + +impl fmt::Debug for OccupiedError<'_, K, V> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OccupiedError") + .field("key", self.entry.key()) + .field("old_value", self.entry.get()) + .field("new_value", &self.value) + .finish() + } +} + +impl<'a, K: fmt::Debug + Eq, V: fmt::Debug> fmt::Display for OccupiedError<'a, K, V> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "failed to insert {:?}, key {:?} already exists with value {:?}", + self.value, + self.entry.key(), + self.entry.get(), + ) + } +} + +impl<'a, K: fmt::Debug + Eq, V: fmt::Debug> std::error::Error for OccupiedError<'a, K, V> { + fn description(&self) -> &str { + "key already exists" + } +} + +impl<'a, K: Eq, V> Entry<'a, K, V> { + /// Ensures a value is in the entry by inserting the default if empty, and returns + /// a mutable reference to the value in the entry. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// map.entry("poneyland").or_insert(12); + /// + /// assert_eq!(map["poneyland"], 12); + /// ``` + pub fn or_insert(self, default: V) -> &'a mut V { + match self { + Occupied(entry) => entry.into_mut(), + Vacant(entry) => entry.insert(default), + } + } + + /// Ensures a value is in the entry by inserting the result of the default function if empty, + /// and returns a mutable reference to the value in the entry. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map: EqMap<&str, String> = EqMap::new(); + /// let s = "hoho".to_string(); + /// + /// map.entry("poneyland").or_insert_with(|| s); + /// + /// assert_eq!(map["poneyland"], "hoho".to_string()); + /// ``` + pub fn or_insert_with V>(self, default: F) -> &'a mut V { + match self { + Occupied(entry) => entry.into_mut(), + Vacant(entry) => entry.insert(default()), + } + } + + /// Ensures a value is in the entry by inserting, if empty, the result of the default function. + /// This method allows for generating key-derived values for insertion by providing the default + /// function a reference to the key that was moved during the `.entry(key)` method call. + /// + /// The reference to the moved key is provided so that cloning or copying the key is + /// unnecessary, unlike with `.or_insert_with(|| ... )`. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// + /// map.entry("poneyland").or_insert_with_key(|key| key.chars().count()); + /// + /// assert_eq!(map["poneyland"], 9); + /// ``` + #[inline] + pub fn or_insert_with_key V>(self, default: F) -> &'a mut V { + match self { + Occupied(entry) => entry.into_mut(), + Vacant(entry) => { + let value = default(entry.key()); + entry.insert(value) + } + } + } + + /// Returns a reference to this entry's key. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// assert_eq!(map.entry("poneyland").key(), &"poneyland"); + /// ``` + pub fn key(&self) -> &K { + match *self { + Occupied(ref entry) => entry.key(), + Vacant(ref entry) => entry.key(), + } + } + + /// Provides in-place mutable access to an occupied entry before any + /// potential inserts into the map. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// + /// map.entry("poneyland") + /// .and_modify(|e| { *e += 1 }) + /// .or_insert(42); + /// assert_eq!(map["poneyland"], 42); + /// + /// map.entry("poneyland") + /// .and_modify(|e| { *e += 1 }) + /// .or_insert(42); + /// assert_eq!(map["poneyland"], 43); + /// ``` + pub fn and_modify(self, f: F) -> Self + where + F: FnOnce(&mut V), + { + match self { + Occupied(mut entry) => { + f(entry.get_mut()); + Occupied(entry) + } + Vacant(entry) => Vacant(entry), + } + } +} + +impl<'a, K: Eq, V: Default> Entry<'a, K, V> { + /// Ensures a value is in the entry by inserting the default value if empty, + /// and returns a mutable reference to the value in the entry. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map: EqMap<&str, Option> = EqMap::new(); + /// map.entry("poneyland").or_default(); + /// + /// assert_eq!(map["poneyland"], None); + /// ``` + pub fn or_default(self) -> &'a mut V { + match self { + Occupied(entry) => entry.into_mut(), + Vacant(entry) => entry.insert(Default::default()), + } + } +} + +impl<'a, K: Eq, V> VacantEntry<'a, K, V> { + /// Gets a reference to the key that would be used when inserting a value + /// through the VacantEntry. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// assert_eq!(map.entry("poneyland").key(), &"poneyland"); + /// ``` + pub fn key(&self) -> &K { + &self.key + } + + /// Take ownership of the key. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// use startos::util::collections::eq_map::Entry; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// + /// if let Entry::Vacant(v) = map.entry("poneyland") { + /// v.into_key(); + /// } + /// ``` + pub fn into_key(self) -> K { + self.key + } + + /// Sets the value of the entry with the `VacantEntry`'s key, + /// and returns a mutable reference to it. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// use startos::util::collections::eq_map::Entry; + /// + /// let mut map: EqMap<&str, u32> = EqMap::new(); + /// + /// if let Entry::Vacant(o) = map.entry("poneyland") { + /// o.insert(37); + /// } + /// assert_eq!(map["poneyland"], 37); + /// ``` + pub fn insert(self, value: V) -> &'a mut V { + self.map.0.push((self.key, value)); + self.map.0.last_mut().map(|(_, v)| v).unwrap() + } +} + +impl<'a, K: Eq, V> OccupiedEntry<'a, K, V> { + /// Gets a reference to the key in the entry. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// map.entry("poneyland").or_insert(12); + /// assert_eq!(map.entry("poneyland").key(), &"poneyland"); + /// ``` + #[must_use] + pub fn key(&self) -> &K { + &self.map.0[self.idx].0 + } + + /// Take ownership of the key and value from the map. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// use startos::util::collections::eq_map::Entry; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// map.entry("poneyland").or_insert(12); + /// + /// if let Entry::Occupied(o) = map.entry("poneyland") { + /// // We delete the entry from the map. + /// o.remove_entry(); + /// } + /// + /// // If now try to get the value, it will panic: + /// // println!("{}", map["poneyland"]); + /// ``` + pub fn remove_entry(self) -> (K, V) { + self.map.0.swap_remove(self.idx) + } + + /// Gets a reference to the value in the entry. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// use startos::util::collections::eq_map::Entry; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// map.entry("poneyland").or_insert(12); + /// + /// if let Entry::Occupied(o) = map.entry("poneyland") { + /// assert_eq!(o.get(), &12); + /// } + /// ``` + #[must_use] + pub fn get(&self) -> &V { + &self.map.0[self.idx].1 + } + + /// Gets a mutable reference to the value in the entry. + /// + /// If you need a reference to the `OccupiedEntry` that may outlive the + /// destruction of the `Entry` value, see [`into_mut`]. + /// + /// [`into_mut`]: OccupiedEntry::into_mut + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// use startos::util::collections::eq_map::Entry; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// map.entry("poneyland").or_insert(12); + /// + /// assert_eq!(map["poneyland"], 12); + /// if let Entry::Occupied(mut o) = map.entry("poneyland") { + /// *o.get_mut() += 10; + /// assert_eq!(*o.get(), 22); + /// + /// // We can use the same Entry multiple times. + /// *o.get_mut() += 2; + /// } + /// assert_eq!(map["poneyland"], 24); + /// ``` + pub fn get_mut(&mut self) -> &mut V { + &mut self.map.0[self.idx].1 + } + + /// Converts the entry into a mutable reference to its value. + /// + /// If you need multiple references to the `OccupiedEntry`, see [`get_mut`]. + /// + /// [`get_mut`]: OccupiedEntry::get_mut + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// use startos::util::collections::eq_map::Entry; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// map.entry("poneyland").or_insert(12); + /// + /// assert_eq!(map["poneyland"], 12); + /// if let Entry::Occupied(o) = map.entry("poneyland") { + /// *o.into_mut() += 10; + /// } + /// assert_eq!(map["poneyland"], 22); + /// ``` + #[must_use = "`self` will be dropped if the result is not used"] + pub fn into_mut(self) -> &'a mut V { + &mut self.map.0[self.idx].1 + } + + /// Sets the value of the entry with the `OccupiedEntry`'s key, + /// and returns the entry's old value. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// use startos::util::collections::eq_map::Entry; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// map.entry("poneyland").or_insert(12); + /// + /// if let Entry::Occupied(mut o) = map.entry("poneyland") { + /// assert_eq!(o.insert(15), 12); + /// } + /// assert_eq!(map["poneyland"], 15); + /// ``` + pub fn insert(&mut self, value: V) -> V { + std::mem::replace(self.get_mut(), value) + } + + /// Takes the value of the entry out of the map, and returns it. + /// + /// # Examples + /// + /// ``` + /// use startos::util::collections::EqMap; + /// use startos::util::collections::eq_map::Entry; + /// + /// let mut map: EqMap<&str, usize> = EqMap::new(); + /// map.entry("poneyland").or_insert(12); + /// + /// if let Entry::Occupied(o) = map.entry("poneyland") { + /// assert_eq!(o.remove(), 12); + /// } + /// // If we try to get "poneyland"'s value, it'll panic: + /// // println!("{}", map["poneyland"]); + /// ``` + pub fn remove(self) -> V { + self.remove_entry().1 + } +} + +pub struct IntoValues(std::vec::IntoIter<(K, V)>); +impl<'a, K: Eq, V> From> for std::vec::IntoIter<(K, V)> { + fn from(value: IntoValues) -> Self { + value.0 + } +} +impl Iterator for IntoValues { + type Item = V; + fn next(&mut self) -> Option { + self.0.next().map(|(_, v)| v) + } + fn size_hint(&self) -> (usize, Option) { + self.0.size_hint() + } + fn count(self) -> usize + where + Self: Sized, + { + self.0.count() + } +} +impl DoubleEndedIterator for IntoValues { + fn next_back(&mut self) -> Option { + self.0.next_back().map(|(_, v)| v) + } +} +impl ExactSizeIterator for IntoValues { + fn len(&self) -> usize { + self.0.len() + } +} + +pub struct IntoKeys(std::vec::IntoIter<(K, V)>); +impl<'a, K: Eq, V> From> for std::vec::IntoIter<(K, V)> { + fn from(value: IntoKeys) -> Self { + value.0 + } +} +impl Iterator for IntoKeys { + type Item = K; + fn next(&mut self) -> Option { + self.0.next().map(|(k, _)| k) + } + fn size_hint(&self) -> (usize, Option) { + self.0.size_hint() + } + fn count(self) -> usize + where + Self: Sized, + { + self.0.count() + } +} +impl DoubleEndedIterator for IntoKeys { + fn next_back(&mut self) -> Option { + self.0.next_back().map(|(k, _)| k) + } +} +impl ExactSizeIterator for IntoKeys { + fn len(&self) -> usize { + self.0.len() + } +} diff --git a/core/startos/src/util/collections/mod.rs b/core/startos/src/util/collections/mod.rs new file mode 100644 index 000000000..aa6e3ddb5 --- /dev/null +++ b/core/startos/src/util/collections/mod.rs @@ -0,0 +1,3 @@ +pub mod eq_map; + +pub use eq_map::EqMap; diff --git a/core/startos/src/util/mod.rs b/core/startos/src/util/mod.rs index 3b66d7507..1641e4496 100644 --- a/core/startos/src/util/mod.rs +++ b/core/startos/src/util/mod.rs @@ -34,8 +34,10 @@ use crate::shutdown::Shutdown; use crate::util::io::create_file; use crate::util::serde::{deserialize_from_str, serialize_display}; use crate::{Error, ErrorKind, ResultExt as _}; + pub mod actor; pub mod clap; +pub mod collections; pub mod cpupower; pub mod crypto; pub mod future; diff --git a/core/startos/src/util/rpc_client.rs b/core/startos/src/util/rpc_client.rs index 36fe0031a..fc93e4c64 100644 --- a/core/startos/src/util/rpc_client.rs +++ b/core/startos/src/util/rpc_client.rs @@ -138,6 +138,31 @@ impl RpcClient { err.data = Some(json!("RpcClient thread has terminated")); Err(err) } + + pub async fn notify( + &mut self, + method: T, + params: T::Params, + ) -> Result<(), RpcError> + where + T: Serialize, + T::Params: Serialize, + { + let request = RpcRequest { + id: None, + method, + params, + }; + self.writer + .write_all((dbg!(serde_json::to_string(&request))? + "\n").as_bytes()) + .await + .map_err(|e| { + let mut err = rpc_toolkit::yajrc::INTERNAL_ERROR.clone(); + err.data = Some(json!(e.to_string())); + err + })?; + Ok(()) + } } #[derive(Clone)] @@ -224,4 +249,36 @@ impl UnixRpcClient { }; res } + + pub async fn notify(&self, method: T, params: T::Params) -> Result<(), RpcError> + where + T: Serialize + Clone, + T::Params: Serialize + Clone, + { + let mut tries = 0; + let res = loop { + let mut client = self.pool.clone().get().await?; + if client.handler.is_finished() { + client.destroy(); + continue; + } + let res = client.notify(method.clone(), params.clone()).await; + match &res { + Err(e) if e.code == rpc_toolkit::yajrc::INTERNAL_ERROR.code => { + let mut e = Error::from(e.clone()); + e.kind = ErrorKind::Filesystem; + tracing::error!("{e}"); + tracing::debug!("{e:?}"); + client.destroy(); + } + _ => break res, + } + tries += 1; + if tries > MAX_TRIES { + tracing::warn!("Max Tries exceeded"); + break res; + } + }; + res + } } diff --git a/sdk/Makefile b/sdk/Makefile index ef1c886f5..660a476c4 100644 --- a/sdk/Makefile +++ b/sdk/Makefile @@ -3,6 +3,8 @@ version = $(shell git tag --sort=committerdate | tail -1) .PHONY: test clean bundle fmt buildOutput check +all: bundle + test: $(TS_FILES) lib/test/output.ts npm test diff --git a/sdk/lib/StartSdk.ts b/sdk/lib/StartSdk.ts index f09d83750..7cdedff90 100644 --- a/sdk/lib/StartSdk.ts +++ b/sdk/lib/StartSdk.ts @@ -77,6 +77,7 @@ import { ExposedStorePaths } from "./store/setupExposeStore" import { PathBuilder, extractJsonPath, pathBuilder } from "./store/PathBuilder" import { checkAllDependencies } from "./dependencies/dependencies" import { health } from "." +import { GetSslCertificate } from "./util/GetSslCertificate" export const SDKVersion = testTypeVersion("0.3.6") @@ -88,14 +89,29 @@ type AnyNeverCond = never export type ServiceInterfaceType = "ui" | "p2p" | "api" -export type MainEffects = Effects & { _type: "main" } +export type MainEffects = Effects & { + _type: "main" + clearCallbacks: () => Promise +} export type Signals = NodeJS.Signals export const SIGTERM: Signals = "SIGTERM" export const SIGKILL: Signals = "SIGKILL" export const NO_TIMEOUT = -1 -function removeConstType() { - return (t: T) => t as T & (E extends MainEffects ? {} : { const: never }) +function removeCallbackTypes(effects: E) { + return (t: T) => { + if ("_type" in effects && effects._type === "main") { + return t as E extends MainEffects ? T : Omit + } else { + if ("const" in t) { + delete t.const + } + if ("watch" in t) { + delete t.watch + } + return t as E extends MainEffects ? T : Omit + } + } } export class StartSdk { @@ -129,26 +145,23 @@ export class StartSdk { checkAllDependencies, serviceInterface: { getOwn: (effects: E, id: ServiceInterfaceId) => - removeConstType()( + removeCallbackTypes(effects)( getServiceInterface(effects, { id, - packageId: null, }), ), get: ( effects: E, opts: { id: ServiceInterfaceId; packageId: PackageId }, - ) => removeConstType()(getServiceInterface(effects, opts)), + ) => + removeCallbackTypes(effects)(getServiceInterface(effects, opts)), getAllOwn: (effects: E) => - removeConstType()( - getServiceInterfaces(effects, { - packageId: null, - }), - ), + removeCallbackTypes(effects)(getServiceInterfaces(effects, {})), getAll: ( effects: E, opts: { packageId: PackageId }, - ) => removeConstType()(getServiceInterfaces(effects, opts)), + ) => + removeCallbackTypes(effects)(getServiceInterfaces(effects, opts)), }, store: { @@ -157,7 +170,7 @@ export class StartSdk { packageId: string, path: PathBuilder, ) => - removeConstType()( + removeCallbackTypes(effects)( getStore(effects, path, { packageId, }), @@ -165,7 +178,10 @@ export class StartSdk { getOwn: ( effects: E, path: PathBuilder, - ) => removeConstType()(getStore(effects, path)), + ) => + removeCallbackTypes(effects)( + getStore(effects, path), + ), setOwn: >( effects: E, path: Path, @@ -241,7 +257,16 @@ export class StartSdk { }, ) => new ServiceInterfaceBuilder({ ...options, effects }), getSystemSmtp: (effects: E) => - removeConstType()(new GetSystemSmtp(effects)), + removeCallbackTypes(effects)(new GetSystemSmtp(effects)), + + getSslCerificate: ( + effects: E, + hostnames: string[], + algorithm?: T.Algorithm, + ) => + removeCallbackTypes(effects)( + new GetSslCertificate(effects, hostnames, algorithm), + ), createDynamicAction: < ConfigType extends diff --git a/sdk/lib/osBindings/AddAssetParams.ts b/sdk/lib/osBindings/AddAssetParams.ts index ffd7db675..7522a1cb4 100644 --- a/sdk/lib/osBindings/AddAssetParams.ts +++ b/sdk/lib/osBindings/AddAssetParams.ts @@ -1,10 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AnySignature } from "./AnySignature" import type { Blake3Commitment } from "./Blake3Commitment" -import type { Version } from "./Version" export type AddAssetParams = { - version: Version + version: string platform: string url: string signature: AnySignature diff --git a/sdk/lib/osBindings/AddVersionParams.ts b/sdk/lib/osBindings/AddVersionParams.ts index 4ecbb7dcc..9fc281a6f 100644 --- a/sdk/lib/osBindings/AddVersionParams.ts +++ b/sdk/lib/osBindings/AddVersionParams.ts @@ -1,8 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Version } from "./Version" export type AddVersionParams = { - version: Version + version: string headline: string releaseNotes: string sourceVersion: string diff --git a/sdk/lib/osBindings/Callback.ts b/sdk/lib/osBindings/CallbackId.ts similarity index 76% rename from sdk/lib/osBindings/Callback.ts rename to sdk/lib/osBindings/CallbackId.ts index 1e5cb1af5..0ac5d7ce2 100644 --- a/sdk/lib/osBindings/Callback.ts +++ b/sdk/lib/osBindings/CallbackId.ts @@ -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 Callback = () => void +export type CallbackId = number diff --git a/sdk/lib/osBindings/CheckDependenciesParam.ts b/sdk/lib/osBindings/CheckDependenciesParam.ts index 54580a7ff..3a00faf4f 100644 --- a/sdk/lib/osBindings/CheckDependenciesParam.ts +++ b/sdk/lib/osBindings/CheckDependenciesParam.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { PackageId } from "./PackageId" -export type CheckDependenciesParam = { packageIds: Array | null } +export type CheckDependenciesParam = { packageIds?: Array } diff --git a/sdk/lib/osBindings/ChrootParams.ts b/sdk/lib/osBindings/ChrootParams.ts deleted file mode 100644 index 19131b224..000000000 --- a/sdk/lib/osBindings/ChrootParams.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ChrootParams = { - env: string | null - workdir: string | null - user: string | null - path: string - command: string - args: string[] -} diff --git a/sdk/lib/osBindings/DependencyRequirement.ts b/sdk/lib/osBindings/DependencyRequirement.ts index 01b8e12ce..3b857c476 100644 --- a/sdk/lib/osBindings/DependencyRequirement.ts +++ b/sdk/lib/osBindings/DependencyRequirement.ts @@ -1,10 +1,12 @@ // 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 { PackageId } from "./PackageId" export type DependencyRequirement = | { kind: "running" - id: string - healthChecks: string[] + id: PackageId + healthChecks: Array versionRange: string } - | { kind: "exists"; id: string; versionRange: string } + | { kind: "exists"; id: PackageId; versionRange: string } diff --git a/sdk/lib/osBindings/ExecuteAction.ts b/sdk/lib/osBindings/ExecuteAction.ts index b4eb60949..6e3c44f79 100644 --- a/sdk/lib/osBindings/ExecuteAction.ts +++ b/sdk/lib/osBindings/ExecuteAction.ts @@ -1,9 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Guid } from "./Guid" +import type { ActionId } from "./ActionId" +import type { PackageId } from "./PackageId" export type ExecuteAction = { - procedureId: Guid - serviceId: string | null - actionId: string + packageId?: PackageId + actionId: ActionId input: any } diff --git a/sdk/lib/osBindings/ExportActionParams.ts b/sdk/lib/osBindings/ExportActionParams.ts index 5eee8fc63..8bcfbc349 100644 --- a/sdk/lib/osBindings/ExportActionParams.ts +++ b/sdk/lib/osBindings/ExportActionParams.ts @@ -1,4 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActionId } from "./ActionId" import type { ActionMetadata } from "./ActionMetadata" +import type { PackageId } from "./PackageId" -export type ExportActionParams = { id: string; metadata: ActionMetadata } +export type ExportActionParams = { + packageId?: PackageId + id: ActionId + metadata: ActionMetadata +} diff --git a/sdk/lib/osBindings/ParamsPackageId.ts b/sdk/lib/osBindings/GetConfiguredParams.ts similarity index 51% rename from sdk/lib/osBindings/ParamsPackageId.ts rename to sdk/lib/osBindings/GetConfiguredParams.ts index f4dd1c1eb..66fb6e320 100644 --- a/sdk/lib/osBindings/ParamsPackageId.ts +++ b/sdk/lib/osBindings/GetConfiguredParams.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PackageId } from "./PackageId" -export type ParamsPackageId = { packageId: string } +export type GetConfiguredParams = { packageId?: PackageId } diff --git a/sdk/lib/osBindings/GetHostInfoParams.ts b/sdk/lib/osBindings/GetHostInfoParams.ts index 120b4cfe1..ff6d9d709 100644 --- a/sdk/lib/osBindings/GetHostInfoParams.ts +++ b/sdk/lib/osBindings/GetHostInfoParams.ts @@ -1,9 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Callback } from "./Callback" +import type { CallbackId } from "./CallbackId" import type { HostId } from "./HostId" +import type { PackageId } from "./PackageId" export type GetHostInfoParams = { hostId: HostId - packageId: string | null - callback: Callback + packageId?: PackageId + callback?: CallbackId } diff --git a/sdk/lib/osBindings/GetOsAssetParams.ts b/sdk/lib/osBindings/GetOsAssetParams.ts index 9872d0b59..100f711c7 100644 --- a/sdk/lib/osBindings/GetOsAssetParams.ts +++ b/sdk/lib/osBindings/GetOsAssetParams.ts @@ -1,4 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Version } from "./Version" -export type GetOsAssetParams = { version: Version; platform: string } +export type GetOsAssetParams = { version: string; platform: string } diff --git a/sdk/lib/osBindings/GetPrimaryUrlParams.ts b/sdk/lib/osBindings/GetPrimaryUrlParams.ts index dbafa4152..06bf73976 100644 --- a/sdk/lib/osBindings/GetPrimaryUrlParams.ts +++ b/sdk/lib/osBindings/GetPrimaryUrlParams.ts @@ -1,9 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Callback } from "./Callback" -import type { ServiceInterfaceId } from "./ServiceInterfaceId" +import type { CallbackId } from "./CallbackId" +import type { HostId } from "./HostId" +import type { PackageId } from "./PackageId" export type GetPrimaryUrlParams = { - packageId: string | null - serviceInterfaceId: ServiceInterfaceId - callback: Callback + packageId?: PackageId + hostId: HostId + callback?: CallbackId } diff --git a/sdk/lib/osBindings/GetServiceInterfaceParams.ts b/sdk/lib/osBindings/GetServiceInterfaceParams.ts index 0a8bdfcb2..b71591e17 100644 --- a/sdk/lib/osBindings/GetServiceInterfaceParams.ts +++ b/sdk/lib/osBindings/GetServiceInterfaceParams.ts @@ -1,9 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Callback } from "./Callback" +import type { CallbackId } from "./CallbackId" +import type { PackageId } from "./PackageId" import type { ServiceInterfaceId } from "./ServiceInterfaceId" export type GetServiceInterfaceParams = { - packageId: string | null + packageId?: PackageId serviceInterfaceId: ServiceInterfaceId - callback: Callback + callback?: CallbackId } diff --git a/sdk/lib/osBindings/GetServicePortForwardParams.ts b/sdk/lib/osBindings/GetServicePortForwardParams.ts index beb423d9a..63236328e 100644 --- a/sdk/lib/osBindings/GetServicePortForwardParams.ts +++ b/sdk/lib/osBindings/GetServicePortForwardParams.ts @@ -1,8 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { HostId } from "./HostId" +import type { PackageId } from "./PackageId" export type GetServicePortForwardParams = { - packageId: string | null - internalPort: number + packageId?: PackageId hostId: HostId + internalPort: number } diff --git a/sdk/lib/osBindings/GetSslCertificateParams.ts b/sdk/lib/osBindings/GetSslCertificateParams.ts index a33eff540..85c677540 100644 --- a/sdk/lib/osBindings/GetSslCertificateParams.ts +++ b/sdk/lib/osBindings/GetSslCertificateParams.ts @@ -1,8 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Algorithm } from "./Algorithm" +import type { CallbackId } from "./CallbackId" export type GetSslCertificateParams = { - packageId: string | null - hostId: string - algorithm: Algorithm | null + hostnames: string[] + algorithm?: Algorithm + callback?: CallbackId } diff --git a/sdk/lib/osBindings/GetSslKeyParams.ts b/sdk/lib/osBindings/GetSslKeyParams.ts index 0438c345a..2ca3076c8 100644 --- a/sdk/lib/osBindings/GetSslKeyParams.ts +++ b/sdk/lib/osBindings/GetSslKeyParams.ts @@ -1,8 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Algorithm } from "./Algorithm" -export type GetSslKeyParams = { - packageId: string | null - hostId: string - algorithm: Algorithm | null -} +export type GetSslKeyParams = { hostnames: string[]; algorithm?: Algorithm } diff --git a/sdk/lib/osBindings/GetStoreParams.ts b/sdk/lib/osBindings/GetStoreParams.ts index dc3d4e211..e134cd4a6 100644 --- a/sdk/lib/osBindings/GetStoreParams.ts +++ b/sdk/lib/osBindings/GetStoreParams.ts @@ -1,3 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CallbackId } from "./CallbackId" +import type { PackageId } from "./PackageId" -export type GetStoreParams = { packageId: string | null; path: string } +export type GetStoreParams = { + packageId?: PackageId + path: string + callback?: CallbackId +} diff --git a/sdk/lib/osBindings/GetSystemSmtpParams.ts b/sdk/lib/osBindings/GetSystemSmtpParams.ts index 650d59c49..73b91057c 100644 --- a/sdk/lib/osBindings/GetSystemSmtpParams.ts +++ b/sdk/lib/osBindings/GetSystemSmtpParams.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Callback } from "./Callback" +import type { CallbackId } from "./CallbackId" -export type GetSystemSmtpParams = { callback: Callback } +export type GetSystemSmtpParams = { callback: CallbackId | null } diff --git a/sdk/lib/osBindings/HostAddress.ts b/sdk/lib/osBindings/HostAddress.ts index 0388e49c7..73b46d8e5 100644 --- a/sdk/lib/osBindings/HostAddress.ts +++ b/sdk/lib/osBindings/HostAddress.ts @@ -1,3 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HostAddress = { kind: "onion"; address: string } +export type HostAddress = + | { kind: "onion"; address: string } + | { kind: "domain"; address: string } diff --git a/sdk/lib/osBindings/ListServiceInterfacesParams.ts b/sdk/lib/osBindings/ListServiceInterfacesParams.ts index 4140831d0..fd27ace2b 100644 --- a/sdk/lib/osBindings/ListServiceInterfacesParams.ts +++ b/sdk/lib/osBindings/ListServiceInterfacesParams.ts @@ -1,7 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Callback } from "./Callback" +import type { CallbackId } from "./CallbackId" +import type { PackageId } from "./PackageId" export type ListServiceInterfacesParams = { - packageId: string | null - callback: Callback + packageId?: PackageId + callback?: CallbackId } diff --git a/sdk/lib/osBindings/ListVersionSignersParams.ts b/sdk/lib/osBindings/ListVersionSignersParams.ts index d066fbeb4..baf516bf2 100644 --- a/sdk/lib/osBindings/ListVersionSignersParams.ts +++ b/sdk/lib/osBindings/ListVersionSignersParams.ts @@ -1,4 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Version } from "./Version" -export type ListVersionSignersParams = { version: Version } +export type ListVersionSignersParams = { version: string } diff --git a/sdk/lib/osBindings/MountTarget.ts b/sdk/lib/osBindings/MountTarget.ts index e4888d075..bbee5453b 100644 --- a/sdk/lib/osBindings/MountTarget.ts +++ b/sdk/lib/osBindings/MountTarget.ts @@ -1,8 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PackageId } from "./PackageId" +import type { VolumeId } from "./VolumeId" export type MountTarget = { - packageId: string - volumeId: string + packageId: PackageId + volumeId: VolumeId subpath: string | null readonly: boolean } diff --git a/sdk/lib/osBindings/OsIndex.ts b/sdk/lib/osBindings/OsIndex.ts index 9fb795402..fe9a4e395 100644 --- a/sdk/lib/osBindings/OsIndex.ts +++ b/sdk/lib/osBindings/OsIndex.ts @@ -1,5 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { OsVersionInfo } from "./OsVersionInfo" -import type { Version } from "./Version" +import type { OsVersionInfoMap } from "./OsVersionInfoMap" -export type OsIndex = { versions: { [key: Version]: OsVersionInfo } } +export type OsIndex = { versions: OsVersionInfoMap } diff --git a/sdk/lib/osBindings/OsVersionInfoMap.ts b/sdk/lib/osBindings/OsVersionInfoMap.ts new file mode 100644 index 000000000..6f333f1fb --- /dev/null +++ b/sdk/lib/osBindings/OsVersionInfoMap.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { OsVersionInfo } from "./OsVersionInfo" + +export type OsVersionInfoMap = { [key: string]: OsVersionInfo } diff --git a/sdk/lib/osBindings/ParamsMaybePackageId.ts b/sdk/lib/osBindings/ParamsMaybePackageId.ts deleted file mode 100644 index a20bb9aa5..000000000 --- a/sdk/lib/osBindings/ParamsMaybePackageId.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ParamsMaybePackageId = { packageId: string | null } diff --git a/sdk/lib/osBindings/RemoveActionParams.ts b/sdk/lib/osBindings/RemoveActionParams.ts deleted file mode 100644 index c343620b8..000000000 --- a/sdk/lib/osBindings/RemoveActionParams.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RemoveActionParams = { id: string } diff --git a/sdk/lib/osBindings/RemoveAddressParams.ts b/sdk/lib/osBindings/RemoveAddressParams.ts deleted file mode 100644 index 14099ebbc..000000000 --- a/sdk/lib/osBindings/RemoveAddressParams.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ServiceInterfaceId } from "./ServiceInterfaceId" - -export type RemoveAddressParams = { id: ServiceInterfaceId } diff --git a/sdk/lib/osBindings/RemoveVersionParams.ts b/sdk/lib/osBindings/RemoveVersionParams.ts index d00a6ee9e..2c974de56 100644 --- a/sdk/lib/osBindings/RemoveVersionParams.ts +++ b/sdk/lib/osBindings/RemoveVersionParams.ts @@ -1,4 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Version } from "./Version" -export type RemoveVersionParams = { version: Version } +export type RemoveVersionParams = { version: string } diff --git a/sdk/lib/osBindings/ServerInfo.ts b/sdk/lib/osBindings/ServerInfo.ts index 935e3a99f..76840cfc4 100644 --- a/sdk/lib/osBindings/ServerInfo.ts +++ b/sdk/lib/osBindings/ServerInfo.ts @@ -2,6 +2,7 @@ import type { Governor } from "./Governor" import type { IpInfo } from "./IpInfo" import type { ServerStatus } from "./ServerStatus" +import type { SmtpValue } from "./SmtpValue" import type { WifiInfo } from "./WifiInfo" export type ServerInfo = { @@ -28,5 +29,5 @@ export type ServerInfo = { ntpSynced: boolean zram: boolean governor: Governor | null - smtp: string | null + smtp: SmtpValue | null } diff --git a/sdk/lib/osBindings/SetSystemSmtpParams.ts b/sdk/lib/osBindings/SetSystemSmtpParams.ts deleted file mode 100644 index 49c66e86c..000000000 --- a/sdk/lib/osBindings/SetSystemSmtpParams.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SetSystemSmtpParams = { smtp: string } diff --git a/sdk/lib/osBindings/SignAssetParams.ts b/sdk/lib/osBindings/SignAssetParams.ts index d55a061a7..39f54ad69 100644 --- a/sdk/lib/osBindings/SignAssetParams.ts +++ b/sdk/lib/osBindings/SignAssetParams.ts @@ -1,9 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AnySignature } from "./AnySignature" -import type { Version } from "./Version" export type SignAssetParams = { - version: Version + version: string platform: string signature: AnySignature } diff --git a/sdk/lib/osBindings/SmtpValue.ts b/sdk/lib/osBindings/SmtpValue.ts new file mode 100644 index 000000000..5291d6602 --- /dev/null +++ b/sdk/lib/osBindings/SmtpValue.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SmtpValue = { + server: string + port: number + from: string + login: string + password: string | null +} diff --git a/sdk/lib/osBindings/VersionSignerParams.ts b/sdk/lib/osBindings/VersionSignerParams.ts index 102eecefd..781e2a4df 100644 --- a/sdk/lib/osBindings/VersionSignerParams.ts +++ b/sdk/lib/osBindings/VersionSignerParams.ts @@ -1,5 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Guid } from "./Guid" -import type { Version } from "./Version" -export type VersionSignerParams = { version: Version; signer: Guid } +export type VersionSignerParams = { version: string; signer: Guid } diff --git a/sdk/lib/osBindings/index.ts b/sdk/lib/osBindings/index.ts index 70ce65f29..2bf2bf69c 100644 --- a/sdk/lib/osBindings/index.ts +++ b/sdk/lib/osBindings/index.ts @@ -24,11 +24,10 @@ export { BindOptions } from "./BindOptions" export { BindParams } from "./BindParams" export { Blake3Commitment } from "./Blake3Commitment" export { BlockDev } from "./BlockDev" -export { Callback } from "./Callback" +export { CallbackId } from "./CallbackId" export { Category } from "./Category" export { CheckDependenciesParam } from "./CheckDependenciesParam" export { CheckDependenciesResult } from "./CheckDependenciesResult" -export { ChrootParams } from "./ChrootParams" export { Cifs } from "./Cifs" export { ContactInfo } from "./ContactInfo" export { CreateOverlayedImageParams } from "./CreateOverlayedImageParams" @@ -50,6 +49,7 @@ export { ExportServiceInterfaceParams } from "./ExportServiceInterfaceParams" export { ExposeForDependentsParams } from "./ExposeForDependentsParams" export { FullIndex } from "./FullIndex" export { FullProgress } from "./FullProgress" +export { GetConfiguredParams } from "./GetConfiguredParams" export { GetHostInfoParams } from "./GetHostInfoParams" export { GetOsAssetParams } from "./GetOsAssetParams" export { GetOsVersionParams } from "./GetOsVersionParams" @@ -99,6 +99,7 @@ export { MountTarget } from "./MountTarget" export { NamedProgress } from "./NamedProgress" export { OnionHostname } from "./OnionHostname" export { OsIndex } from "./OsIndex" +export { OsVersionInfoMap } from "./OsVersionInfoMap" export { OsVersionInfo } from "./OsVersionInfo" export { PackageDataEntry } from "./PackageDataEntry" export { PackageDetailLevel } from "./PackageDetailLevel" @@ -108,8 +109,6 @@ export { PackageInfoShort } from "./PackageInfoShort" export { PackageInfo } from "./PackageInfo" export { PackageState } from "./PackageState" export { PackageVersionInfo } from "./PackageVersionInfo" -export { ParamsMaybePackageId } from "./ParamsMaybePackageId" -export { ParamsPackageId } from "./ParamsPackageId" export { PasswordType } from "./PasswordType" export { PathOrUrl } from "./PathOrUrl" export { ProcedureId } from "./ProcedureId" @@ -118,8 +117,6 @@ export { Public } from "./Public" export { RecoverySource } from "./RecoverySource" export { RegistryAsset } from "./RegistryAsset" export { RegistryInfo } from "./RegistryInfo" -export { RemoveActionParams } from "./RemoveActionParams" -export { RemoveAddressParams } from "./RemoveAddressParams" export { RemoveVersionParams } from "./RemoveVersionParams" export { RequestCommitment } from "./RequestCommitment" export { Security } from "./Security" @@ -138,13 +135,13 @@ export { SetHealth } from "./SetHealth" export { SetMainStatusStatus } from "./SetMainStatusStatus" export { SetMainStatus } from "./SetMainStatus" export { SetStoreParams } from "./SetStoreParams" -export { SetSystemSmtpParams } from "./SetSystemSmtpParams" export { SetupExecuteParams } from "./SetupExecuteParams" export { SetupProgress } from "./SetupProgress" export { SetupResult } from "./SetupResult" export { SetupStatusRes } from "./SetupStatusRes" export { SignAssetParams } from "./SignAssetParams" export { SignerInfo } from "./SignerInfo" +export { SmtpValue } from "./SmtpValue" export { Status } from "./Status" export { UpdatingState } from "./UpdatingState" export { VerifyCifsParams } from "./VerifyCifsParams" diff --git a/sdk/lib/store/getStore.ts b/sdk/lib/store/getStore.ts index 38265c7fd..5250a02a1 100644 --- a/sdk/lib/store/getStore.ts +++ b/sdk/lib/store/getStore.ts @@ -28,7 +28,6 @@ export class GetStore { return this.effects.store.get({ ...this.options, path: extractJsonPath(this.path), - callback: () => {}, }) } diff --git a/sdk/lib/test/startosTypeValidation.test.ts b/sdk/lib/test/startosTypeValidation.test.ts index 79ec62106..846967c31 100644 --- a/sdk/lib/test/startosTypeValidation.test.ts +++ b/sdk/lib/test/startosTypeValidation.test.ts @@ -2,14 +2,13 @@ import { Effects } from "../types" import { CheckDependenciesParam, ExecuteAction, + GetConfiguredParams, SetMainStatus, } from ".././osBindings" import { CreateOverlayedImageParams } from ".././osBindings" import { DestroyOverlayedImageParams } from ".././osBindings" import { BindParams } from ".././osBindings" import { GetHostInfoParams } from ".././osBindings" -import { ParamsPackageId } from ".././osBindings" -import { ParamsMaybePackageId } from ".././osBindings" import { SetConfigured } from ".././osBindings" import { SetHealth } from ".././osBindings" import { ExposeForDependentsParams } from ".././osBindings" @@ -22,11 +21,12 @@ import { GetServicePortForwardParams } from ".././osBindings" import { ExportServiceInterfaceParams } from ".././osBindings" import { GetPrimaryUrlParams } from ".././osBindings" import { ListServiceInterfacesParams } from ".././osBindings" -import { RemoveAddressParams } from ".././osBindings" import { ExportActionParams } from ".././osBindings" -import { RemoveActionParams } from ".././osBindings" import { MountParams } from ".././osBindings" function typeEquality(_a: ExpectedType) {} + +type WithCallback = Omit & { callback: () => void } + describe("startosTypeValidation ", () => { test(`checking the params match`, () => { const testInput: any = {} @@ -39,32 +39,29 @@ describe("startosTypeValidation ", () => { createOverlayedImage: {} as CreateOverlayedImageParams, destroyOverlayedImage: {} as DestroyOverlayedImageParams, clearBindings: undefined, + getInstalledPackages: undefined, bind: {} as BindParams, - getHostInfo: {} as GetHostInfoParams, - exists: {} as ParamsPackageId, - getConfigured: undefined, - stopped: {} as ParamsMaybePackageId, - running: {} as ParamsPackageId, + getHostInfo: {} as WithCallback, + getConfigured: {} as GetConfiguredParams, restart: undefined, shutdown: undefined, setConfigured: {} as SetConfigured, setHealth: {} as SetHealth, exposeForDependents: {} as ExposeForDependentsParams, - getSslCertificate: {} as GetSslCertificateParams, + getSslCertificate: {} as WithCallback, getSslKey: {} as GetSslKeyParams, - getServiceInterface: {} as GetServiceInterfaceParams, + getServiceInterface: {} as WithCallback, setDependencies: {} as SetDependenciesParams, store: {} as never, - getSystemSmtp: {} as GetSystemSmtpParams, + getSystemSmtp: {} as WithCallback, getContainerIp: undefined, getServicePortForward: {} as GetServicePortForwardParams, clearServiceInterfaces: undefined, exportServiceInterface: {} as ExportServiceInterfaceParams, - getPrimaryUrl: {} as GetPrimaryUrlParams, - listServiceInterfaces: {} as ListServiceInterfacesParams, - removeAddress: {} as RemoveAddressParams, + getPrimaryUrl: {} as WithCallback, + listServiceInterfaces: {} as WithCallback, exportAction: {} as ExportActionParams, - removeAction: {} as RemoveActionParams, + clearActions: undefined, mount: {} as MountParams, checkDependencies: {} as CheckDependenciesParam, getDependencies: undefined, diff --git a/sdk/lib/types.ts b/sdk/lib/types.ts index e9c766448..9a3157ed3 100644 --- a/sdk/lib/types.ts +++ b/sdk/lib/types.ts @@ -13,6 +13,8 @@ import { BindParams, Manifest, CheckDependenciesResult, + ActionId, + HostId, } from "./osBindings" import { MainEffects, ServiceInterfaceType, Signals } from "./StartSdk" @@ -289,187 +291,51 @@ export type PropertiesReturn = { /** Used to reach out from the pure js runtime */ export type Effects = { + // action + + /** Run an action exported by a service */ executeAction(opts: { - serviceId: string | null + packageId?: PackageId + actionId: ActionId input: Input }): Promise + /** Define an action that can be invoked by a user or service */ + exportAction(options: { + id: ActionId + metadata: ActionMetadata + }): Promise + /** Remove all exported actions */ + clearActions(): Promise - /** A low level api used by makeOverlay */ - createOverlayedImage(options: { imageId: string }): Promise<[string, string]> + // config - /** A low level api used by destroyOverlay + makeOverlay:destroy */ - destroyOverlayedImage(options: { guid: string }): Promise - - /** Removes all network bindings */ - clearBindings(): Promise - /** Creates a host connected to the specified port with the provided options */ - bind(options: BindParams): Promise - /** Retrieves the current hostname(s) associated with a host id */ - // getHostInfo(options: { - // kind: "static" | "single" - // serviceInterfaceId: string - // packageId: string | null - // callback: () => void - // }): Promise - getHostInfo(options: { - hostId: string - packageId: string | null - callback: () => void - }): Promise - - // /** - // * Run rsync between two volumes. This is used to backup data between volumes. - // * This is a long running process, and a structure that we can either wait for, or get the progress of. - // */ - // runRsync(options: { - // srcVolume: string - // dstVolume: string - // srcPath: string - // dstPath: string - // // rsync options: https://linux.die.net/man/1/rsync - // options: BackupOptions - // }): { - // id: () => Promise - // wait: () => Promise - // progress: () => Promise - // } - - store: { - /** Get a value in a json like data, can be observed and subscribed */ - get(options: { - /** If there is no packageId it is assumed the current package */ - packageId?: string - /** The path defaults to root level, using the [JsonPath](https://jsonpath.com/) */ - path: StorePath - callback: (config: unknown, previousConfig: unknown) => void - }): Promise - /** Used to store values that can be accessed and subscribed to */ - set(options: { - /** Sets the value for the wrapper at the path, it will override, using the [JsonPath](https://jsonpath.com/) */ - path: StorePath - value: ExtractStore - }): Promise - } - - setMainStatus(o: SetMainStatus): Promise - - getSystemSmtp(input: { - callback: (config: unknown, previousConfig: unknown) => void - }): Promise - - /** Get the IP address of the container */ - getContainerIp(): Promise - /** - * Get the port address for another service - */ - getServicePortForward(options: { - internalPort: number - packageId: string | null - }): Promise - - /** Removes all network interfaces */ - clearServiceInterfaces(): Promise - /** When we want to create a link in the front end interfaces, and example is - * exposing a url to view a web service - */ - exportServiceInterface(options: ExportServiceInterfaceParams): Promise - - exposeForDependents(options: { paths: string[] }): Promise - - /** - * There are times that we want to see the addresses that where exported - * @param options.addressId If we want to filter the address id - * - * Note: any auth should be filtered out already - */ - getServiceInterface(options: { - packageId: PackageId | null - serviceInterfaceId: ServiceInterfaceId - callback: () => void - }): Promise - - /** - * The user sets the primary url for a interface - * @param options - */ - getPrimaryUrl(options: GetPrimaryUrlParams): Promise - - /** - * There are times that we want to see the addresses that where exported - * @param options.addressId If we want to filter the address id - * - * Note: any auth should be filtered out already - */ - listServiceInterfaces(options: { - packageId: PackageId | null - callback: () => void - }): Promise> - - /** - *Remove an address that was exported. Used problably during main or during setConfig. - * @param options - */ - removeAddress(options: { id: string }): Promise - - /** - * - * @param options - */ - exportAction(options: { id: string; metadata: ActionMetadata }): Promise - /** - * Remove an action that was exported. Used problably during main or during setConfig. - */ - removeAction(options: { id: string }): Promise - - getConfigured(): Promise - /** - * This called after a valid set config as well as during init. - * @param configured - */ + /** Returns whether or not the package has been configured */ + getConfigured(options: { packageId?: PackageId }): Promise + /** Indicates that this package has been configured. Called during setConfig or init */ setConfigured(options: { configured: boolean }): Promise - /** - * - * @returns PEM encoded fullchain (ecdsa) - */ - getSslCertificate: (options: { - packageId: string | null - hostId: string - algorithm: "ecdsa" | "ed25519" | null - }) => Promise<[string, string, string]> - /** - * @returns PEM encoded ssl key (ecdsa) - */ - getSslKey: (options: { - packageId: string | null - hostId: string - algorithm: "ecdsa" | "ed25519" | null - }) => Promise + // control - setHealth(o: SetHealth): Promise + /** restart this service's main function */ + restart(): Promise + /** stop this service's main function */ + shutdown(): Promise + /** indicate to the host os what runstate the service is in */ + setMainStatus(options: SetMainStatus): Promise - /** Set the dependencies of what the service needs, usually ran during the set config as a best practice */ + // dependency + + /** Set the dependencies of what the service needs, usually run during the set config as a best practice */ setDependencies(options: { dependencies: Dependencies }): Promise - /** Get the list of the dependencies, both the dynamic set by the effect of setDependencies and the end result any required in the manifest */ getDependencies(): Promise - - /** When one wants to checks the status of several services during the checking of dependencies. The result will include things like the status - * of the service and what the current health checks are. - */ + /** Test whether current dependency requirements are satisfied */ checkDependencies(options: { - packageIds: PackageId[] | null + packageIds?: PackageId[] }): Promise - /** Exists could be useful during the runtime to know if some service exists, option dep */ - exists(options: { packageId: PackageId }): Promise - /** Exists could be useful during the runtime to know if some service is running, option dep */ - running(options: { packageId: PackageId }): Promise - - restart(): Promise - shutdown(): Promise - + /** mount a volume of a dependency */ mount(options: { location: string target: { @@ -479,8 +345,103 @@ export type Effects = { readonly: boolean } }): Promise + /** Returns a list of the ids of all installed packages */ + getInstalledPackages(): Promise + /** grants access to certain paths in the store to dependents */ + exposeForDependents(options: { paths: string[] }): Promise - stopped(options: { packageId: string | null }): Promise + // health + + /** sets the result of a health check */ + setHealth(o: SetHealth): Promise + + // image + + /** A low level api used by Overlay */ + createOverlayedImage(options: { imageId: string }): Promise<[string, string]> + /** A low level api used by Overlay */ + destroyOverlayedImage(options: { guid: string }): Promise + + // net + + // bind + /** Creates a host connected to the specified port with the provided options */ + bind(options: BindParams): Promise + /** Get the port address for a service */ + getServicePortForward(options: { + packageId?: PackageId + hostId: HostId + internalPort: number + }): Promise + /** Removes all network bindings */ + clearBindings(): Promise + // host + /** Returns information about the specified host, if it exists */ + getHostInfo(options: { + packageId?: PackageId + hostId: HostId + callback?: () => void + }): Promise + /** Returns the primary url that a user has selected for a host, if it exists */ + getPrimaryUrl(options: { + packageId?: PackageId + hostId: HostId + callback?: () => void + }): Promise + /** Returns the IP address of the container */ + getContainerIp(): Promise + // interface + /** Creates an interface bound to a specific host and port to show to the user */ + exportServiceInterface(options: ExportServiceInterfaceParams): Promise + /** Returns an exported service interface */ + getServiceInterface(options: { + packageId?: PackageId + serviceInterfaceId: ServiceInterfaceId + callback?: () => void + }): Promise + /** Returns all exported service interfaces for a package */ + listServiceInterfaces(options: { + packageId?: PackageId + callback?: () => void + }): Promise> + /** Removes all service interfaces */ + clearServiceInterfaces(): Promise + // ssl + /** Returns a PEM encoded fullchain for the hostnames specified */ + getSslCertificate: (options: { + hostnames: string[] + algorithm?: "ecdsa" | "ed25519" + callback?: () => void + }) => Promise<[string, string, string]> + /** Returns a PEM encoded private key corresponding to the certificate for the hostnames specified */ + getSslKey: (options: { + hostnames: string[] + algorithm?: "ecdsa" | "ed25519" + }) => Promise + + // store + + store: { + /** Get a value in a json like data, can be observed and subscribed */ + get(options: { + /** If there is no packageId it is assumed the current package */ + packageId?: string + /** The path defaults to root level, using the [JsonPath](https://jsonpath.com/) */ + path: StorePath + callback?: () => void + }): Promise + /** Used to store values that can be accessed and subscribed to */ + set(options: { + /** Sets the value for the wrapper at the path, it will override, using the [JsonPath](https://jsonpath.com/) */ + path: StorePath + value: ExtractStore + }): Promise + } + + // system + + /** Returns globally configured SMTP settings, if they exist */ + getSystemSmtp(options: { callback?: () => void }): Promise } /** rsync options: https://linux.die.net/man/1/rsync diff --git a/sdk/lib/util/GetSslCertificate.ts b/sdk/lib/util/GetSslCertificate.ts new file mode 100644 index 000000000..df19607d4 --- /dev/null +++ b/sdk/lib/util/GetSslCertificate.ts @@ -0,0 +1,47 @@ +import { T } from ".." +import { Effects } from "../types" + +export class GetSslCertificate { + constructor( + readonly effects: Effects, + readonly hostnames: string[], + readonly algorithm?: T.Algorithm, + ) {} + + /** + * Returns the system SMTP credentials. Restarts the service if the credentials change + */ + const() { + return this.effects.getSslCertificate({ + hostnames: this.hostnames, + algorithm: this.algorithm, + callback: this.effects.restart, + }) + } + /** + * Returns the system SMTP credentials. Does nothing if the credentials change + */ + once() { + return this.effects.getSslCertificate({ + hostnames: this.hostnames, + algorithm: this.algorithm, + }) + } + /** + * Watches the system SMTP credentials. Takes a custom callback function to run whenever the credentials change + */ + async *watch() { + while (true) { + let callback: () => void + const waitForNext = new Promise((resolve) => { + callback = resolve + }) + yield await this.effects.getSslCertificate({ + hostnames: this.hostnames, + algorithm: this.algorithm, + callback: () => callback(), + }) + await waitForNext + } + } +} diff --git a/sdk/lib/util/GetSystemSmtp.ts b/sdk/lib/util/GetSystemSmtp.ts index 1853afd78..498a2d8b2 100644 --- a/sdk/lib/util/GetSystemSmtp.ts +++ b/sdk/lib/util/GetSystemSmtp.ts @@ -15,9 +15,7 @@ export class GetSystemSmtp { * Returns the system SMTP credentials. Does nothing if the credentials change */ once() { - return this.effects.getSystemSmtp({ - callback: () => {}, - }) + return this.effects.getSystemSmtp({}) } /** * Watches the system SMTP credentials. Takes a custom callback function to run whenever the credentials change diff --git a/sdk/lib/util/getServiceInterface.ts b/sdk/lib/util/getServiceInterface.ts index a2f17be10..b41d8a15f 100644 --- a/sdk/lib/util/getServiceInterface.ts +++ b/sdk/lib/util/getServiceInterface.ts @@ -55,9 +55,9 @@ export type ServiceInterfaceFilled = { /** Whether or not to mask the URIs for this interface. Useful if the URIs contain sensitive information, such as a password, macaroon, or API key */ masked: boolean /** Information about the host for this binding */ - host: Host + host: Host | null /** URI information */ - addressInfo: FilledAddressInfo + addressInfo: FilledAddressInfo | null /** Indicates if we are a ui/p2p/api for the kind of interface that this is representing */ type: ServiceInterfaceType /** The primary hostname for the service, as chosen by the user */ @@ -183,33 +183,36 @@ const makeInterfaceFilled = async ({ }: { effects: Effects id: string - packageId: string | null - callback: () => void + packageId?: string + callback?: () => void }) => { const serviceInterfaceValue = await effects.getServiceInterface({ serviceInterfaceId: id, packageId, callback, }) + if (!serviceInterfaceValue) { + return null + } const hostId = serviceInterfaceValue.addressInfo.hostId const host = await effects.getHostInfo({ packageId, hostId, callback, }) - const primaryUrl = await effects - .getPrimaryUrl({ - serviceInterfaceId: id, - packageId, - callback, - }) - .catch((e) => null) + const primaryUrl = await effects.getPrimaryUrl({ + hostId, + packageId, + callback, + }) const interfaceFilled: ServiceInterfaceFilled = { ...serviceInterfaceValue, primaryUrl: primaryUrl, host, - addressInfo: filledAddress(host, serviceInterfaceValue.addressInfo), + addressInfo: host + ? filledAddress(host, serviceInterfaceValue.addressInfo) + : null, get primaryHostname() { if (primaryUrl == null) return null return getHostname(primaryUrl) @@ -221,7 +224,7 @@ const makeInterfaceFilled = async ({ export class GetServiceInterface { constructor( readonly effects: Effects, - readonly opts: { id: string; packageId: string | null }, + readonly opts: { id: string; packageId?: string }, ) {} /** @@ -230,7 +233,7 @@ export class GetServiceInterface { async const() { const { id, packageId } = this.opts const callback = this.effects.restart - const interfaceFilled: ServiceInterfaceFilled = await makeInterfaceFilled({ + const interfaceFilled = await makeInterfaceFilled({ effects: this.effects, id, packageId, @@ -244,12 +247,10 @@ export class GetServiceInterface { */ async once() { const { id, packageId } = this.opts - const callback = () => {} - const interfaceFilled: ServiceInterfaceFilled = await makeInterfaceFilled({ + const interfaceFilled = await makeInterfaceFilled({ effects: this.effects, id, packageId, - callback, }) return interfaceFilled @@ -277,7 +278,7 @@ export class GetServiceInterface { } export function getServiceInterface( effects: Effects, - opts: { id: string; packageId: string | null }, + opts: { id: string; packageId?: string }, ) { return new GetServiceInterface(effects, opts) } diff --git a/sdk/lib/util/getServiceInterfaces.ts b/sdk/lib/util/getServiceInterfaces.ts index c4cdc6b59..9f0e242b8 100644 --- a/sdk/lib/util/getServiceInterfaces.ts +++ b/sdk/lib/util/getServiceInterfaces.ts @@ -11,8 +11,8 @@ const makeManyInterfaceFilled = async ({ callback, }: { effects: Effects - packageId: string | null - callback: () => void + packageId?: string + callback?: () => void }) => { const serviceInterfaceValues = await effects.listServiceInterfaces({ packageId, @@ -27,9 +27,12 @@ const makeManyInterfaceFilled = async ({ hostId, callback, }) + if (!host) { + throw new Error(`host ${hostId} not found!`) + } const primaryUrl = await effects .getPrimaryUrl({ - serviceInterfaceId: serviceInterfaceValue.id, + hostId, packageId, callback, }) @@ -52,7 +55,7 @@ const makeManyInterfaceFilled = async ({ export class GetServiceInterfaces { constructor( readonly effects: Effects, - readonly opts: { packageId: string | null }, + readonly opts: { packageId?: string }, ) {} /** @@ -75,12 +78,10 @@ export class GetServiceInterfaces { */ async once() { const { packageId } = this.opts - const callback = () => {} const interfaceFilled: ServiceInterfaceFilled[] = await makeManyInterfaceFilled({ effects: this.effects, packageId, - callback, }) return interfaceFilled @@ -107,7 +108,7 @@ export class GetServiceInterfaces { } export function getServiceInterfaces( effects: Effects, - opts: { packageId: string | null }, + opts: { packageId?: string }, ) { return new GetServiceInterfaces(effects, opts) } diff --git a/sdk/package.json b/sdk/package.json index e03153722..1f9090b14 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@start9labs/start-sdk", - "version": "0.3.6-alpha5", + "version": "0.3.6-alpha6", "description": "Software development kit to facilitate packaging services for StartOS", "main": "./cjs/lib/index.js", "types": "./cjs/lib/index.d.ts", diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 7b0d6b1df..8b4c28d1e 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -74,7 +74,7 @@ export const mockPatchData: DataModel = { platform: 'x86_64-nonfree', zram: true, governor: 'performance', - smtp: 'todo', + smtp: null, wifi: { interface: 'wlan0', ssids: [],