diff --git a/container-runtime/src/Adapters/RpcListener.ts b/container-runtime/src/Adapters/RpcListener.ts index 28f578149..860f1c066 100644 --- a/container-runtime/src/Adapters/RpcListener.ts +++ b/container-runtime/src/Adapters/RpcListener.ts @@ -19,7 +19,7 @@ import * as fs from "fs" import { CallbackHolder } from "../Models/CallbackHolder" import { AllGetDependencies } from "../Interfaces/AllGetDependencies" -import { jsonPath } from "../Models/JsonPath" +import { jsonPath, unNestPath } from "../Models/JsonPath" import { RunningMain, System } from "../Interfaces/System" import { MakeMainEffects, @@ -52,6 +52,8 @@ const SOCKET_PARENT = "/media/startos/rpc" const SOCKET_PATH = "/media/startos/rpc/service.sock" const jsonrpc = "2.0" as const +const isResult = object({ result: any }).test + const idType = some(string, number, literal(null)) type IdType = null | string | number const runType = object({ @@ -64,7 +66,7 @@ const runType = object({ input: any, timeout: number, }, - ["timeout", "input"], + ["timeout"], ), }) const sandboxRunType = object({ @@ -77,7 +79,7 @@ const sandboxRunType = object({ input: any, timeout: number, }, - ["timeout", "input"], + ["timeout"], ), }) const callbackType = object({ @@ -226,27 +228,25 @@ export class RpcListener { const system = this.system const procedure = jsonPath.unsafeCast(params.procedure) const effects = this.getDependencies.makeProcedureEffects()(params.id) - return handleRpc( - id, - system.execute(effects, { - procedure, - input: params.input, - timeout: params.timeout, - }), - ) + const input = params.input + const timeout = params.timeout + const result = getResult(procedure, system, effects, timeout, input) + + return handleRpc(id, result) }) .when(sandboxRunType, async ({ id, params }) => { const system = this.system const procedure = jsonPath.unsafeCast(params.procedure) const effects = this.makeProcedureEffects(params.id) - return handleRpc( - id, - system.sandbox(effects, { - procedure, - input: params.input, - timeout: params.timeout, - }), + const result = getResult( + procedure, + system, + effects, + params.input, + params.input, ) + + return handleRpc(id, result) }) .when(callbackType, async ({ params: { callback, args } }) => { this.system.callCallback(callback, args) @@ -280,7 +280,7 @@ export class RpcListener { (async () => { if (!this._system) { const system = await this.getDependencies.system() - await system.init() + await system.containerInit() this._system = system } })().then((result) => ({ result })), @@ -342,3 +342,97 @@ export class RpcListener { }) } } +function getResult( + procedure: typeof jsonPath._TYPE, + system: System, + effects: T.Effects, + timeout: number | undefined, + input: any, +) { + const ensureResultTypeShape = ( + result: + | void + | T.ConfigRes + | T.PropertiesReturn + | T.ActionMetadata[] + | T.ActionResult, + ): { result: any } => { + if (isResult(result)) return result + return { result } + } + return (async () => { + switch (procedure) { + case "/backup/create": + return system.createBackup(effects, timeout || null) + case "/backup/restore": + return system.restoreBackup(effects, timeout || null) + case "/config/get": + return system.getConfig(effects, timeout || null) + case "/config/set": + return system.setConfig(effects, input, timeout || null) + case "/properties": + return system.properties(effects, timeout || null) + case "/actions/metadata": + return system.actionsMetadata(effects) + case "/init": + return system.packageInit( + effects, + string.optional().unsafeCast(input), + timeout || null, + ) + case "/uninit": + return system.packageUninit( + effects, + string.optional().unsafeCast(input), + timeout || null, + ) + default: + const procedures = unNestPath(procedure) + switch (true) { + case procedures[1] === "actions" && procedures[3] === "get": + return system.action(effects, procedures[2], input, timeout || null) + case procedures[1] === "actions" && procedures[3] === "run": + return system.action(effects, procedures[2], input, timeout || null) + case procedures[1] === "dependencies" && procedures[3] === "query": + return system.dependenciesAutoconfig( + effects, + procedures[2], + input, + timeout || null, + ) + + case procedures[1] === "dependencies" && procedures[3] === "update": + return system.dependenciesAutoconfig( + effects, + procedures[2], + input, + timeout || null, + ) + } + } + })().then(ensureResultTypeShape, (error) => + matches(error) + .when( + object( + { + error: string, + code: number, + }, + ["code"], + { code: 0 }, + ), + (error) => ({ + error: { + code: error.code, + message: error.error, + }, + }), + ) + .defaultToLazy(() => ({ + error: { + code: 0, + message: String(error), + }, + })), + ) +} diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts index 131d912e1..cee873c21 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts @@ -61,6 +61,42 @@ const MANIFEST_LOCATION = "/usr/lib/startos/package/embassyManifest.json" export const EMBASSY_JS_LOCATION = "/usr/lib/startos/package/embassy.js" const EMBASSY_POINTER_PATH_PREFIX = "/embassyConfig" as StorePath +const matchResult = object({ + result: any, +}) +const matchError = object({ + error: string, +}) +const matchErrorCode = object<{ + "error-code": [number, string] | readonly [number, string] +}>({ + "error-code": tuple(number, string), +}) + +const assertNever = ( + x: never, + message = "Not expecting to get here: ", +): never => { + throw new Error(message + JSON.stringify(x)) +} +/** + Should be changing the type for specific properties, and this is mostly a transformation for the old return types to the newer one. +*/ +const fromReturnType = (a: U.ResultType): A => { + if (matchResult.test(a)) { + return a.result + } + if (matchError.test(a)) { + console.info({ passedErrorStack: new Error().stack, error: a.error }) + throw { error: a.error } + } + if (matchErrorCode.test(a)) { + const [code, message] = a["error-code"] + throw { error: message, code } + } + return assertNever(a) +} + const matchSetResult = object( { "depends-on": dictionary([string, array(string)]), @@ -206,12 +242,49 @@ export class SystemForEmbassy implements System { moduleCode, ) } + constructor( readonly manifest: Manifest, readonly moduleCode: Partial, ) {} - async init(): Promise {} + async actionsMetadata(effects: T.Effects): Promise { + const actions = Object.entries(this.manifest.actions ?? {}) + return Promise.all( + actions.map(async ([actionId, action]): Promise => { + const name = action.name ?? actionId + const description = action.description + const warning = action.warning ?? null + const disabled = false + const input = (await convertToNewConfig(action["input-spec"] as any)) + .spec + const hasRunning = !!action["allowed-statuses"].find( + (x) => x === "running", + ) + const hasStopped = !!action["allowed-statuses"].find( + (x) => x === "stopped", + ) + // prettier-ignore + const allowedStatuses = + hasRunning && hasStopped ? "any": + hasRunning ? "onlyRunning" : + "onlyStopped" + + const group = null + return { + name, + description, + warning, + disabled, + allowedStatuses, + group, + input, + } + }), + ) + } + + async containerInit(): Promise {} async exit(): Promise { if (this.currentRunning) await this.currentRunning.clean() @@ -235,141 +308,7 @@ export class SystemForEmbassy implements System { } } - async execute( - effects: Effects, - options: { - procedure: JsonPath - input?: unknown - timeout?: number | undefined - }, - ): Promise { - return this._execute(effects, options) - .then((x) => - matches(x) - .when( - object({ - result: any, - }), - (x) => x, - ) - .when( - object({ - error: string, - }), - (x) => ({ - error: { - code: 0, - message: x.error, - }, - }), - ) - .when( - object({ - "error-code": tuple(number, string), - }), - ({ "error-code": [code, message] }) => ({ - error: { - code, - message, - }, - }), - ) - .defaultTo({ result: x }), - ) - .catch((error: unknown) => { - if (error instanceof Error) - return { - error: { - code: 0, - message: error.name, - data: { - details: error.message, - debug: `${error?.cause ?? "[noCause]"}:${error?.stack ?? "[noStack]"}`, - }, - }, - } - if (matchRpcResult.test(error)) return error - return { - error: { - code: 0, - message: String(error), - }, - } - }) - } - async _execute( - effects: Effects, - options: { - procedure: JsonPath - input?: unknown - timeout?: number | undefined - }, - ): Promise { - const input = options.input - switch (options.procedure) { - case "/backup/create": - return this.createBackup(effects, options.timeout || null) - case "/backup/restore": - return this.restoreBackup(effects, options.timeout || null) - case "/config/get": - return this.getConfig(effects, options.timeout || null) - case "/config/set": - return this.setConfig(effects, input, options.timeout || null) - case "/properties": - return this.properties(effects, options.timeout || null) - case "/actions/metadata": - return todo() - case "/init": - return this.initProcedure( - effects, - string.optional().unsafeCast(input), - options.timeout || null, - ) - case "/uninit": - return this.uninit( - effects, - string.optional().unsafeCast(input), - options.timeout || null, - ) - default: - const procedures = unNestPath(options.procedure) - switch (true) { - case procedures[1] === "actions" && procedures[3] === "get": - return this.action( - effects, - procedures[2], - input, - options.timeout || null, - ) - case procedures[1] === "actions" && procedures[3] === "run": - return this.action( - effects, - procedures[2], - input, - options.timeout || null, - ) - case procedures[1] === "dependencies" && procedures[3] === "query": - return null - - case procedures[1] === "dependencies" && procedures[3] === "update": - return this.dependenciesAutoconfig( - effects, - procedures[2], - input, - options.timeout || null, - ) - } - } - throw new Error(`Could not find the path for ${options.procedure}`) - } - async sandbox( - effects: Effects, - options: { procedure: Procedure; input: unknown; timeout?: number }, - ): Promise { - return this.execute(effects, options) - } - - private async initProcedure( + async packageInit( effects: Effects, previousVersion: Optional, timeoutMs: number | null, @@ -489,7 +428,7 @@ export class SystemForEmbassy implements System { }) } } - private async uninit( + async packageUninit( effects: Effects, nextVersion: Optional, timeoutMs: number | null, @@ -498,7 +437,7 @@ export class SystemForEmbassy implements System { await effects.setMainStatus({ status: "stopped" }) } - private async createBackup( + async createBackup( effects: Effects, timeoutMs: number | null, ): Promise { @@ -519,7 +458,7 @@ export class SystemForEmbassy implements System { await moduleCode.createBackup?.(polyfillEffects(effects, this.manifest)) } } - private async restoreBackup( + async restoreBackup( effects: Effects, timeoutMs: number | null, ): Promise { @@ -543,7 +482,7 @@ export class SystemForEmbassy implements System { await moduleCode.restoreBackup?.(polyfillEffects(effects, this.manifest)) } } - private async getConfig( + async getConfig( effects: Effects, timeoutMs: number | null, ): Promise { @@ -584,7 +523,7 @@ export class SystemForEmbassy implements System { )) as any } } - private async setConfig( + async setConfig( effects: Effects, newConfigWithoutPointers: unknown, timeoutMs: number | null, @@ -676,7 +615,7 @@ export class SystemForEmbassy implements System { }) } - private async migration( + async migration( effects: Effects, fromVersion: string, timeoutMs: number | null, @@ -748,10 +687,10 @@ export class SystemForEmbassy implements System { } return { configured: true } } - private async properties( + async properties( effects: Effects, timeoutMs: number | null, - ): Promise> { + ): Promise { // TODO BLU-J set the properties ever so often const setConfigValue = this.manifest.properties if (!setConfigValue) throw new Error("There is no properties") @@ -779,36 +718,81 @@ export class SystemForEmbassy implements System { if (!method) throw new Error("Expecting that the method properties exists") const properties = matchProperties.unsafeCast( - await method(polyfillEffects(effects, this.manifest)).then((x) => { - if ("result" in x) return x.result - if ("error" in x) throw new Error("Error getting config: " + x.error) - throw new Error("Error getting config: " + x["error-code"][1]) - }), + await method(polyfillEffects(effects, this.manifest)).then( + fromReturnType, + ), ) return asProperty(properties.data) } throw new Error(`Unknown type in the fetch properties: ${setConfigValue}`) } - private async action( + async action( effects: Effects, actionId: string, formData: unknown, timeoutMs: number | null, ): Promise { const actionProcedure = this.manifest.actions?.[actionId]?.implementation - if (!actionProcedure) return { message: "Action not found", value: null } + const toActionResult = ({ + message, + value = "", + copyable, + qr, + }: U.ActionResult): T.ActionResult => ({ + version: "0", + message, + value, + copyable, + qr, + }) + if (!actionProcedure) throw Error("Action not found") + if (actionProcedure.type === "docker") { + const container = await DockerProcedureContainer.of( + effects, + this.manifest.id, + actionProcedure, + this.manifest.volumes, + ) + return toActionResult( + JSON.parse( + ( + await container.execFail( + [ + actionProcedure.entrypoint, + ...actionProcedure.args, + JSON.stringify(formData), + ], + timeoutMs, + ) + ).stdout.toString(), + ), + ) + } else { + const moduleCode = await this.moduleCode + const method = moduleCode.action?.[actionId] + if (!method) throw new Error("Expecting that the method action exists") + return await method( + polyfillEffects(effects, this.manifest), + formData as any, + ) + .then(fromReturnType) + .then(toActionResult) + } + } + async dependenciesCheck( + effects: Effects, + id: string, + oldConfig: unknown, + timeoutMs: number | null, + ): Promise { + const actionProcedure = this.manifest.dependencies?.[id]?.config?.check + if (!actionProcedure) return { message: "Action not found", value: null } if (actionProcedure.type === "docker") { - const overlay = actionProcedure.inject - ? this.currentRunning?.mainOverlay - : undefined const container = await DockerProcedureContainer.of( effects, this.manifest.id, actionProcedure, this.manifest.volumes, - { - overlay, - }, ) return JSON.parse( ( @@ -816,27 +800,32 @@ export class SystemForEmbassy implements System { [ actionProcedure.entrypoint, ...actionProcedure.args, - JSON.stringify(formData), + JSON.stringify(oldConfig), ], timeoutMs, ) ).stdout.toString(), ) - } else { + } else if (actionProcedure.type === "script") { const moduleCode = await this.moduleCode - const method = moduleCode.action?.[actionId] - if (!method) throw new Error("Expecting that the method action exists") + const method = moduleCode.dependencies?.[id]?.check + if (!method) + throw new Error( + `Expecting that the method dependency check ${id} exists`, + ) return (await method( polyfillEffects(effects, this.manifest), - formData as any, + oldConfig as any, ).then((x) => { if ("result" in x) return x.result if ("error" in x) throw new Error("Error getting config: " + x.error) throw new Error("Error getting config: " + x["error-code"][1]) })) as any + } else { + return {} } } - private async dependenciesAutoconfig( + async dependenciesAutoconfig( effects: Effects, id: string, input: unknown, diff --git a/container-runtime/src/Adapters/Systems/SystemForStartOs.ts b/container-runtime/src/Adapters/Systems/SystemForStartOs.ts index be7b0fc84..51d91abb5 100644 --- a/container-runtime/src/Adapters/Systems/SystemForStartOs.ts +++ b/container-runtime/src/Adapters/Systems/SystemForStartOs.ts @@ -8,6 +8,7 @@ import { T, utils } from "@start9labs/start-sdk" import { Volume } from "../../Models/Volume" import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk" import { CallbackHolder } from "../../Models/CallbackHolder" +import { Optional } from "ts-matches/lib/parsers/interfaces" export const STARTOS_JS_LOCATION = "/usr/lib/startos/package/index.js" @@ -25,6 +26,107 @@ export class SystemForStartOs implements System { } constructor(readonly abi: T.ABI) {} + containerInit(): Promise { + throw new Error("Method not implemented.") + } + async packageInit( + effects: Effects, + previousVersion: Optional = null, + timeoutMs: number | null = null, + ): Promise { + return void (await this.abi.init({ effects })) + } + async packageUninit( + effects: Effects, + nextVersion: Optional = null, + timeoutMs: number | null = null, + ): Promise { + return void (await this.abi.uninit({ effects, nextVersion })) + } + async createBackup( + effects: T.Effects, + timeoutMs: number | null, + ): Promise { + return void (await this.abi.createBackup({ + effects, + pathMaker: ((options) => + new Volume(options.volume, options.path).path) as T.PathMaker, + })) + } + async restoreBackup( + effects: T.Effects, + timeoutMs: number | null, + ): Promise { + return void (await this.abi.restoreBackup({ + effects, + pathMaker: ((options) => + new Volume(options.volume, options.path).path) as T.PathMaker, + })) + } + getConfig( + effects: T.Effects, + timeoutMs: number | null, + ): Promise { + return this.abi.getConfig({ effects }) + } + async setConfig( + effects: Effects, + input: { effects: Effects; input: Record }, + timeoutMs: number | null, + ): Promise { + const _: unknown = await this.abi.setConfig({ effects, input }) + return + } + migration( + effects: Effects, + fromVersion: string, + timeoutMs: number | null, + ): Promise { + throw new Error("Method not implemented.") + } + properties( + effects: Effects, + timeoutMs: number | null, + ): Promise { + throw new Error("Method not implemented.") + } + async action( + effects: Effects, + id: string, + formData: unknown, + timeoutMs: number | null, + ): Promise { + const action = (await this.abi.actions({ effects }))[id] + if (!action) throw new Error(`Action ${id} not found`) + return action.run({ effects }) + } + dependenciesCheck( + effects: Effects, + id: string, + oldConfig: unknown, + timeoutMs: number | null, + ): Promise { + const dependencyConfig = this.abi.dependencyConfig[id] + if (!dependencyConfig) throw new Error(`dependencyConfig ${id} not found`) + return dependencyConfig.query({ effects }) + } + async dependenciesAutoconfig( + effects: Effects, + id: string, + remoteConfig: unknown, + timeoutMs: number | null, + ): Promise { + const dependencyConfig = this.abi.dependencyConfig[id] + if (!dependencyConfig) throw new Error(`dependencyConfig ${id} not found`) + const queryResults = await this.getConfig(effects, timeoutMs) + return void (await dependencyConfig.update({ + queryResults, + remoteConfig, + })) // TODO + } + async actionsMetadata(effects: T.Effects): Promise { + return this.abi.actionsMetadata({ effects }) + } async init(): Promise {} @@ -72,155 +174,4 @@ export class SystemForStartOs implements System { this.runningMain = undefined } } - - async execute( - effects: Effects, - options: { - procedure: Procedure - input?: unknown - timeout?: number | undefined - }, - ): Promise { - return this._execute(effects, options) - .then((x) => - matches(x) - .when( - object({ - result: any, - }), - (x) => x, - ) - .when( - object({ - error: string, - }), - (x) => ({ - error: { - code: 0, - message: x.error, - }, - }), - ) - .when( - object({ - "error-code": tuple(number, string), - }), - ({ "error-code": [code, message] }) => ({ - error: { - code, - message, - }, - }), - ) - .defaultTo({ result: x }), - ) - .catch((error: unknown) => { - if (error instanceof Error) - return { - error: { - code: 0, - message: error.name, - data: { - details: error.message, - debug: `${error?.cause ?? "[noCause]"}:${error?.stack ?? "[noStack]"}`, - }, - }, - } - if (matchRpcResult.test(error)) return error - return { - error: { - code: 0, - message: String(error), - }, - } - }) - } - async _execute( - effects: Effects | MainEffects, - options: { - procedure: Procedure - input?: unknown - timeout?: number | undefined - }, - ): Promise { - switch (options.procedure) { - case "/init": { - return this.abi.init({ effects }) - } - case "/uninit": { - const nextVersion = string.optional().unsafeCast(options.input) || null - return this.abi.uninit({ effects, nextVersion }) - } - // 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 }) - } - case "/config/get": { - return this.abi.getConfig({ effects }) - } - case "/backup/create": - return this.abi.createBackup({ - effects, - pathMaker: ((options) => - new Volume(options.volume, options.path).path) as T.PathMaker, - }) - case "/backup/restore": - return this.abi.restoreBackup({ - effects, - pathMaker: ((options) => - new Volume(options.volume, options.path).path) as T.PathMaker, - }) - case "/actions/metadata": { - return this.abi.actionsMetadata({ effects }) - } - case "/properties": { - throw new Error("TODO") - } - default: - const procedures = unNestPath(options.procedure) - const id = procedures[2] - switch (true) { - case procedures[1] === "actions" && procedures[3] === "get": { - const action = (await this.abi.actions({ effects }))[id] - if (!action) throw new Error(`Action ${id} not found`) - return action.getConfig({ effects }) - } - case procedures[1] === "actions" && procedures[3] === "run": { - const action = (await this.abi.actions({ effects }))[id] - if (!action) throw new Error(`Action ${id} not found`) - return action.run({ effects, input: options.input as any }) // TODO - } - case procedures[1] === "dependencies" && procedures[3] === "query": { - const dependencyConfig = this.abi.dependencyConfig[id] - if (!dependencyConfig) - throw new Error(`dependencyConfig ${id} not found`) - const localConfig = options.input - return dependencyConfig.query({ effects }) - } - case procedures[1] === "dependencies" && procedures[3] === "update": { - const dependencyConfig = this.abi.dependencyConfig[id] - if (!dependencyConfig) - throw new Error(`dependencyConfig ${id} not found`) - return dependencyConfig.update(options.input as any) // TODO - } - } - return - } - } - - async sandbox( - effects: Effects, - options: { procedure: Procedure; input?: unknown; timeout?: number }, - ): Promise { - return this.execute(effects, options) - } } diff --git a/container-runtime/src/Interfaces/System.ts b/container-runtime/src/Interfaces/System.ts index 01fd3c5ff..1348b79e9 100644 --- a/container-runtime/src/Interfaces/System.ts +++ b/container-runtime/src/Interfaces/System.ts @@ -3,6 +3,7 @@ import { RpcResult } from "../Adapters/RpcListener" import { Effects } from "../Models/Effects" import { CallbackHolder } from "../Models/CallbackHolder" import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk" +import { Optional } from "ts-matches/lib/parsers/interfaces" export type Procedure = | "/init" @@ -22,28 +23,60 @@ export type ExecuteResult = | { ok: unknown } | { err: { code: number; message: string } } export type System = { - init(): Promise + containerInit(): Promise start(effects: MainEffects): Promise callCallback(callback: number, args: any[]): void stop(): Promise - execute( + packageInit( effects: Effects, - options: { - procedure: Procedure - input: unknown - timeout?: number - }, - ): Promise - sandbox( + previousVersion: Optional, + timeoutMs: number | null, + ): Promise + packageUninit( effects: Effects, - options: { - procedure: Procedure - input: unknown - timeout?: number - }, - ): Promise + nextVersion: Optional, + timeoutMs: number | null, + ): Promise + + createBackup(effects: T.Effects, timeoutMs: number | null): Promise + restoreBackup(effects: T.Effects, timeoutMs: number | null): Promise + getConfig(effects: T.Effects, timeoutMs: number | null): Promise + setConfig( + effects: Effects, + input: { effects: Effects; input: Record }, + timeoutMs: number | null, + ): Promise + migration( + effects: Effects, + fromVersion: string, + timeoutMs: number | null, + ): Promise + properties( + effects: Effects, + timeoutMs: number | null, + ): Promise + action( + effects: Effects, + actionId: string, + formData: unknown, + timeoutMs: number | null, + ): Promise + + dependenciesCheck( + effects: Effects, + id: string, + oldConfig: unknown, + timeoutMs: number | null, + ): Promise + dependenciesAutoconfig( + effects: Effects, + id: string, + oldConfig: unknown, + timeoutMs: number | null, + ): Promise + actionsMetadata(effects: T.Effects): Promise exit(): Promise } diff --git a/sdk/lib/types.ts b/sdk/lib/types.ts index 2611a0b84..d448b2255 100644 --- a/sdk/lib/types.ts +++ b/sdk/lib/types.ts @@ -476,12 +476,11 @@ export type MigrationRes = { } export type ActionResult = { + version: "0" message: string - value: null | { - value: string - copyable: boolean - qr: boolean - } + value: string | null + copyable: boolean + qr: boolean } export type SetResult = { dependsOn: DependsOn