Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/minor

This commit is contained in:
Aiden McClelland
2024-08-19 21:39:45 -06:00
5 changed files with 419 additions and 353 deletions

View File

@@ -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),
},
})),
)
}

View File

@@ -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>(a: U.ResultType<A>): 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<U.ExpectedExports>,
) {}
async init(): Promise<void> {}
async actionsMetadata(effects: T.Effects): Promise<T.ActionMetadata[]> {
const actions = Object.entries(this.manifest.actions ?? {})
return Promise.all(
actions.map(async ([actionId, action]): Promise<T.ActionMetadata> => {
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<void> {}
async exit(): Promise<void> {
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<RpcResult> {
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<unknown> {
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<RpcResult> {
return this.execute(effects, options)
}
private async initProcedure(
async packageInit(
effects: Effects,
previousVersion: Optional<string>,
timeoutMs: number | null,
@@ -489,7 +428,7 @@ export class SystemForEmbassy implements System {
})
}
}
private async uninit(
async packageUninit(
effects: Effects,
nextVersion: Optional<string>,
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<void> {
@@ -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<void> {
@@ -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<T.ConfigRes> {
@@ -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<ReturnType<T.ExpectedExports.properties>> {
): Promise<T.PropertiesReturn> {
// 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<T.ActionResult> {
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<object> {
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,

View File

@@ -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<void> {
throw new Error("Method not implemented.")
}
async packageInit(
effects: Effects,
previousVersion: Optional<string> = null,
timeoutMs: number | null = null,
): Promise<void> {
return void (await this.abi.init({ effects }))
}
async packageUninit(
effects: Effects,
nextVersion: Optional<string> = null,
timeoutMs: number | null = null,
): Promise<void> {
return void (await this.abi.uninit({ effects, nextVersion }))
}
async createBackup(
effects: T.Effects,
timeoutMs: number | null,
): Promise<void> {
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<void> {
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<T.ConfigRes> {
return this.abi.getConfig({ effects })
}
async setConfig(
effects: Effects,
input: { effects: Effects; input: Record<string, unknown> },
timeoutMs: number | null,
): Promise<void> {
const _: unknown = await this.abi.setConfig({ effects, input })
return
}
migration(
effects: Effects,
fromVersion: string,
timeoutMs: number | null,
): Promise<T.MigrationRes> {
throw new Error("Method not implemented.")
}
properties(
effects: Effects,
timeoutMs: number | null,
): Promise<T.PropertiesReturn> {
throw new Error("Method not implemented.")
}
async action(
effects: Effects,
id: string,
formData: unknown,
timeoutMs: number | null,
): Promise<T.ActionResult> {
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<any> {
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<void> {
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<T.ActionMetadata[]> {
return this.abi.actionsMetadata({ effects })
}
async init(): Promise<void> {}
@@ -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<RpcResult> {
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<unknown> {
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<RpcResult> {
return this.execute(effects, options)
}
}

View File

@@ -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<void>
containerInit(): Promise<void>
start(effects: MainEffects): Promise<void>
callCallback(callback: number, args: any[]): void
stop(): Promise<void>
execute(
packageInit(
effects: Effects,
options: {
procedure: Procedure
input: unknown
timeout?: number
},
): Promise<RpcResult>
sandbox(
previousVersion: Optional<string>,
timeoutMs: number | null,
): Promise<void>
packageUninit(
effects: Effects,
options: {
procedure: Procedure
input: unknown
timeout?: number
},
): Promise<RpcResult>
nextVersion: Optional<string>,
timeoutMs: number | null,
): Promise<void>
createBackup(effects: T.Effects, timeoutMs: number | null): Promise<void>
restoreBackup(effects: T.Effects, timeoutMs: number | null): Promise<void>
getConfig(effects: T.Effects, timeoutMs: number | null): Promise<T.ConfigRes>
setConfig(
effects: Effects,
input: { effects: Effects; input: Record<string, unknown> },
timeoutMs: number | null,
): Promise<void>
migration(
effects: Effects,
fromVersion: string,
timeoutMs: number | null,
): Promise<T.MigrationRes>
properties(
effects: Effects,
timeoutMs: number | null,
): Promise<T.PropertiesReturn>
action(
effects: Effects,
actionId: string,
formData: unknown,
timeoutMs: number | null,
): Promise<T.ActionResult>
dependenciesCheck(
effects: Effects,
id: string,
oldConfig: unknown,
timeoutMs: number | null,
): Promise<any>
dependenciesAutoconfig(
effects: Effects,
id: string,
oldConfig: unknown,
timeoutMs: number | null,
): Promise<void>
actionsMetadata(effects: T.Effects): Promise<T.ActionMetadata[]>
exit(): Promise<void>
}

View File

@@ -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