Feature/lxc container runtime (#2514)

* wip: static-server errors

* wip: fix wifi

* wip: Fix the service_effects

* wip: Fix cors in the middleware

* wip(chore): Auth clean up the lint.

* wip(fix): Vhost

* wip: continue manager refactor

Co-authored-by: J H <Blu-J@users.noreply.github.com>

* wip: service manager refactor

* wip: Some fixes

* wip(fix): Fix the lib.rs

* wip

* wip(fix): Logs

* wip: bins

* wip(innspect): Add in the inspect

* wip: config

* wip(fix): Diagnostic

* wip(fix): Dependencies

* wip: context

* wip(fix) Sorta auth

* wip: warnings

* wip(fix): registry/admin

* wip(fix) marketplace

* wip(fix) Some more converted and fixed with the linter and config

* wip: Working on the static server

* wip(fix)static server

* wip: Remove some asynnc

* wip: Something about the request and regular rpc

* wip: gut install

Co-authored-by: J H <Blu-J@users.noreply.github.com>

* wip: Convert the static server into the new system

* wip delete file

* test

* wip(fix) vhost does not need the with safe defaults

* wip: Adding in the wifi

* wip: Fix the developer and the verify

* wip: new install flow

Co-authored-by: J H <Blu-J@users.noreply.github.com>

* fix middleware

* wip

* wip: Fix the auth

* wip

* continue service refactor

* feature: Service get_config

* feat: Action

* wip: Fighting the great fight against the borrow checker

* wip: Remove an error in a file that I just need to deel with later

* chore: Add in some more lifetime stuff to the services

* wip: Install fix on lifetime

* cleanup

* wip: Deal with the borrow later

* more cleanup

* resolve borrowchecker errors

* wip(feat): add in the handler for the socket, for now

* wip(feat): Update the service_effect_handler::action

* chore: Add in the changes to make sure the from_service goes to context

* chore: Change the

* refactor service map

* fix references to service map

* fill out restore

* wip: Before I work on the store stuff

* fix backup module

* handle some warnings

* feat: add in the ui components on the rust side

* feature: Update the procedures

* chore: Update the js side of the main and a few of the others

* chore: Update the rpc listener to match the persistant container

* wip: Working on updating some things to have a better name

* wip(feat): Try and get the rpc to return the correct shape?

* lxc wip

* wip(feat): Try and get the rpc to return the correct shape?

* build for container runtime wip

* remove container-init

* fix build

* fix error

* chore: Update to work I suppose

* lxc wip

* remove docker module and feature

* download alpine squashfs automatically

* overlays effect

Co-authored-by: Jade <Blu-J@users.noreply.github.com>

* chore: Add the overlay effect

* feat: Add the mounter in the main

* chore: Convert to use the mounts, still need to work with the sandbox

* install fixes

* fix ssl

* fixes from testing

* implement tmpfile for upload

* wip

* misc fixes

* cleanup

* cleanup

* better progress reporting

* progress for sideload

* return real guid

* add devmode script

* fix lxc rootfs path

* fix percentage bar

* fix progress bar styling

* fix build for unstable

* tweaks

* label progress

* tweaks

* update progress more often

* make symlink in rpc_client

* make socket dir

* fix parent path

* add start-cli to container

* add echo and gitInfo commands

* wip: Add the init + errors

* chore: Add in the exit effect for the system

* chore: Change the type to null for failure to parse

* move sigterm timeout to stopping status

* update order

* chore: Update the return type

* remove dbg

* change the map error

* chore: Update the thing to capture id

* chore add some life changes

* chore: Update the loging

* chore: Update the package to run module

* us From for RpcError

* chore: Update to use import instead

* chore: update

* chore: Use require for the backup

* fix a default

* update the type that is wrong

* chore: Update the type of the manifest

* chore: Update to make null

* only symlink if not exists

* get rid of double result

* better debug info for ErrorCollection

* chore: Update effects

* chore: fix

* mount assets and volumes

* add exec instead of spawn

* fix mounting in image

* fix overlay mounts

Co-authored-by: Jade <Blu-J@users.noreply.github.com>

* misc fixes

* feat: Fix two

* fix: systemForEmbassy main

* chore: Fix small part of main loop

* chore: Modify the bundle

* merge

* fixMain loop"

* move tsc to makefile

* chore: Update the return types of the health check

* fix client

* chore: Convert the todo to use tsmatches

* add in the fixes for the seen and create the hack to allow demo

* chore: Update to include the systemForStartOs

* chore UPdate to the latest types from the expected outout

* fixes

* fix typo

* Don't emit if failure on tsc

* wip

Co-authored-by: Jade <Blu-J@users.noreply.github.com>

* add s9pk api

* add inspection

* add inspect manifest

* newline after display serializable

* fix squashfs in image name

* edit manifest

Co-authored-by: Jade <Blu-J@users.noreply.github.com>

* wait for response on repl

* ignore sig for now

* ignore sig for now

* re-enable sig verification

* fix

* wip

* env and chroot

* add profiling logs

* set uid & gid in squashfs to 100000

* set uid of sqfs to 100000

* fix mksquashfs args

* add env to compat

* fix

* re-add docker feature flag

* fix docker output format being stupid

* here be dragons

* chore: Add in the cross compiling for something

* fix npm link

* extract logs from container on exit

* chore: Update for testing

* add log capture to drop trait

* chore: add in the modifications that I make

* chore: Update small things for no updates

* chore: Update the types of something

* chore: Make main not complain

* idmapped mounts

* idmapped volumes

* re-enable kiosk

* chore: Add in some logging for the new system

* bring in start-sdk

* remove avahi

* chore: Update the deps

* switch to musl

* chore: Update the version of prettier

* chore: Organize'

* chore: Update some of the headers back to the standard of fetch

* fix musl build

* fix idmapped mounts

* fix cross build

* use cross compiler for correct arch

* feat: Add in the faked ssl stuff for the effects

* @dr_bonez Did a solution here

* chore: Something that DrBonez

* chore: up

* wip: We have a working server!!!

* wip

* uninstall

* wip

* tes

---------

Co-authored-by: J H <dragondef@gmail.com>
Co-authored-by: J H <Blu-J@users.noreply.github.com>
Co-authored-by: J H <2364004+Blu-J@users.noreply.github.com>
This commit is contained in:
Aiden McClelland
2024-02-17 11:14:14 -07:00
committed by GitHub
parent 65009e2f69
commit fab13db4b4
326 changed files with 31708 additions and 13987 deletions

View File

@@ -3,4 +3,6 @@ dist/
bundle.js
startInit.js
service/
service.js
service.js
alpine.squashfs
/tmp

View File

@@ -0,0 +1,4 @@
FROM node:18-alpine
ADD ./startInit.js /usr/local/lib/startInit.js
ADD ./entrypoint.sh /usr/local/bin/entrypoint.sh

View File

@@ -0,0 +1,59 @@
# Container RPC SERVER Specification
## Methods
### init
initialize runtime (mount `/proc`, `/sys`, `/dev`, and `/run` to each image in `/media/images`)
called after os has mounted js and images to the container
#### args
`[]`
#### response
`null`
### exit
shutdown runtime
#### args
`[]`
#### response
`null`
### start
run main method if not already running
#### args
`[]`
#### response
`null`
### stop
stop main method by sending SIGTERM to child processes, and SIGKILL after timeout
#### args
`{ timeout: millis }`
#### response
`null`
### execute
run a specific package procedure
#### args
```ts
{
procedure: JsonPath,
input: any,
timeout: millis,
}
```
#### response
`any`
### sandbox
run a specific package procedure in sandbox mode
#### args
```ts
{
procedure: JsonPath,
input: any,
timeout: millis,
}
```
#### response
`any`

View File

@@ -0,0 +1,10 @@
#!/sbin/openrc-run
name=containerRuntime
#cfgfile="/etc/containerRuntime/containerRuntime.conf"
command="/usr/bin/node"
command_args="--experimental-detect-module --unhandled-rejections=warn /usr/lib/startos/init/index.js"
pidfile="/run/containerRuntime.pid"
command_background="yes"
output_log="/var/log/containerRuntime.log"
error_log="/var/log/containerRuntime.err"

View File

@@ -0,0 +1,18 @@
#!/bin/bash
cd "$(dirname "${BASH_SOURCE[0]}")"
set -e
DISTRO=alpine
VERSION=3.19
ARCH=${ARCH:-$(uname -m)}
FLAVOR=default
if [ "$ARCH" = "x86_64" ]; then
ARCH=amd64
elif [ "$ARCH" = "aarch64" ]; then
ARCH=arm64
fi
curl https://images.linuxcontainers.org/$(curl --silent https://images.linuxcontainers.org/meta/1.0/index-system | grep "^$DISTRO;$VERSION;$ARCH;$FLAVOR;" | head -n1 | sed 's/^.*;//g')/rootfs.squashfs --output alpine.squashfs

View File

@@ -1,22 +0,0 @@
export class CallbackHolder {
constructor() {
}
private root = (Math.random() + 1).toString(36).substring(7);
private inc = 0
private callbacks = new Map<string, Function>()
private newId() {
return this.root + (this.inc++).toString(36)
}
addCallback(callback: Function) {
return this.callbacks.set(this.newId(), callback);
}
callCallback(index: string, args: any[]): Promise<unknown> {
const callback = this.callbacks.get(index)
if (!callback) throw new Error(`Callback ${index} does not exist`)
this.callbacks.delete(index)
return Promise.resolve().then(() => callback(...args))
}
}

View File

@@ -1,184 +0,0 @@
import * as T from "@start9labs/start-sdk/lib/types"
import * as net from "net"
import { CallbackHolder } from "./CallbackHolder"
const SOCKET_PATH = "/start9/sockets/startDaemon.sock"
const MAIN = "main" as const
export class Effects implements T.Effects {
constructor(readonly method: string, readonly callbackHolder: CallbackHolder) {}
id = 0
rpcRound(method: string, params: unknown) {
const id = this.id++;
const client = net.createConnection(SOCKET_PATH, () => {
client.write(JSON.stringify({
id,
method,
params
}));
});
return new Promise((resolve, reject) => {
client.on('data', (data) => {
try {
resolve(JSON.parse(data.toString())?.result)
} catch (error) {
reject(error)
}
client.end();
});
})
}
started= this.method !== MAIN ? null : ()=> {
return this.rpcRound('started', null)
}
bind(...[options]: Parameters<T.Effects["bind"]>) {
return this.rpcRound('bind', (options)) as ReturnType<T.Effects["bind"]>
}
clearBindings(...[]: Parameters<T.Effects["clearBindings"]>) {
return this.rpcRound('clearBindings', null) as ReturnType<T.Effects["clearBindings"]>
}
clearNetworkInterfaces(
...[]: Parameters<T.Effects["clearNetworkInterfaces"]>
) {
return this.rpcRound('clearNetworkInterfaces', null) as ReturnType<T.Effects["clearNetworkInterfaces"]>
}
executeAction(...[options]: Parameters<T.Effects["executeAction"]>) {
return this.rpcRound('executeAction', options) as ReturnType<T.Effects["executeAction"]>
}
exists(...[packageId]: Parameters<T.Effects["exists"]>) {
return this.rpcRound('exists', packageId) as ReturnType<T.Effects["exists"]>
}
exportAction(...[options]: Parameters<T.Effects["exportAction"]>) {
return this.rpcRound('exportAction', (options)) as ReturnType<T.Effects["exportAction"]>
}
exportNetworkInterface(
...[options]: Parameters<T.Effects["exportNetworkInterface"]>
) {
return this.rpcRound('exportNetworkInterface', (options)) as ReturnType<T.Effects["exportNetworkInterface"]>
}
exposeForDependents(...[options]: any) {
return this.rpcRound('exposeForDependents', (null)) as ReturnType<T.Effects["exposeForDependents"]>
}
exposeUi(...[options]: Parameters<T.Effects["exposeUi"]>) {
return this.rpcRound('exposeUi', (options)) as ReturnType<T.Effects["exposeUi"]>
}
getConfigured(...[]: Parameters<T.Effects["getConfigured"]>) {
return this.rpcRound('getConfigured',null) as ReturnType<T.Effects["getConfigured"]>
}
getContainerIp(...[]: Parameters<T.Effects["getContainerIp"]>) {
return this.rpcRound('getContainerIp', null) as ReturnType<T.Effects["getContainerIp"]>
}
getHostnames: any = (...[allOptions]: any[]) => {
const options = {
...allOptions,
callback: this.callbackHolder.addCallback(allOptions.callback)
}
return this.rpcRound('getHostnames', options) as ReturnType<T.Effects["getHostnames"]>
}
getInterface(...[options]: Parameters<T.Effects["getInterface"]>) {
return this.rpcRound('getInterface', {...options, callback: this.callbackHolder.addCallback(options.callback)}) as ReturnType<T.Effects["getInterface"]>
}
getIPHostname(...[]: Parameters<T.Effects["getIPHostname"]>) {
return this.rpcRound('getIPHostname', (null)) as ReturnType<T.Effects["getIPHostname"]>
}
getLocalHostname(...[]: Parameters<T.Effects["getLocalHostname"]>) {
return this.rpcRound('getLocalHostname', null) as ReturnType<T.Effects["getLocalHostname"]>
}
getPrimaryUrl(...[options]: Parameters<T.Effects["getPrimaryUrl"]>) {
return this.rpcRound('getPrimaryUrl', {...options, callback: this.callbackHolder.addCallback(options.callback)}) as ReturnType<T.Effects["getPrimaryUrl"]>
}
getServicePortForward(
...[options]: Parameters<T.Effects["getServicePortForward"]>
) {
return this.rpcRound('getServicePortForward', (options)) as ReturnType<T.Effects["getServicePortForward"]>
}
getServiceTorHostname(
...[interfaceId, packageId]: Parameters<T.Effects["getServiceTorHostname"]>
) {
return this.rpcRound('getServiceTorHostname', ({interfaceId, packageId})) as ReturnType<T.Effects["getServiceTorHostname"]>
}
getSslCertificate(...[packageId, algorithm]: Parameters<T.Effects["getSslCertificate"]>) {
return this.rpcRound('getSslCertificate', ({packageId, algorithm})) as ReturnType<T.Effects["getSslCertificate"]>
}
getSslKey(...[packageId, algorithm]: Parameters<T.Effects["getSslKey"]>) {
return this.rpcRound('getSslKey', ({packageId, algorithm})) as ReturnType<T.Effects["getSslKey"]>
}
getSystemSmtp(...[options]: Parameters<T.Effects["getSystemSmtp"]>) {
return this.rpcRound('getSystemSmtp', {...options, callback: this.callbackHolder.addCallback(options.callback)}) as ReturnType<T.Effects["getSystemSmtp"]>
}
is_sandboxed(...[]: Parameters<T.Effects["is_sandboxed"]>) {
return this.rpcRound('is_sandboxed', (null)) as ReturnType<T.Effects["is_sandboxed"]>
}
listInterface(...[options]: Parameters<T.Effects["listInterface"]>) {
return this.rpcRound('listInterface', {...options, callback: this.callbackHolder.addCallback(options.callback)}) as ReturnType<T.Effects["listInterface"]>
}
mount(...[options]: Parameters<T.Effects["mount"]>) {
return this.rpcRound('mount', options) as ReturnType<T.Effects["mount"]>
}
removeAction(...[options]: Parameters<T.Effects["removeAction"]>) {
return this.rpcRound('removeAction', options) as ReturnType<T.Effects["removeAction"]>
}
removeAddress(...[options]: Parameters<T.Effects["removeAddress"]>) {
return this.rpcRound('removeAddress', options) as ReturnType<T.Effects["removeAddress"]>
}
restart(...[]: Parameters<T.Effects["restart"]>) {
this.rpcRound('restart', null)
}
reverseProxy(...[options]: Parameters<T.Effects["reverseProxy"]>) {
return this.rpcRound('reverseProxy', options) as ReturnType<T.Effects["reverseProxy"]>
}
running(...[packageId]: Parameters<T.Effects["running"]>) {
return this.rpcRound('running', {packageId}) as ReturnType<T.Effects["running"]>
}
// runRsync(...[options]: Parameters<T.Effects[""]>) {
//
// return this.rpcRound('executeAction', options) as ReturnType<T.Effects["executeAction"]>
//
// return this.rpcRound('executeAction', options) as ReturnType<T.Effects["executeAction"]>
// }
setConfigured(...[configured]: Parameters<T.Effects["setConfigured"]>) {
return this.rpcRound('setConfigured', {configured}) as ReturnType<T.Effects["setConfigured"]>
}
setDependencies(...[dependencies]: Parameters<T.Effects["setDependencies"]>) {
return this.rpcRound('setDependencies', {dependencies}) as ReturnType<T.Effects["setDependencies"]>
}
setHealth(...[options]: Parameters<T.Effects["setHealth"]>) {
return this.rpcRound('setHealth', options) as ReturnType<T.Effects["setHealth"]>
}
shutdown(...[]: Parameters<T.Effects["shutdown"]>) {
return this.rpcRound('shutdown', null)
}
stopped(...[packageId]: Parameters<T.Effects["stopped"]>) {
return this.rpcRound('stopped', {packageId}) as ReturnType<T.Effects["stopped"]>
}
store: T.Effects['store'] = {
get:(options) => this.rpcRound('getStore', {...options, callback: this.callbackHolder.addCallback(options.callback)}) as ReturnType<T.Effects["store"]['get']>,
set:(options) => this.rpcRound('setStore', options) as ReturnType<T.Effects["store"]['set']>
}
}

View File

@@ -1,177 +0,0 @@
// @ts-check
import * as net from "net"
import {
object,
some,
string,
literal,
array,
number,
matches,
} from "ts-matches"
import { Effects } from "./Effects"
import { CallbackHolder } from "./CallbackHolder"
import * as CP from "child_process"
import * as Mod from "module"
const SOCKET_PATH = "/start9/sockets/rpc.sock"
const LOCATION_OF_SERVICE_JS = "/services/service.js"
const childProcesses = new Map<number, CP.ChildProcess[]>()
let childProcessIndex = 0
const require = Mod.prototype.require
const setupRequire = () => {
const requireChildProcessIndex = childProcessIndex++
// @ts-ignore
Mod.prototype.require = (name, ...rest) => {
if (["child_process", "node:child_process"].indexOf(name) !== -1) {
return {
exec(...args: any[]) {
const returning = CP.exec.apply(null, args as any)
const childProcessArray =
childProcesses.get(requireChildProcessIndex) ?? []
childProcessArray.push(returning)
childProcesses.set(requireChildProcessIndex, childProcessArray)
return returning
},
execFile(...args: any[]) {
const returning = CP.execFile.apply(null, args as any)
const childProcessArray =
childProcesses.get(requireChildProcessIndex) ?? []
childProcessArray.push(returning)
childProcesses.set(requireChildProcessIndex, childProcessArray)
return returning
},
execFileSync: CP.execFileSync,
execSync: CP.execSync,
fork(...args: any[]) {
const returning = CP.fork.apply(null, args as any)
const childProcessArray =
childProcesses.get(requireChildProcessIndex) ?? []
childProcessArray.push(returning)
childProcesses.set(requireChildProcessIndex, childProcessArray)
return returning
},
spawn(...args: any[]) {
const returning = CP.spawn.apply(null, args as any)
const childProcessArray =
childProcesses.get(requireChildProcessIndex) ?? []
childProcessArray.push(returning)
childProcesses.set(requireChildProcessIndex, childProcessArray)
return returning
},
spawnSync: CP.spawnSync,
} as typeof CP
}
console.log("require", name)
return require(name, ...rest)
}
return requireChildProcessIndex
}
const cleanupRequire = (requireChildProcessIndex: number) => {
const foundChildren = childProcesses.get(requireChildProcessIndex)
if (!foundChildren) return
childProcesses.delete(requireChildProcessIndex)
foundChildren.forEach((x) => x.kill())
}
const idType = some(string, number)
const runType = object({
id: idType,
method: literal("run"),
params: object({
methodName: string.map((x) => {
const splitValue = x.split("/")
if (splitValue.length === 1)
throw new Error(`X (${x}) is not a valid path`)
return splitValue.slice(1)
}),
methodArgs: object,
}),
})
const callbackType = object({
id: idType,
method: literal("callback"),
params: object({
callback: string,
args: array,
}),
})
const dealWithInput = async (callbackHolder: CallbackHolder, input: unknown) =>
matches(input)
.when(runType, async ({ id, params: { methodName, methodArgs } }) => {
const index = setupRequire()
const effects = new Effects(`/${methodName.join("/")}`, callbackHolder)
// @ts-ignore
return import(LOCATION_OF_SERVICE_JS)
.then((x) => methodName.reduce(reduceMethod(methodArgs, effects), x))
.then()
.then((result) => ({ id, result }))
.catch((error) => ({
id,
error: { message: error?.message ?? String(error) },
}))
.finally(() => cleanupRequire(index))
})
.when(callbackType, async ({ id, params: { callback, args } }) =>
Promise.resolve(callbackHolder.callCallback(callback, args))
.then((result) => ({ id, result }))
.catch((error) => ({
id,
error: { message: error?.message ?? String(error) },
})),
)
.defaultToLazy(() => {
console.warn(`Coudln't parse the following input ${input}`)
return {
error: { message: "Could not figure out shape" },
}
})
const jsonParse = (x: Buffer) => JSON.parse(x.toString())
export class Runtime {
unixSocketServer = net.createServer(async (server) => {})
private callbacks = new CallbackHolder()
constructor() {
this.unixSocketServer.listen(SOCKET_PATH)
this.unixSocketServer.on("connection", (s) => {
s.on("data", (a) =>
Promise.resolve(a)
.then(jsonParse)
.then(dealWithInput.bind(null, this.callbacks))
.then((x) => {
console.log("x", JSON.stringify(x), typeof x)
return x
})
.catch((error) => ({
error: { message: error?.message ?? String(error) },
}))
.then(JSON.stringify)
.then((x) => new Promise((resolve) => s.write("" + x, resolve)))
.finally(() => void s.end()),
)
})
}
}
function reduceMethod(
methodArgs: object,
effects: Effects,
): (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,
}),
)
}

View File

@@ -0,0 +1,10 @@
#!/bin/bash
cd "$(dirname "${BASH_SOURCE[0]}")"
set -e
cat ./package.json | sed 's/file:\.\([.\/]\)/file:..\/.\1/g' > ./dist/package.json
cat ./package-lock.json | sed 's/"\.\([.\/]\)/"..\/.\1/g' > ./dist/package-lock.json
npm --prefix dist ci --omit=dev

View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -e
IMAGE=$1
if [ -z "$IMAGE" ]; then
>&2 echo "usage: $0 <image id>"
exit 1
fi
if ! [ -d "/media/images/$IMAGE" ]; then
>&2 echo "image does not exist"
exit 1
fi
container=$(mktemp -d)
mkdir -p $container/rootfs $container/upper $container/work
mount -t overlay -olowerdir=/media/images/$IMAGE,upperdir=$container/upper,workdir=$container/work overlay $container/rootfs
rootfs=$container/rootfs
for special in dev sys proc run; do
mkdir -p $rootfs/$special
mount --bind /$special $rootfs/$special
done
echo $rootfs

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,11 @@
"name": "start-init",
"version": "0.0.0",
"description": "We want to be the sdk intermitent for the system",
"module": "./index.js",
"scripts": {
"bundle:esbuild": "esbuild initSrc/index.ts --platform=node --bundle --outfile=startInit.js",
"bundle:service": "esbuild /service/startos/procedures/index.ts --platform=node --bundle --outfile=service.js",
"run:manifest": "esbuild /service/startos/procedures/index.ts --platform=node --bundle --outfile=service.js"
"check": "tsc --noEmit",
"build": "prettier --write '**/*.ts' && rm -rf dist && tsc",
"tsc": "rm -rf dist; tsc"
},
"author": "",
"prettier": {
@@ -16,11 +17,11 @@
},
"dependencies": {
"@iarna/toml": "^2.2.5",
"@start9labs/start-sdk": "=0.4.0-rev0.lib0.rc8.alpha3",
"esbuild": "0.18.4",
"@start9labs/start-sdk": "file:../sdk/dist",
"esbuild-plugin-resolve": "^2.0.0",
"filebrowser": "^1.0.0",
"isomorphic-fetch": "^3.0.0",
"node-fetch": "^3.1.0",
"ts-matches": "^5.4.1",
"tslib": "^2.5.3",
"typescript": "^5.1.3",
@@ -29,8 +30,8 @@
"devDependencies": {
"@swc/cli": "^0.1.62",
"@swc/core": "^1.3.65",
"@types/node": "^20.2.5",
"prettier": "^2.8.8",
"rollup": "^3.25.1"
"@types/node": "^20.11.13",
"prettier": "^3.2.5",
"typescript": ">5.2"
}
}

View File

@@ -0,0 +1,12 @@
#!/bin/bash
set -e
rootfs=$1
if [ -z "$rootfs" ]; then
>&2 echo "usage: $0 <container rootfs path>"
exit 1
fi
umount --recursive $rootfs
rm -rf $rootfs/..

View File

@@ -0,0 +1,320 @@
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
export class HostSystemStartOs implements Effects {
static of(callbackHolder: CallbackHolder) {
return new HostSystemStartOs(callbackHolder)
}
constructor(readonly callbackHolder: CallbackHolder) {}
id = 0
rpcRound(method: string, params: unknown) {
const id = this.id++
const client = net.createConnection({ path: SOCKET_PATH }, () => {
client.write(
JSON.stringify({
id,
method,
params,
}) + "\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))
} 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)
})
})
}
started =
// @ts-ignore
this.method !== MAIN
? null
: () => {
return this.rpcRound("started", null)
}
bind(...[options]: Parameters<T.Effects["bind"]>) {
return this.rpcRound("bind", options) as ReturnType<T.Effects["bind"]>
}
clearBindings(...[]: Parameters<T.Effects["clearBindings"]>) {
return this.rpcRound("clearBindings", null) as ReturnType<
T.Effects["clearBindings"]
>
}
clearNetworkInterfaces(
...[]: Parameters<T.Effects["clearNetworkInterfaces"]>
) {
return this.rpcRound("clearNetworkInterfaces", null) as ReturnType<
T.Effects["clearNetworkInterfaces"]
>
}
createOverlayedImage(options: { imageId: string }): Promise<string> {
return this.rpcRound("createOverlayedImage", options) as ReturnType<
T.Effects["createOverlayedImage"]
>
}
executeAction(...[options]: Parameters<T.Effects["executeAction"]>) {
return this.rpcRound("executeAction", options) as ReturnType<
T.Effects["executeAction"]
>
}
exists(...[packageId]: Parameters<T.Effects["exists"]>) {
return this.rpcRound("exists", packageId) as ReturnType<T.Effects["exists"]>
}
exportAction(...[options]: Parameters<T.Effects["exportAction"]>) {
return this.rpcRound("exportAction", options) as ReturnType<
T.Effects["exportAction"]
>
}
exportNetworkInterface(
...[options]: Parameters<T.Effects["exportNetworkInterface"]>
) {
return this.rpcRound("exportNetworkInterface", options) as ReturnType<
T.Effects["exportNetworkInterface"]
>
}
exposeForDependents(...[options]: any) {
return this.rpcRound("exposeForDependents", null) as ReturnType<
T.Effects["exposeForDependents"]
>
}
exposeUi(...[options]: Parameters<T.Effects["exposeUi"]>) {
return this.rpcRound("exposeUi", options) as ReturnType<
T.Effects["exposeUi"]
>
}
getConfigured(...[]: Parameters<T.Effects["getConfigured"]>) {
return this.rpcRound("getConfigured", null) as ReturnType<
T.Effects["getConfigured"]
>
}
getContainerIp(...[]: Parameters<T.Effects["getContainerIp"]>) {
return this.rpcRound("getContainerIp", null) as ReturnType<
T.Effects["getContainerIp"]
>
}
getHostnames: any = (...[allOptions]: any[]) => {
const options = {
...allOptions,
callback: this.callbackHolder.addCallback(allOptions.callback),
}
return this.rpcRound("getHostnames", options) as ReturnType<
T.Effects["getHostnames"]
>
}
getInterface(...[options]: Parameters<T.Effects["getInterface"]>) {
return this.rpcRound("getInterface", {
...options,
callback: this.callbackHolder.addCallback(options.callback),
}) as ReturnType<T.Effects["getInterface"]>
}
getIPHostname(...[]: Parameters<T.Effects["getIPHostname"]>) {
return this.rpcRound("getIPHostname", null) as ReturnType<
T.Effects["getIPHostname"]
>
}
getLocalHostname(...[]: Parameters<T.Effects["getLocalHostname"]>) {
return this.rpcRound("getLocalHostname", null) as ReturnType<
T.Effects["getLocalHostname"]
>
}
getPrimaryUrl(...[options]: Parameters<T.Effects["getPrimaryUrl"]>) {
return this.rpcRound("getPrimaryUrl", {
...options,
callback: this.callbackHolder.addCallback(options.callback),
}) as ReturnType<T.Effects["getPrimaryUrl"]>
}
getServicePortForward(
...[options]: Parameters<T.Effects["getServicePortForward"]>
) {
return this.rpcRound("getServicePortForward", options) as ReturnType<
T.Effects["getServicePortForward"]
>
}
getServiceTorHostname(
...[interfaceId, packageId]: Parameters<T.Effects["getServiceTorHostname"]>
) {
return this.rpcRound("getServiceTorHostname", {
interfaceId,
packageId,
}) as ReturnType<T.Effects["getServiceTorHostname"]>
}
getSslCertificate(
...[packageId, algorithm]: Parameters<T.Effects["getSslCertificate"]>
) {
return this.rpcRound("getSslCertificate", {
packageId,
algorithm,
}) as ReturnType<T.Effects["getSslCertificate"]>
}
getSslKey(...[packageId, algorithm]: Parameters<T.Effects["getSslKey"]>) {
return this.rpcRound("getSslKey", { packageId, algorithm }) as ReturnType<
T.Effects["getSslKey"]
>
}
getSystemSmtp(...[options]: Parameters<T.Effects["getSystemSmtp"]>) {
return this.rpcRound("getSystemSmtp", {
...options,
callback: this.callbackHolder.addCallback(options.callback),
}) as ReturnType<T.Effects["getSystemSmtp"]>
}
listInterface(...[options]: Parameters<T.Effects["listInterface"]>) {
return this.rpcRound("listInterface", {
...options,
callback: this.callbackHolder.addCallback(options.callback),
}) as ReturnType<T.Effects["listInterface"]>
}
mount(...[options]: Parameters<T.Effects["mount"]>) {
return this.rpcRound("mount", options) as ReturnType<T.Effects["mount"]>
}
removeAction(...[options]: Parameters<T.Effects["removeAction"]>) {
return this.rpcRound("removeAction", options) as ReturnType<
T.Effects["removeAction"]
>
}
removeAddress(...[options]: Parameters<T.Effects["removeAddress"]>) {
return this.rpcRound("removeAddress", options) as ReturnType<
T.Effects["removeAddress"]
>
}
restart(...[]: Parameters<T.Effects["restart"]>) {
return this.rpcRound("restart", null)
}
reverseProxy(...[options]: Parameters<T.Effects["reverseProxy"]>) {
return this.rpcRound("reverseProxy", options) as ReturnType<
T.Effects["reverseProxy"]
>
}
running(...[packageId]: Parameters<T.Effects["running"]>) {
return this.rpcRound("running", { packageId }) as ReturnType<
T.Effects["running"]
>
}
// runRsync(...[options]: Parameters<T.Effects[""]>) {
//
// return this.rpcRound('executeAction', options) as ReturnType<T.Effects["executeAction"]>
//
// return this.rpcRound('executeAction', options) as ReturnType<T.Effects["executeAction"]>
// }
setConfigured(...[configured]: Parameters<T.Effects["setConfigured"]>) {
return this.rpcRound("setConfigured", { configured }) as ReturnType<
T.Effects["setConfigured"]
>
}
setDependencies(
...[dependencies]: Parameters<T.Effects["setDependencies"]>
): ReturnType<T.Effects["setDependencies"]> {
return this.rpcRound("setDependencies", { dependencies }) as ReturnType<
T.Effects["setDependencies"]
>
}
setHealth(...[options]: Parameters<T.Effects["setHealth"]>) {
return this.rpcRound("setHealth", options) as ReturnType<
T.Effects["setHealth"]
>
}
setMainStatus(o: { status: "running" | "stopped" }): Promise<void> {
return this.rpcRound("setMainStatus", o) as ReturnType<
T.Effects["setHealth"]
>
}
shutdown(...[]: Parameters<T.Effects["shutdown"]>) {
return this.rpcRound("shutdown", null)
}
stopped(...[packageId]: Parameters<T.Effects["stopped"]>) {
return this.rpcRound("stopped", { packageId }) as ReturnType<
T.Effects["stopped"]
>
}
store: T.Effects["store"] = {
get: async (options: any) =>
this.rpcRound("getStore", {
...options,
callback: this.callbackHolder.addCallback(options.callback),
}) as any,
set: async (options: any) =>
this.rpcRound("setStore", options) as ReturnType<
T.Effects["store"]["set"]
>,
}
/**
* So, this is created
* @param options
* @returns
*/
embassyGetInterface(options: {
target: "tor-key" | "tor-address" | "lan-address"
packageId: string
interface: string
}) {
return this.rpcRound("embassyGetInterface", options) as Promise<string>
}
}

View File

@@ -0,0 +1,303 @@
// @ts-check
import * as net from "net"
import {
object,
some,
string,
literal,
array,
number,
matches,
any,
shape,
} 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"
type MaybePromise<T> = T | Promise<T>
type SocketResponse = { jsonrpc: "2.0"; id: IdType } & (
| { result: unknown }
| {
error: {
code: number
message: string
data: { details: string; debug?: string }
}
}
)
const SOCKET_PARENT = "/media/startos/rpc"
const SOCKET_PATH = "/media/startos/rpc/service.sock"
const jsonrpc = "2.0" as const
const idType = some(string, number, literal(null))
type IdType = null | string | number
const runType = object({
id: idType,
method: literal("execute"),
params: object(
{
procedure: string,
input: any,
timeout: number,
},
["timeout"],
),
})
const sandboxRunType = object({
id: idType,
method: literal("sandbox"),
params: object(
{
procedure: string,
input: any,
timeout: number,
},
["timeout"],
),
})
const callbackType = object({
id: idType,
method: literal("callback"),
params: object({
callback: string,
args: array,
}),
})
const initType = object({
id: idType,
method: literal("init"),
})
const exitType = object({
id: idType,
method: literal("exit"),
})
const evalType = object({
id: idType,
method: literal("eval"),
params: object({
script: string,
}),
})
const jsonParse = (x: Buffer) => JSON.parse(x.toString())
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 hasId = object({ id: idType }).test
export class RpcListener {
unixSocketServer = net.createServer(async (server) => {})
private _system: System | undefined
private _effects: HostSystem | undefined
constructor(
readonly getDependencies: AllGetDependencies,
private callbacks = new CallbackHolder(),
) {
if (!fs.existsSync(SOCKET_PARENT)) {
fs.mkdirSync(SOCKET_PARENT, { recursive: true })
}
this.unixSocketServer.listen(SOCKET_PATH)
this.unixSocketServer.on("connection", (s) => {
let id: IdType = null
const captureId = <X>(x: X) => {
if (hasId(x)) id = x.id
return x
}
const logData =
(location: string) =>
<X>(x: X) => {
console.log({
location,
stringified: JSON.stringify(x),
type: typeof x,
id,
})
return x
}
const mapError = (error: any): SocketResponse => ({
jsonrpc,
id,
error: {
message: typeof error,
data: {
details: error?.message ?? String(error),
debug: error?.stack,
},
code: 0,
},
})
const writeDataToSocket = (x: SocketResponse) =>
new Promise((resolve) => s.write(JSON.stringify(x), resolve))
s.on("data", (a) =>
Promise.resolve(a)
.then(logData("dataIn"))
.then(jsonParse)
.then(captureId)
.then((x) => this.dealWithInput(x))
.catch(mapError)
.then(logData("response"))
.then(writeDataToSocket)
.finally(() => void s.end()),
)
})
}
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 dealWithInput(input: unknown): MaybePromise<SocketResponse> {
return matches(input)
.when(some(runType, sandboxRunType), async ({ id, params }) => {
const system = this.system
const procedure = jsonPath.unsafeCast(params.procedure)
return system
.execute(this.effects, {
procedure,
input: params.input,
timeout: params.timeout,
})
.then((result) =>
"ok" in result
? {
jsonrpc,
id,
result: result.ok === undefined ? null : result.ok,
}
: {
jsonrpc,
id,
error: {
code: result.err.code,
message: "Package Root Error",
data: { details: result.err.message },
},
},
)
.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) this._system.exit(this.effects)
delete this._system
delete this._effects
return {
jsonrpc,
id,
result: null,
}
})
.when(initType, async ({ id }) => {
this._system = await this.getDependencies.system()
return {
jsonrpc,
id,
result: null,
}
})
.when(evalType, async ({ id, params }) => {
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,
id,
error: {
code: -32601,
message: `Method not found`,
data: {
details: method,
},
},
}))
.defaultToLazy(() => {
console.warn(
`Coudln't parse the following input ${JSON.stringify(input)}`,
)
return {
jsonrpc,
id: (input as any)?.id,
error: {
code: -32602,
message: "invalid params",
data: {
details: JSON.stringify(input),
},
},
}
})
}
}

View File

@@ -0,0 +1,76 @@
import * as fs from "fs/promises"
import * as cp from "child_process"
import { Overlay, types as T } from "@start9labs/start-sdk"
import { promisify } from "util"
import { DockerProcedure, VolumeId } from "../../../Models/DockerProcedure"
import { Volume } from "./matchVolume"
export const exec = promisify(cp.exec)
export const execFile = promisify(cp.execFile)
export class DockerProcedureContainer {
private constructor(readonly overlay: Overlay) {}
// static async readonlyOf(data: DockerProcedure) {
// return DockerProcedureContainer.of(data, ["-o", "ro"])
// }
static async of(
effects: T.Effects,
data: DockerProcedure,
volumes: { [id: VolumeId]: Volume },
) {
const overlay = await Overlay.of(effects, data.image)
if (data.mounts) {
const mounts = data.mounts
for (const mount in mounts) {
const path = mounts[mount].startsWith("/")
? `${overlay.rootfs}${mounts[mount]}`
: `${overlay.rootfs}/${mounts[mount]}`
await fs.mkdir(path, { recursive: true })
const volumeMount = volumes[mount]
if (volumeMount.type === "data") {
await overlay.mount({ type: "volume", id: mount }, mounts[mount])
} else if (volumeMount.type === "assets") {
await overlay.mount({ type: "assets", id: mount }, mounts[mount])
} else if (volumeMount.type === "certificate") {
volumeMount
const certChain = await effects.getSslCertificate()
const key = await effects.getSslKey()
await fs.writeFile(
`${path}/${volumeMount["interface-id"]}.cert.pem`,
certChain.join("\n"),
)
await fs.writeFile(
`${path}/${volumeMount["interface-id"]}.key.pem`,
key,
)
} else if (volumeMount.type === "pointer") {
await effects.mount({
location: path,
target: {
packageId: volumeMount["package-id"],
path: volumeMount.path,
readonly: volumeMount.readonly,
volumeId: volumeMount["volume-id"],
},
})
} else if (volumeMount.type === "backup") {
throw new Error("TODO")
}
}
}
return new DockerProcedureContainer(overlay)
}
async exec(commands: string[]) {
try {
return await this.overlay.exec(commands)
} finally {
await this.overlay.destroy()
}
}
async spawn(commands: string[]): Promise<cp.ChildProcessWithoutNullStreams> {
return await this.overlay.spawn(commands)
}
}

View File

@@ -0,0 +1,150 @@
import { PolyfillEffects } from "./polyfillEffects"
import { DockerProcedureContainer } from "./DockerProcedureContainer"
import { SystemForEmbassy } from "."
import { HostSystemStartOs } from "../../HostSystemStartOs"
import { util, Daemons, types as T } from "@start9labs/start-sdk"
const EMBASSY_HEALTH_INTERVAL = 15 * 1000
const EMBASSY_PROPERTIES_LOOP = 30 * 1000
/**
* We wanted something to represent what the main loop is doing, and
* in this case it used to run the properties, health, and the docker/ js main.
* 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 mainEvent:
| Promise<{
daemon: T.DaemonReturned
wait: Promise<unknown>
}>
| undefined
private propertiesEvent: NodeJS.Timeout | undefined
constructor(
readonly system: SystemForEmbassy,
readonly effects: HostSystemStartOs,
readonly runProperties: () => Promise<void>,
) {
this.healthLoops = this.constructHealthLoops()
this.mainEvent = this.constructMainEvent()
this.propertiesEvent = this.constructPropertiesEvent()
}
private async constructMainEvent() {
const { system, effects } = this
const utils = util.createUtils(effects)
const currentCommand: [string, ...string[]] = [
system.manifest.main.entrypoint,
...system.manifest.main.args,
]
await effects.setMainStatus({ status: "running" })
const jsMain = (this.system.moduleCode as any)?.jsMain
const dockerProcedureContainer = await DockerProcedureContainer.of(
effects,
this.system.manifest.main,
this.system.manifest.volumes,
)
if (jsMain) {
const daemons = Daemons.of({
effects,
started: async (_) => {},
healthReceipts: [],
})
throw new Error("todo")
// return {
// daemon,
// wait: daemon.wait().finally(() => {
// this.clean()
// effects.setMainStatus({ status: "stopped" })
// }),
// }
}
const daemon = await utils.runDaemon(
this.system.manifest.main.image,
currentCommand,
{
overlay: dockerProcedureContainer.overlay,
},
)
return {
daemon,
wait: daemon.wait().finally(() => {
this.clean()
effects
.setMainStatus({ status: "stopped" })
.catch((e) => console.error("Could not set the status to stopped"))
}),
}
}
public async clean(options?: { timeout?: number }) {
const { mainEvent, healthLoops, propertiesEvent } = this
delete this.mainEvent
delete this.healthLoops
delete this.propertiesEvent
if (mainEvent) await (await mainEvent).daemon.term()
clearInterval(propertiesEvent)
if (healthLoops) healthLoops.forEach((x) => clearInterval(x.interval))
}
private constructPropertiesEvent() {
const { runProperties } = this
return setInterval(() => {
runProperties()
}, EMBASSY_PROPERTIES_LOOP)
}
private constructHealthLoops() {
const { manifest } = this.system
const effects = this.effects
const start = Date.now()
return Object.values(manifest["health-checks"]).map((value) => {
const name = value.name
const interval = setInterval(async () => {
const actionProcedure = value
const timeChanged = Date.now() - start
if (actionProcedure.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
actionProcedure,
manifest.volumes,
)
const executed = await container.exec([
actionProcedure.entrypoint,
...actionProcedure.args,
JSON.stringify(timeChanged),
])
const stderr = executed.stderr.toString()
if (stderr)
console.error(`Error running health check ${value.name}: ${stderr}`)
return executed.stdout.toString()
} else {
const moduleCode = await this.system.moduleCode
const method = moduleCode.health?.[value.name]
if (!method)
return console.error(
`Expecting that thejs health check ${value.name} exists`,
)
return (await method(
new PolyfillEffects(effects, this.system.manifest),
timeChanged,
).then((x) => {
if ("result" in x) return x.result
if ("error" in x)
return console.error("Error getting config: " + x.error)
return console.error("Error getting config: " + x["error-code"][1])
})) as any
}
}, EMBASSY_HEALTH_INTERVAL)
return { name, interval }
})
}
}

View File

@@ -0,0 +1,900 @@
import { types as T, util, EmVer } from "@start9labs/start-sdk"
import * as fs from "fs/promises"
import { PolyfillEffects } from "./polyfillEffects"
import { ExecuteResult, System } from "../../../Interfaces/System"
import { matchManifest, Manifest, Procedure } from "./matchManifest"
import { create } from "domain"
import * as childProcess from "node:child_process"
import { Volume } from "../../../Models/Volume"
import { DockerProcedure } from "../../../Models/DockerProcedure"
import { DockerProcedureContainer } from "./DockerProcedureContainer"
import { promisify } from "node:util"
import * as U from "./oldEmbassyTypes"
import { MainLoop } from "./MainLoop"
import {
matches,
boolean,
dictionary,
literal,
literals,
object,
string,
unknown,
any,
tuple,
number,
} from "ts-matches"
import { HostSystemStartOs } from "../../HostSystemStartOs"
import { JsonPath, unNestPath } from "../../../Models/JsonPath"
import { HostSystem } from "../../../Interfaces/HostSystem"
type Optional<A> = A | undefined | null
function todo(): never {
throw new Error("Not implemented")
}
const execFile = promisify(childProcess.execFile)
const MANIFEST_LOCATION = "/usr/lib/startos/package/embassyManifest.json"
const EMBASSY_JS_LOCATION = "/usr/lib/startos/package/embassy.js"
const EMBASSY_POINTER_PATH_PREFIX = "/embassyConfig"
export class SystemForEmbassy implements System {
currentRunning: MainLoop | undefined
static async of(manifestLocation: string = MANIFEST_LOCATION) {
const moduleCode = await import(EMBASSY_JS_LOCATION)
.catch((_) => require(EMBASSY_JS_LOCATION))
.catch(async (_) => {
console.error("Could not load the js")
console.error({
exists: await fs.stat(EMBASSY_JS_LOCATION),
})
return {}
})
const manifestData = await fs.readFile(manifestLocation, "utf-8")
return new SystemForEmbassy(
matchManifest.unsafeCast(JSON.parse(manifestData)),
moduleCode,
)
}
constructor(
readonly manifest: Manifest,
readonly moduleCode: Partial<U.ExpectedExports>,
) {}
async execute(
effects: HostSystemStartOs,
options: {
procedure: JsonPath
input: unknown
timeout?: number | undefined
},
): Promise<ExecuteResult> {
return this._execute(effects, options)
.then((x) =>
matches(x)
.when(
object({
result: any,
}),
(x) => ({
ok: x.result,
}),
)
.when(
object({
error: string,
}),
(x) => ({
err: {
code: 0,
message: x.error,
},
}),
)
.when(
object({
"error-code": tuple(number, string),
}),
({ "error-code": [code, message] }) => ({
err: {
code,
message,
},
}),
)
.defaultTo({ ok: x }),
)
.catch((error) => ({
err: {
code: 0,
message: "" + error,
},
}))
}
async exit(effects: HostSystemStartOs): Promise<void> {
if (this.currentRunning) await this.currentRunning.clean()
delete this.currentRunning
}
async _execute(
effects: HostSystemStartOs,
options: {
procedure: JsonPath
input: unknown
timeout?: number | undefined
},
): Promise<unknown> {
const input = options.input
switch (options.procedure) {
case "/backup/create":
return this.createBackup(effects)
case "/backup/restore":
return this.restoreBackup(effects)
case "/config/get":
return this.getConfig(effects)
case "/config/set":
return this.setConfig(effects, input)
case "/actions/metadata":
return todo()
case "/init":
return this.init(effects, string.optional().unsafeCast(input))
case "/uninit":
return this.uninit(effects, string.optional().unsafeCast(input))
case "/main/start":
return this.mainStart(effects)
case "/main/stop":
return this.mainStop(effects)
default:
const procedures = unNestPath(options.procedure)
switch (true) {
case procedures[1] === "actions" && procedures[3] === "get":
return this.action(effects, procedures[2], input)
case procedures[1] === "actions" && procedures[3] === "run":
return this.action(effects, procedures[2], input)
case procedures[1] === "dependencies" && procedures[3] === "query":
return this.dependenciesAutoconfig(effects, procedures[2], input)
case procedures[1] === "dependencies" && procedures[3] === "update":
return this.dependenciesAutoconfig(effects, procedures[2], input)
}
}
}
private async init(
effects: HostSystemStartOs,
previousVersion: Optional<string>,
): Promise<void> {
console.log("here1")
if (previousVersion) await this.migration(effects, previousVersion)
console.log("here2")
await this.properties(effects)
console.log("here3")
await effects.setMainStatus({ status: "stopped" })
console.log("here4")
}
private async uninit(
effects: HostSystemStartOs,
nextVersion: Optional<string>,
): Promise<void> {
// TODO Do a migration down if the version exists
await effects.setMainStatus({ status: "stopped" })
}
private async mainStart(effects: HostSystemStartOs): Promise<void> {
if (!!this.currentRunning) return
this.currentRunning = new MainLoop(this, effects, () =>
this.properties(effects),
)
}
private async mainStop(
effects: HostSystemStartOs,
options?: { timeout?: number },
): Promise<void> {
const { currentRunning } = this
delete this.currentRunning
if (currentRunning) {
await currentRunning.clean({
timeout: options?.timeout || this.manifest.main["sigterm-timeout"],
})
}
}
private async createBackup(effects: HostSystemStartOs): Promise<void> {
const backup = this.manifest.backup.create
if (backup.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
backup,
this.manifest.volumes,
)
await container.exec([backup.entrypoint, ...backup.args])
} else {
const moduleCode = await this.moduleCode
await moduleCode.createBackup?.(
new PolyfillEffects(effects, this.manifest),
)
}
}
private async restoreBackup(effects: HostSystemStartOs): Promise<void> {
const restoreBackup = this.manifest.backup.restore
if (restoreBackup.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
restoreBackup,
this.manifest.volumes,
)
await container.exec([restoreBackup.entrypoint, ...restoreBackup.args])
} else {
const moduleCode = await this.moduleCode
await moduleCode.restoreBackup?.(
new PolyfillEffects(effects, this.manifest),
)
}
}
private async getConfig(effects: HostSystemStartOs): Promise<T.ConfigRes> {
return this.getConfigUncleaned(effects).then(removePointers)
}
private async getConfigUncleaned(
effects: HostSystemStartOs,
): Promise<T.ConfigRes> {
const config = this.manifest.config?.get
if (!config) return { spec: {} }
if (config.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
config,
this.manifest.volumes,
)
// TODO: yaml
return JSON.parse(
(
await container.exec([config.entrypoint, ...config.args])
).stdout.toString(),
)
} else {
const moduleCode = await this.moduleCode
const method = moduleCode.getConfig
if (!method) throw new Error("Expecting that the method getConfig exists")
return (await method(new 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])
},
)) as any
}
}
private async setConfig(
effects: HostSystemStartOs,
newConfigWithoutPointers: unknown,
): Promise<T.SetResult> {
const newConfig = structuredClone(newConfigWithoutPointers)
await updateConfig(
effects,
await this.getConfigUncleaned(effects).then((x) => x.spec),
newConfig,
)
const setConfigValue = this.manifest.config?.set
if (!setConfigValue) return { signal: "SIGTERM", "depends-on": {} }
if (setConfigValue.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
setConfigValue,
this.manifest.volumes,
)
return JSON.parse(
(
await container.exec([
setConfigValue.entrypoint,
...setConfigValue.args,
JSON.stringify(newConfig),
])
).stdout.toString(),
)
} else if (setConfigValue.type === "script") {
const moduleCode = await this.moduleCode
const method = moduleCode.setConfig
if (!method) throw new Error("Expecting that the method setConfig exists")
return await method(
new PolyfillEffects(effects, this.manifest),
newConfig as U.Config,
).then((x): T.SetResult => {
if ("result" in x)
return {
"depends-on": x.result["depends-on"],
signal: x.result.signal === "SIGEMT" ? "SIGTERM" : x.result.signal,
}
if ("error" in x) throw new Error("Error getting config: " + x.error)
throw new Error("Error getting config: " + x["error-code"][1])
})
} else {
return {
"depends-on": {},
signal: "SIGTERM",
}
}
}
private async migration(
effects: HostSystemStartOs,
fromVersion: string,
): Promise<T.MigrationRes> {
const fromEmver = EmVer.from(fromVersion)
const currentEmver = EmVer.from(this.manifest.version)
if (!this.manifest.migrations) return { configured: true }
const fromMigration = Object.entries(this.manifest.migrations.from)
.map(([version, procedure]) => [EmVer.from(version), procedure] as const)
.find(
([versionEmver, procedure]) =>
versionEmver.greaterThan(fromEmver) &&
versionEmver.lessThanOrEqual(currentEmver),
)
const toMigration = Object.entries(this.manifest.migrations.to)
.map(([version, procedure]) => [EmVer.from(version), procedure] as const)
.find(
([versionEmver, procedure]) =>
versionEmver.greaterThan(fromEmver) &&
versionEmver.lessThanOrEqual(currentEmver),
)
// prettier-ignore
const migration = (
fromEmver.greaterThan(currentEmver) ? [toMigration, fromMigration] :
[fromMigration, toMigration]).filter(Boolean)[0]
if (migration) {
const [version, procedure] = migration
if (procedure.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
procedure,
this.manifest.volumes,
)
return JSON.parse(
(
await container.exec([
procedure.entrypoint,
...procedure.args,
JSON.stringify(fromVersion),
])
).stdout.toString(),
)
} else if (procedure.type === "script") {
const moduleCode = await this.moduleCode
const method = moduleCode.migration
if (!method)
throw new Error("Expecting that the method migration exists")
return (await method(
new PolyfillEffects(effects, this.manifest),
fromVersion as string,
).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
}
}
return { configured: true }
}
private async properties(effects: HostSystemStartOs): Promise<undefined> {
// TODO BLU-J set the properties ever so often
const setConfigValue = this.manifest.properties
if (!setConfigValue) return
if (setConfigValue.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
setConfigValue,
this.manifest.volumes,
)
return JSON.parse(
(
await container.exec([
setConfigValue.entrypoint,
...setConfigValue.args,
])
).stdout.toString(),
)
} else if (setConfigValue.type === "script") {
const moduleCode = this.moduleCode
const method = moduleCode.properties
if (!method)
throw new Error("Expecting that the method properties exists")
await method(new 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])
})
}
}
private async health(
effects: HostSystemStartOs,
healthId: string,
timeSinceStarted: unknown,
): Promise<void> {
const healthProcedure = this.manifest["health-checks"][healthId]
if (!healthProcedure) return
if (healthProcedure.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
healthProcedure,
this.manifest.volumes,
)
return JSON.parse(
(
await container.exec([
healthProcedure.entrypoint,
...healthProcedure.args,
JSON.stringify(timeSinceStarted),
])
).stdout.toString(),
)
} else if (healthProcedure.type === "script") {
const moduleCode = await this.moduleCode
const method = moduleCode.health?.[healthId]
if (!method) throw new Error("Expecting that the method health exists")
await method(
new PolyfillEffects(effects, this.manifest),
Number(timeSinceStarted),
).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])
})
}
}
private async action(
effects: HostSystemStartOs,
actionId: string,
formData: unknown,
): Promise<T.ActionResult> {
const actionProcedure = this.manifest.actions?.[actionId]?.implementation
if (!actionProcedure) return { message: "Action not found", value: null }
if (actionProcedure.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
actionProcedure,
this.manifest.volumes,
)
return JSON.parse(
(
await container.exec([
actionProcedure.entrypoint,
...actionProcedure.args,
JSON.stringify(formData),
])
).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(
new PolyfillEffects(effects, this.manifest),
formData 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
}
}
private async dependenciesCheck(
effects: HostSystemStartOs,
id: string,
oldConfig: unknown,
): Promise<object> {
const actionProcedure = this.manifest.dependencies?.[id]?.config?.check
if (!actionProcedure) return { message: "Action not found", value: null }
if (actionProcedure.type === "docker") {
const container = await DockerProcedureContainer.of(
effects,
actionProcedure,
this.manifest.volumes,
)
return JSON.parse(
(
await container.exec([
actionProcedure.entrypoint,
...actionProcedure.args,
JSON.stringify(oldConfig),
])
).stdout.toString(),
)
} else if (actionProcedure.type === "script") {
const moduleCode = await this.moduleCode
const method = moduleCode.dependencies?.[id]?.check
if (!method)
throw new Error(
`Expecting that the method dependency check ${id} exists`,
)
return (await method(
new PolyfillEffects(effects, this.manifest),
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(
effects: HostSystemStartOs,
id: string,
oldConfig: unknown,
): Promise<void> {
const moduleCode = await this.moduleCode
const method = moduleCode.dependencies?.[id]?.autoConfigure
if (!method)
throw new Error(
`Expecting that the method dependency autoConfigure ${id} exists`,
)
return (await method(
new PolyfillEffects(effects, this.manifest),
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
}
// private async sandbox(
// effects: HostSystemStartOs,
// options: {
// procedure:
// | "/createBackup"
// | "/restoreBackup"
// | "/getConfig"
// | "/setConfig"
// | "migration"
// | "/properties"
// | `/action/${string}`
// | `/dependencies/${string}/check`
// | `/dependencies/${string}/autoConfigure`
// input: unknown
// timeout?: number | undefined
// },
// ): Promise<unknown> {
// const input = options.input
// switch (options.procedure) {
// case "/createBackup":
// return this.roCreateBackup(effects)
// case "/restoreBackup":
// return this.roRestoreBackup(effects)
// case "/getConfig":
// return this.roGetConfig(effects)
// case "/setConfig":
// return this.roSetConfig(effects, input)
// case "migration":
// return this.roMigration(effects, input)
// case "/properties":
// return this.roProperties(effects)
// default:
// const procedure = options.procedure.split("/")
// switch (true) {
// case options.procedure.startsWith("/action/"):
// return this.roAction(effects, procedure[2], input)
// case options.procedure.startsWith("/dependencies/") &&
// procedure[3] === "check":
// return this.roDependenciesCheck(effects, procedure[2], input)
// case options.procedure.startsWith("/dependencies/") &&
// procedure[3] === "autoConfigure":
// return this.roDependenciesAutoconfig(effects, procedure[2], input)
// }
// }
// }
// private async roCreateBackup(effects: HostSystemStartOs): Promise<void> {
// const backup = this.manifest.backup.create
// if (backup.type === "docker") {
// const container = await DockerProcedureContainer.readonlyOf(backup)
// await container.exec([backup.entrypoint, ...backup.args])
// } else {
// const moduleCode = await this.moduleCode
// await moduleCode.createBackup?.(new PolyfillEffects(effects))
// }
// }
// private async roRestoreBackup(effects: HostSystemStartOs): Promise<void> {
// const restoreBackup = this.manifest.backup.restore
// if (restoreBackup.type === "docker") {
// const container = await DockerProcedureContainer.readonlyOf(restoreBackup)
// await container.exec([restoreBackup.entrypoint, ...restoreBackup.args])
// } else {
// const moduleCode = await this.moduleCode
// await moduleCode.restoreBackup?.(new PolyfillEffects(effects))
// }
// }
// private async roGetConfig(effects: HostSystemStartOs): Promise<T.ConfigRes> {
// const config = this.manifest.config?.get
// if (!config) return { spec: {} }
// if (config.type === "docker") {
// const container = await DockerProcedureContainer.readonlyOf(config)
// return JSON.parse(
// (await container.exec([config.entrypoint, ...config.args])).stdout,
// )
// } else {
// const moduleCode = await this.moduleCode
// const method = moduleCode.getConfig
// if (!method) throw new Error("Expecting that the method getConfig exists")
// return (await method(new PolyfillEffects(effects)).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
// }
// }
// private async roSetConfig(
// effects: HostSystemStartOs,
// newConfig: unknown,
// ): Promise<T.SetResult> {
// const setConfigValue = this.manifest.config?.set
// if (!setConfigValue) return { signal: "SIGTERM", "depends-on": {} }
// if (setConfigValue.type === "docker") {
// const container = await DockerProcedureContainer.readonlyOf(
// setConfigValue,
// )
// return JSON.parse(
// (
// await container.exec([
// setConfigValue.entrypoint,
// ...setConfigValue.args,
// JSON.stringify(newConfig),
// ])
// ).stdout,
// )
// } else {
// const moduleCode = await this.moduleCode
// const method = moduleCode.setConfig
// if (!method) throw new Error("Expecting that the method setConfig exists")
// return await method(
// new PolyfillEffects(effects),
// newConfig as U.Config,
// ).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])
// })
// }
// }
// private async roMigration(
// effects: HostSystemStartOs,
// fromVersion: unknown,
// ): Promise<T.MigrationRes> {
// throw new Error("Migrations should never be ran in the sandbox mode")
// }
// private async roProperties(effects: HostSystemStartOs): Promise<unknown> {
// const setConfigValue = this.manifest.properties
// if (!setConfigValue) return {}
// if (setConfigValue.type === "docker") {
// const container = await DockerProcedureContainer.readonlyOf(
// setConfigValue,
// )
// return JSON.parse(
// (
// await container.exec([
// setConfigValue.entrypoint,
// ...setConfigValue.args,
// ])
// ).stdout,
// )
// } else {
// const moduleCode = await this.moduleCode
// const method = moduleCode.properties
// if (!method)
// throw new Error("Expecting that the method properties exists")
// return await method(new PolyfillEffects(effects)).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])
// })
// }
// }
// private async roHealth(
// effects: HostSystemStartOs,
// healthId: string,
// timeSinceStarted: unknown,
// ): Promise<void> {
// const healthProcedure = this.manifest["health-checks"][healthId]
// if (!healthProcedure) return
// if (healthProcedure.type === "docker") {
// const container = await DockerProcedureContainer.readonlyOf(
// healthProcedure,
// )
// return JSON.parse(
// (
// await container.exec([
// healthProcedure.entrypoint,
// ...healthProcedure.args,
// JSON.stringify(timeSinceStarted),
// ])
// ).stdout,
// )
// } else {
// const moduleCode = await this.moduleCode
// const method = moduleCode.health?.[healthId]
// if (!method) throw new Error("Expecting that the method health exists")
// await method(new PolyfillEffects(effects), Number(timeSinceStarted)).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])
// },
// )
// }
// }
// private async roAction(
// effects: HostSystemStartOs,
// actionId: string,
// formData: unknown,
// ): Promise<T.ActionResult> {
// const actionProcedure = this.manifest.actions?.[actionId]?.implementation
// if (!actionProcedure) return { message: "Action not found", value: null }
// if (actionProcedure.type === "docker") {
// const container = await DockerProcedureContainer.readonlyOf(
// actionProcedure,
// )
// return JSON.parse(
// (
// await container.exec([
// actionProcedure.entrypoint,
// ...actionProcedure.args,
// JSON.stringify(formData),
// ])
// ).stdout,
// )
// } 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(new PolyfillEffects(effects), formData 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
// }
// }
// private async roDependenciesCheck(
// effects: HostSystemStartOs,
// id: string,
// oldConfig: unknown,
// ): Promise<object> {
// const actionProcedure = this.manifest.dependencies?.[id]?.config?.check
// if (!actionProcedure) return { message: "Action not found", value: null }
// if (actionProcedure.type === "docker") {
// const container = await DockerProcedureContainer.readonlyOf(
// actionProcedure,
// )
// return JSON.parse(
// (
// await container.exec([
// actionProcedure.entrypoint,
// ...actionProcedure.args,
// JSON.stringify(oldConfig),
// ])
// ).stdout,
// )
// } else {
// const moduleCode = await this.moduleCode
// const method = moduleCode.dependencies?.[id]?.check
// if (!method)
// throw new Error(
// `Expecting that the method dependency check ${id} exists`,
// )
// return (await method(new PolyfillEffects(effects), 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
// }
// }
// private async roDependenciesAutoconfig(
// effects: HostSystemStartOs,
// id: string,
// oldConfig: unknown,
// ): Promise<void> {
// const moduleCode = await this.moduleCode
// const method = moduleCode.dependencies?.[id]?.autoConfigure
// if (!method)
// throw new Error(
// `Expecting that the method dependency autoConfigure ${id} exists`,
// )
// return (await method(new PolyfillEffects(effects), 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
// }
}
async function removePointers(value: T.ConfigRes): Promise<T.ConfigRes> {
const startingSpec = structuredClone(value.spec)
const spec = cleanSpecOfPointers(startingSpec)
return { ...value, spec }
}
const matchPointer = object({
type: literal("pointer"),
})
const matchPointerPackage = object({
subtype: literal("package"),
target: literals("tor-key", "tor-address", "lan-address"),
"package-id": string,
interface: string,
})
const matchPointerConfig = object({
subtype: literal("package"),
target: literals("config"),
"package-id": string,
selector: string,
multi: boolean,
})
const matchSpec = object({
spec: object,
})
const matchVariants = object({ variants: dictionary([string, unknown]) })
function cleanSpecOfPointers<T>(mutSpec: T): T {
if (!object.test(mutSpec)) return mutSpec
for (const key in mutSpec) {
const value = mutSpec[key]
if (matchSpec.test(value)) value.spec = cleanSpecOfPointers(value.spec)
if (matchVariants.test(value))
value.variants = Object.fromEntries(
Object.entries(value.variants).map(([key, value]) => [
key,
cleanSpecOfPointers(value),
]),
)
if (!matchPointer.test(value)) continue
delete mutSpec[key]
// // if (value.target === )
}
return mutSpec
}
async function updateConfig(
effects: HostSystemStartOs,
spec: unknown,
mutConfigValue: unknown,
) {
if (!dictionary([string, unknown]).test(spec)) return
if (!dictionary([string, unknown]).test(mutConfigValue)) return
for (const key in spec) {
const specValue = spec[key]
const newConfigValue = mutConfigValue[key]
if (matchSpec.test(specValue)) {
const updateObject = { spec: null }
await updateConfig(effects, { spec: specValue.spec }, updateObject)
mutConfigValue[key] = updateObject.spec
}
if (
matchVariants.test(specValue) &&
object({ tag: object({ id: string }) }).test(newConfigValue) &&
newConfigValue.tag.id in specValue.variants
) {
// Not going to do anything on the variants...
}
if (!matchPointer.test(specValue)) continue
if (matchPointerConfig.test(specValue)) {
const configValue = (await effects.store.get({
packageId: specValue["package-id"],
callback() {},
path: `${EMBASSY_POINTER_PATH_PREFIX}${specValue.selector}` as any,
})) as any
mutConfigValue[key] = configValue
}
if (matchPointerPackage.test(specValue)) {
mutConfigValue[key] = await effects.embassyGetInterface({
target: specValue.target,
packageId: specValue["package-id"],
interface: specValue["interface"],
})
}
}
}

View File

@@ -0,0 +1,119 @@
import {
object,
literal,
string,
array,
boolean,
dictionary,
literals,
number,
unknown,
some,
every,
} from "ts-matches"
import { matchVolume } from "./matchVolume"
import { matchDockerProcedure } from "../../../Models/DockerProcedure"
const matchJsProcedure = object(
{
type: literal("script"),
args: array(unknown),
},
["args"],
{
args: [],
},
)
const matchProcedure = some(matchDockerProcedure, matchJsProcedure)
export type Procedure = typeof matchProcedure._TYPE
const matchAction = object(
{
name: string,
description: string,
warning: string,
implementation: matchProcedure,
"allowed-statuses": array(literals("running", "stopped")),
"input-spec": unknown,
},
["warning", "input-spec", "input-spec"],
)
export const matchManifest = object(
{
id: string,
version: string,
main: matchDockerProcedure,
assets: object(
{
assets: string,
scripts: string,
},
["assets", "scripts"],
),
"health-checks": dictionary([
string,
every(
matchProcedure,
object({
name: string,
}),
),
]),
config: object({
get: matchProcedure,
set: matchProcedure,
}),
properties: matchProcedure,
volumes: dictionary([string, matchVolume]),
interfaces: dictionary([
string,
object({
name: string,
"tor-config": object({}),
"lan-config": object({}),
ui: boolean,
protocols: array(string),
}),
]),
backup: object({
create: matchProcedure,
restore: matchProcedure,
}),
migrations: object({
to: dictionary([string, matchProcedure]),
from: dictionary([string, matchProcedure]),
}),
dependencies: dictionary([
string,
object(
{
version: string,
requirement: some(
object({
type: literal("opt-in"),
how: string,
}),
object({
type: literal("opt-out"),
how: string,
}),
object({
type: literal("required"),
}),
),
description: string,
config: object({
check: matchProcedure,
"auto-configure": matchProcedure,
}),
},
["description", "config"],
),
]),
actions: dictionary([string, matchAction]),
},
["config", "actions", "properties", "migrations", "dependencies"],
)
export type Manifest = typeof matchManifest._TYPE

View File

@@ -0,0 +1,35 @@
import { object, literal, string, boolean, some } from "ts-matches"
const matchDataVolume = object(
{
type: literal("data"),
readonly: boolean,
},
["readonly"],
)
const matchAssetVolume = object({
type: literal("assets"),
})
const matchPointerVolume = object({
type: literal("pointer"),
"package-id": string,
"volume-id": string,
path: string,
readonly: boolean,
})
const matchCertificateVolume = object({
type: literal("certificate"),
"interface-id": string,
})
const matchBackupVolume = object({
type: literal("backup"),
readonly: boolean,
})
export const matchVolume = some(
matchDataVolume,
matchAssetVolume,
matchPointerVolume,
matchCertificateVolume,
matchBackupVolume,
)
export type Volume = typeof matchVolume._TYPE

View File

@@ -0,0 +1,482 @@
// deno-lint-ignore no-namespace
export type ExpectedExports = {
version: 2
/** Set configuration is called after we have modified and saved the configuration in the embassy ui. Use this to make a file for the docker to read from for configuration. */
setConfig: (effects: Effects, input: Config) => Promise<ResultType<SetResult>>
/** Get configuration returns a shape that describes the format that the embassy ui will generate, and later send to the set config */
getConfig: (effects: Effects) => Promise<ResultType<ConfigRes>>
/** These are how we make sure the our dependency configurations are valid and if not how to fix them. */
dependencies: Dependencies
/** For backing up service data though the embassyOS UI */
createBackup: (effects: Effects) => Promise<ResultType<unknown>>
/** For restoring service data that was previously backed up using the embassyOS UI create backup flow. Backup restores are also triggered via the embassyOS UI, or doing a system restore flow during setup. */
restoreBackup: (effects: Effects) => Promise<ResultType<unknown>>
/** Properties are used to get values from the docker, like a username + password, what ports we are hosting from */
properties: (effects: Effects) => Promise<ResultType<Properties>>
health: {
/** Should be the health check id */
[id: string]: (
effects: Effects,
dateMs: number,
) => Promise<ResultType<unknown>>
}
migration: (
effects: Effects,
version: string,
...args: unknown[]
) => Promise<ResultType<MigrationRes>>
action: {
[id: string]: (
effects: Effects,
config?: Config,
) => Promise<ResultType<ActionResult>>
}
/**
* This is the entrypoint for the main container. Used to start up something like the service that the
* package represents, like running a bitcoind in a bitcoind-wrapper.
*/
main: (effects: Effects) => Promise<ResultType<unknown>>
}
/** Used to reach out from the pure js runtime */
export type Effects = {
/** Usable when not sandboxed */
writeFile(input: {
path: string
volumeId: string
toWrite: string
}): Promise<void>
readFile(input: { volumeId: string; path: string }): Promise<string>
metadata(input: { volumeId: string; path: string }): Promise<Metadata>
/** Create a directory. Usable when not sandboxed */
createDir(input: { volumeId: string; path: string }): Promise<string>
readDir(input: { volumeId: string; path: string }): Promise<string[]>
/** Remove a directory. Usable when not sandboxed */
removeDir(input: { volumeId: string; path: string }): Promise<string>
removeFile(input: { volumeId: string; path: string }): Promise<void>
/** Write a json file into an object. Usable when not sandboxed */
writeJsonFile(input: {
volumeId: string
path: string
toWrite: Record<string, unknown>
}): Promise<void>
/** Read a json file into an object */
readJsonFile(input: {
volumeId: string
path: string
}): Promise<Record<string, unknown>>
runCommand(input: {
command: string
args?: string[]
timeoutMillis?: number
}): Promise<ResultType<string>>
runDaemon(input: { command: string; args?: string[] }): {
wait(): Promise<ResultType<string>>
term(): Promise<void>
}
chown(input: { volumeId: string; path: string; uid: string }): Promise<null>
chmod(input: { volumeId: string; path: string; mode: string }): Promise<null>
sleep(timeMs: number): Promise<null>
/** Log at the trace level */
trace(whatToPrint: string): void
/** Log at the warn level */
warn(whatToPrint: string): void
/** Log at the error level */
error(whatToPrint: string): void
/** Log at the debug level */
debug(whatToPrint: string): void
/** Log at the info level */
info(whatToPrint: string): void
/** Sandbox mode lets us read but not write */
is_sandboxed(): boolean
exists(input: { volumeId: string; path: string }): Promise<boolean>
bindLocal(options: {
internalPort: number
name: string
externalPort: number
}): Promise<string>
bindTor(options: {
internalPort: number
name: string
externalPort: number
}): Promise<string>
fetch(
url: string,
options?: {
method?: "GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "PATCH"
headers?: Record<string, string>
body?: string
},
): Promise<{
method: string
ok: boolean
status: number
headers: Record<string, string>
body?: string | null
/// Returns the body as a string
text(): Promise<string>
/// Returns the body as a json
json(): Promise<unknown>
}>
runRsync(options: {
srcVolume: string
dstVolume: string
srcPath: string
dstPath: string
// rsync options: https://linux.die.net/man/1/rsync
options: BackupOptions
}): {
id: () => Promise<string>
wait: () => Promise<null>
progress: () => Promise<number>
}
}
// rsync options: https://linux.die.net/man/1/rsync
export type BackupOptions = {
delete: boolean
force: boolean
ignoreExisting: boolean
exclude: string[]
}
export type Metadata = {
fileType: string
isDir: boolean
isFile: boolean
isSymlink: boolean
len: number
modified?: Date
accessed?: Date
created?: Date
readonly: boolean
uid: number
gid: number
mode: number
}
export type MigrationRes = {
configured: boolean
}
export type ActionResult = {
version: "0"
message: string
value?: string
copyable: boolean
qr: boolean
}
export type ConfigRes = {
/** This should be the previous config, that way during set config we start with the previous */
config?: Config
/** Shape that is describing the form in the ui */
spec: ConfigSpec
}
export type Config = {
[propertyName: string]: unknown
}
export type ConfigSpec = {
/** Given a config value, define what it should render with the following spec */
[configValue: string]: ValueSpecAny
}
export type WithDefault<T, Default> = T & {
default: Default
}
export type WithNullableDefault<T, Default> = T & {
default?: Default
}
export type WithDescription<T> = T & {
description?: string
name: string
warning?: string
}
export type WithOptionalDescription<T> = T & {
/** @deprecated - optional only for backwards compatibility */
description?: string
/** @deprecated - optional only for backwards compatibility */
name?: string
warning?: string
}
export type ListSpec<T> = {
spec: T
range: string
}
export type Tag<T extends string, V> = V & {
type: T
}
export type Subtype<T extends string, V> = V & {
subtype: T
}
export type Target<T extends string, V> = V & {
target: T
}
export type UniqueBy =
| {
any: UniqueBy[]
}
| string
| null
export type WithNullable<T> = T & {
nullable: boolean
}
export type DefaultString =
| string
| {
/** The chars available for the random generation */
charset?: string
/** Length that we generate to */
len: number
}
export type ValueSpecString = // deno-lint-ignore ban-types
(
| {}
| {
pattern: string
"pattern-description": string
}
) & {
copyable?: boolean
masked?: boolean
placeholder?: string
}
export type ValueSpecNumber = {
/** Something like [3,6] or [0, *) */
range?: string
integral?: boolean
/** Used a description of the units */
units?: string
placeholder?: number
}
export type ValueSpecBoolean = Record<string, unknown>
export type ValueSpecAny =
| Tag<"boolean", WithDescription<WithDefault<ValueSpecBoolean, boolean>>>
| Tag<
"string",
WithDescription<
WithNullableDefault<WithNullable<ValueSpecString>, DefaultString>
>
>
| Tag<
"number",
WithDescription<
WithNullableDefault<WithNullable<ValueSpecNumber>, number>
>
>
| Tag<
"enum",
WithDescription<
WithDefault<
{
values: readonly string[] | string[]
"value-names": {
[key: string]: string
}
},
string
>
>
>
| Tag<"list", ValueSpecList>
| Tag<"object", WithDescription<WithNullableDefault<ValueSpecObject, Config>>>
| Tag<"union", WithOptionalDescription<WithDefault<ValueSpecUnion, string>>>
| Tag<
"pointer",
WithDescription<
| Subtype<
"package",
| Target<
"tor-key",
{
"package-id": string
interface: string
}
>
| Target<
"tor-address",
{
"package-id": string
interface: string
}
>
| Target<
"lan-address",
{
"package-id": string
interface: string
}
>
| Target<
"config",
{
"package-id": string
selector: string
multi: boolean
}
>
>
| Subtype<"system", Record<string, unknown>>
>
>
export type ValueSpecUnion = {
/** What tag for the specification, for tag unions */
tag: {
id: string
name: string
description?: string
"variant-names": {
[key: string]: string
}
}
/** The possible enum values */
variants: {
[key: string]: ConfigSpec
}
"display-as"?: string
"unique-by"?: UniqueBy
}
export type ValueSpecObject = {
spec: ConfigSpec
"display-as"?: string
"unique-by"?: UniqueBy
}
export type ValueSpecList =
| Subtype<
"boolean",
WithDescription<WithDefault<ListSpec<ValueSpecBoolean>, boolean[]>>
>
| Subtype<
"string",
WithDescription<WithDefault<ListSpec<ValueSpecString>, string[]>>
>
| Subtype<
"number",
WithDescription<WithDefault<ListSpec<ValueSpecNumber>, number[]>>
>
| Subtype<
"enum",
WithDescription<WithDefault<ListSpec<ValueSpecEnum>, string[]>>
>
| Subtype<
"object",
WithDescription<
WithNullableDefault<
ListSpec<ValueSpecObject>,
Record<string, unknown>[]
>
>
>
| Subtype<
"union",
WithDescription<WithDefault<ListSpec<ValueSpecUnion>, string[]>>
>
export type ValueSpecEnum = {
values: string[]
"value-names": { [key: string]: string }
}
export type SetResult = {
/** These are the unix process signals */
signal:
| "SIGTERM"
| "SIGHUP"
| "SIGINT"
| "SIGQUIT"
| "SIGILL"
| "SIGTRAP"
| "SIGABRT"
| "SIGBUS"
| "SIGFPE"
| "SIGKILL"
| "SIGUSR1"
| "SIGSEGV"
| "SIGUSR2"
| "SIGPIPE"
| "SIGALRM"
| "SIGSTKFLT"
| "SIGCHLD"
| "SIGCONT"
| "SIGSTOP"
| "SIGTSTP"
| "SIGTTIN"
| "SIGTTOU"
| "SIGURG"
| "SIGXCPU"
| "SIGXFSZ"
| "SIGVTALRM"
| "SIGPROF"
| "SIGWINCH"
| "SIGIO"
| "SIGPWR"
| "SIGSYS"
| "SIGEMT"
| "SIGINFO"
"depends-on": DependsOn
}
export type DependsOn = {
[packageId: string]: string[]
}
export type KnownError =
| { error: string }
| {
"error-code": [number, string] | readonly [number, string]
}
export type ResultType<T> = KnownError | { result: T }
export type PackagePropertiesV2 = {
[name: string]: PackagePropertyObject | PackagePropertyString
}
export type PackagePropertyString = {
type: "string"
description?: string
value: string
/** Let's the ui make this copyable button */
copyable?: boolean
/** Let the ui create a qr for this field */
qr?: boolean
/** Hiding the value unless toggled off for field */
masked?: boolean
}
export type PackagePropertyObject = {
value: PackagePropertiesV2
type: "object"
description: string
}
export type Properties = {
version: 2
data: PackagePropertiesV2
}
export type Dependencies = {
/** Id is the id of the package, should be the same as the manifest */
[id: string]: {
/** Checks are called to make sure that our dependency is in the correct shape. If a known error is returned we know that the dependency needs modification */
check(effects: Effects, input: Config): Promise<ResultType<void | null>>
/** This is called after we know that the dependency package needs a new configuration, this would be a transform for defaults */
autoConfigure(effects: Effects, input: Config): Promise<ResultType<Config>>
}
}

View File

@@ -0,0 +1,215 @@
import * as fs from "fs/promises"
import * as oet from "./oldEmbassyTypes"
import { Volume } from "../../../Models/Volume"
import * as child_process from "child_process"
import { promisify } from "util"
import { util, Utils } from "@start9labs/start-sdk"
import { Manifest } from "./matchManifest"
import { HostSystemStartOs } from "../../HostSystemStartOs"
import "isomorphic-fetch"
const { createUtils } = util
const execFile = promisify(child_process.execFile)
export class PolyfillEffects implements oet.Effects {
private utils: Utils<any, any>
constructor(
readonly effects: HostSystemStartOs,
private manifest: Manifest,
) {
this.utils = createUtils(effects as any)
}
async writeFile(input: {
path: string
volumeId: string
toWrite: string
}): Promise<void> {
await fs.writeFile(
new Volume(input.volumeId, input.path).path,
input.toWrite,
)
}
async readFile(input: { volumeId: string; path: string }): Promise<string> {
return (
await fs.readFile(new Volume(input.volumeId, input.path).path)
).toString()
}
async metadata(input: {
volumeId: string
path: string
}): Promise<oet.Metadata> {
const stats = await fs.stat(new Volume(input.volumeId, input.path).path)
return {
fileType: stats.isFile() ? "file" : "directory",
gid: stats.gid,
uid: stats.uid,
mode: stats.mode,
isDir: stats.isDirectory(),
isFile: stats.isFile(),
isSymlink: stats.isSymbolicLink(),
len: stats.size,
readonly: (stats.mode & 0o200) > 0,
}
}
async createDir(input: { volumeId: string; path: string }): Promise<string> {
const path = new Volume(input.volumeId, input.path).path
await fs.mkdir(path, { recursive: true })
return path
}
async readDir(input: { volumeId: string; path: string }): Promise<string[]> {
return fs.readdir(new Volume(input.volumeId, input.path).path)
}
async removeDir(input: { volumeId: string; path: string }): Promise<string> {
const path = new Volume(input.volumeId, input.path).path
await fs.rmdir(new Volume(input.volumeId, input.path).path, {
recursive: true,
})
return path
}
removeFile(input: { volumeId: string; path: string }): Promise<void> {
return fs.rm(new Volume(input.volumeId, input.path).path)
}
async writeJsonFile(input: {
volumeId: string
path: string
toWrite: Record<string, unknown>
}): Promise<void> {
await fs.writeFile(
new Volume(input.volumeId, input.path).path,
JSON.stringify(input.toWrite),
)
}
async readJsonFile(input: {
volumeId: string
path: string
}): Promise<Record<string, unknown>> {
return JSON.parse(
(
await fs.readFile(new Volume(input.volumeId, input.path).path)
).toString(),
)
}
runCommand({
command,
args,
timeoutMillis,
}: {
command: string
args?: string[] | undefined
timeoutMillis?: number | undefined
}): Promise<oet.ResultType<string>> {
return this.utils
.runCommand(this.manifest.main.image, [command, ...(args || [])], {})
.then((x) => ({
stderr: x.stderr.toString(),
stdout: x.stdout.toString(),
}))
.then((x) => (!!x.stderr ? { error: x.stderr } : { result: x.stdout }))
}
runDaemon(input: { command: string; args?: string[] | undefined }): {
wait(): Promise<oet.ResultType<string>>
term(): Promise<void>
} {
throw new Error("Method not implemented.")
}
chown(input: { volumeId: string; path: string; uid: string }): Promise<null> {
throw new Error("Method not implemented.")
}
chmod(input: {
volumeId: string
path: string
mode: string
}): Promise<null> {
throw new Error("Method not implemented.")
}
sleep(timeMs: number): Promise<null> {
return new Promise((resolve) => setTimeout(resolve, timeMs))
}
trace(whatToPrint: string): void {
console.trace(whatToPrint)
}
warn(whatToPrint: string): void {
console.warn(whatToPrint)
}
error(whatToPrint: string): void {
console.error(whatToPrint)
}
debug(whatToPrint: string): void {
console.debug(whatToPrint)
}
info(whatToPrint: string): void {
console.log(false)
}
is_sandboxed(): boolean {
return false
}
exists(input: { volumeId: string; path: string }): Promise<boolean> {
return this.metadata(input)
.then(() => true)
.catch(() => false)
}
bindLocal(options: {
internalPort: number
name: string
externalPort: number
}): Promise<string> {
throw new Error("Method not implemented.")
}
bindTor(options: {
internalPort: number
name: string
externalPort: number
}): Promise<string> {
throw new Error("Method not implemented.")
}
async fetch(
url: string,
options?:
| {
method?:
| "GET"
| "POST"
| "PUT"
| "DELETE"
| "HEAD"
| "PATCH"
| undefined
headers?: Record<string, string> | undefined
body?: string | undefined
}
| undefined,
): Promise<{
method: string
ok: boolean
status: number
headers: Record<string, string>
body?: string | null | undefined
text(): Promise<string>
json(): Promise<unknown>
}> {
const fetched = await fetch(url, options)
return {
method: fetched.type,
ok: fetched.ok,
status: fetched.status,
headers: Object.fromEntries(fetched.headers.entries()),
body: await fetched.text(),
text: () => fetched.text(),
json: () => fetched.json(),
}
}
runRsync(options: {
srcVolume: string
dstVolume: string
srcPath: string
dstPath: string
options: oet.BackupOptions
}): {
id: () => Promise<string>
wait: () => Promise<null>
progress: () => Promise<number>
} {
throw new Error("Method not implemented.")
}
}

View File

@@ -0,0 +1,150 @@
import { ExecuteResult, System } from "../../Interfaces/System"
import { unNestPath } from "../../Models/JsonPath"
import { string } from "ts-matches"
import { HostSystemStartOs } from "../HostSystemStartOs"
import { Effects } from "../../Models/Effects"
const LOCATION = "/usr/lib/startos/package/startos"
export class SystemForStartOs implements System {
private onTerm: (() => Promise<void>) | undefined
static of() {
return new SystemForStartOs()
}
constructor() {}
async execute(
effects: HostSystemStartOs,
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`
input: unknown
timeout?: number | undefined
},
): Promise<ExecuteResult> {
return { ok: await this._execute(effects, options) }
}
async _execute(
effects: Effects,
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`
input: unknown
timeout?: number | undefined
},
): Promise<unknown> {
switch (options.procedure) {
case "/init": {
const path = `${LOCATION}/procedures/init`
const procedure: any = await import(path).catch(() => require(path))
const previousVersion = string.optional().unsafeCast(options)
return procedure.init({ effects, previousVersion })
}
case "/uninit": {
const path = `${LOCATION}/procedures/init`
const procedure: any = await import(path).catch(() => require(path))
const nextVersion = string.optional().unsafeCast(options)
return procedure.uninit({ effects, nextVersion })
}
case "/main/start": {
const path = `${LOCATION}/procedures/main`
const procedure: any = await import(path).catch(() => require(path))
const started = async (onTerm: () => Promise<void>) => {
await effects.setMainStatus({ status: "running" })
if (this.onTerm) await this.onTerm()
this.onTerm = onTerm
}
return procedure.main({ effects, started })
}
case "/main/stop": {
await effects.setMainStatus({ status: "stopped" })
if (this.onTerm) await this.onTerm()
delete this.onTerm
return
}
case "/config/set": {
const path = `${LOCATION}/procedures/config`
const procedure: any = await import(path).catch(() => require(path))
const input = options.input
return procedure.setConfig({ effects, input })
}
case "/config/get": {
const path = `${LOCATION}/procedures/config`
const procedure: any = await import(path).catch(() => require(path))
return procedure.getConfig({ effects })
}
case "/backup/create":
case "/backup/restore":
throw new Error("this should be called with the init/unit")
case "/actions/metadata": {
const path = `${LOCATION}/procedures/actions`
const procedure: any = await import(path).catch(() => require(path))
return procedure.actionsMetadata({ effects })
}
default:
const procedures = unNestPath(options.procedure)
const id = procedures[2]
switch (true) {
case procedures[1] === "actions" && procedures[3] === "get": {
const path = `${LOCATION}/procedures/actions`
const action: any = (await import(path).catch(() => require(path)))
.actions[id]
if (!action) throw new Error(`Action ${id} not found`)
return action.get({ effects })
}
case procedures[1] === "actions" && procedures[3] === "run": {
const path = `${LOCATION}/procedures/actions`
const action: any = (await import(path).catch(() => require(path)))
.actions[id]
if (!action) throw new Error(`Action ${id} not found`)
const input = options.input
return action.run({ effects, input })
}
case procedures[1] === "dependencies" && procedures[3] === "query": {
const path = `${LOCATION}/procedures/dependencies`
const dependencyConfig: any = (
await import(path).catch(() => require(path))
).dependencyConfig[id]
if (!dependencyConfig)
throw new Error(`dependencyConfig ${id} not found`)
const localConfig = options.input
return dependencyConfig.query({ effects, localConfig })
}
case procedures[1] === "dependencies" && procedures[3] === "update": {
const path = `${LOCATION}/procedures/dependencies`
const dependencyConfig: any = (
await import(path).catch(() => require(path))
).dependencyConfig[id]
if (!dependencyConfig)
throw new Error(`dependencyConfig ${id} not found`)
return dependencyConfig.update(options.input)
}
}
}
throw new Error("Method not implemented.")
}
exit(effects: Effects): Promise<void> {
throw new Error("Method not implemented.")
}
}

View File

@@ -0,0 +1,6 @@
import { System } from "../../Interfaces/System"
import { SystemForEmbassy } from "./SystemForEmbassy"
import { SystemForStartOs } from "./SystemForStartOs"
export async function getSystem(): Promise<System> {
return SystemForEmbassy.of()
}

View File

@@ -0,0 +1,6 @@
import { GetDependency } from "./GetDependency"
import { System } from "./System"
import { GetHostSystem, HostSystem } from "./HostSystem"
export type AllGetDependencies = GetDependency<"system", Promise<System>> &
GetDependency<"hostSystem", GetHostSystem>

View File

@@ -0,0 +1,3 @@
export type GetDependency<K extends string, T> = {
[OtherK in K]: () => T
}

View File

@@ -0,0 +1,7 @@
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) => HostSystem

View File

@@ -0,0 +1,31 @@
import { types as T } from "@start9labs/start-sdk"
import { JsonPath } from "../Models/JsonPath"
import { HostSystemStartOs } from "../Adapters/HostSystemStartOs"
export type ExecuteResult =
| { ok: unknown }
| { err: { code: number; message: string } }
export interface System {
// init(effects: Effects): Promise<void>
// exit(effects: Effects): Promise<void>
// start(effects: Effects): Promise<void>
// stop(effects: Effects, options: { timeout: number, signal?: number }): Promise<void>
execute(
effects: T.Effects,
options: {
procedure: JsonPath
input: unknown
timeout?: number
},
): Promise<ExecuteResult>
// sandbox(
// effects: Effects,
// options: {
// procedure: JsonPath
// input: unknown
// timeout?: number
// },
// ): Promise<unknown>
exit(effects: T.Effects): Promise<void>
}

View File

@@ -0,0 +1,18 @@
export class CallbackHolder {
constructor() {}
private root = (Math.random() + 1).toString(36).substring(7)
private inc = 0
private callbacks = new Map<string, Function>()
private newId() {
return this.root + (this.inc++).toString(36)
}
addCallback(callback: Function) {
return this.callbacks.set(this.newId(), callback)
}
callCallback(index: string, args: any[]): Promise<unknown> {
const callback = this.callbacks.get(index)
if (!callback) throw new Error(`Callback ${index} does not exist`)
this.callbacks.delete(index)
return Promise.resolve().then(() => callback(...args))
}
}

View File

@@ -0,0 +1,45 @@
import {
object,
literal,
string,
boolean,
array,
dictionary,
literals,
number,
Parser,
} from "ts-matches"
const VolumeId = string
const Path = string
export type VolumeId = string
export type Path = string
export const matchDockerProcedure = object(
{
type: literal("docker"),
image: string,
system: boolean,
entrypoint: string,
args: array(string),
mounts: dictionary([VolumeId, Path]),
"io-format": literals(
"json",
"json-pretty",
"yaml",
"cbor",
"toml",
"toml-pretty",
),
"sigterm-timeout": number,
inject: boolean,
},
["io-format", "sigterm-timeout", "system", "args", "inject", "mounts"],
{
"sigterm-timeout": 30,
inject: false,
args: [],
},
)
export type DockerProcedure = typeof matchDockerProcedure._TYPE

View File

@@ -0,0 +1,5 @@
import { types as T } from "@start9labs/start-sdk"
export type Effects = T.Effects & {
setMainStatus(o: { status: "running" | "stopped" }): Promise<void>
}

View File

@@ -0,0 +1,42 @@
import { literals, some, string } from "ts-matches"
type NestedPath<A extends string, B extends string> = `/${A}/${string}/${B}`
type NestedPaths =
| NestedPath<"actions", "run" | "get">
| NestedPath<"dependencies", "query" | "update">
// prettier-ignore
type UnNestPaths<A> =
A extends `${infer A}/${infer B}` ? [...UnNestPaths<A>, ... UnNestPaths<B>] :
[A]
export function unNestPath<A extends string>(a: A): UnNestPaths<A> {
return a.split("/") as UnNestPaths<A>
}
function isNestedPath(path: string): path is NestedPaths {
const paths = path.split("/")
if (paths.length !== 4) return false
if (paths[1] === "action" && (paths[3] === "run" || paths[3] === "get"))
return true
if (
paths[1] === "dependencyConfig" &&
(paths[3] === "query" || paths[3] === "update")
)
return true
return false
}
export const jsonPath = some(
literals(
"/init",
"/uninit",
"/main/start",
"/main/stop",
"/config/set",
"/config/get",
"/backup/create",
"/backup/restore",
"/actions/metadata",
),
string.refine(isNestedPath, "isNestedPath"),
)
export type JsonPath = typeof jsonPath._TYPE

View File

@@ -0,0 +1,19 @@
import * as fs from "node:fs/promises"
export class Volume {
readonly path: string
constructor(
readonly volumeId: string,
_path = "",
) {
const path = (this.path = `/media/startos/volumes/${volumeId}${
!_path ? "" : `/${_path}`
}`)
}
async exists() {
return fs.stat(this.path).then(
() => true,
() => false,
)
}
}

View File

@@ -1,6 +1,15 @@
import { Runtime } from "./Runtime"
import { RpcListener } from "./Adapters/RpcListener"
import { SystemForEmbassy } from "./Adapters/Systems/SystemForEmbassy"
import { HostSystemStartOs } from "./Adapters/HostSystemStartOs"
import { AllGetDependencies } from "./Interfaces/AllGetDependencies"
import { getSystem } from "./Adapters/Systems"
new Runtime()
const getDependencies: AllGetDependencies = {
system: getSystem,
hostSystem: () => HostSystemStartOs.of,
}
new RpcListener(getDependencies)
/**

View File

@@ -2,20 +2,25 @@
"include": [
"./**/*.mjs",
"./**/*.js",
"initSrc/Runtime.ts",
"initSrc/index.ts",
"src/Adapters/RpcListener.ts",
"src/index.ts",
"effects.ts"
],
"exclude": [],
"inputs": ["./lib/index.ts"],
"exclude": ["dist"],
"inputs": ["./src/index.ts"],
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "node",
"allowJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"module": "Node16",
"strict": true,
"outDir": "dist",
"preserveConstEnums": true,
"sourceMap": true,
"target": "ES2022",
"pretty": true,
"declaration": true,
"noImplicitAny": true,
"esModuleInterop": true,
"types": ["node"],
"moduleResolution": "Node16",
"skipLibCheck": true
},
"ts-node": {

View File

@@ -0,0 +1,41 @@
#!/bin/bash
cd "$(dirname "${BASH_SOURCE[0]}")"
set -e
if mountpoint tmp/combined; then sudo umount tmp/combined; fi
if mountpoint tmp/lower; then sudo umount tmp/lower; fi
mkdir -p tmp/lower tmp/upper tmp/work tmp/combined
sudo mount alpine.squashfs tmp/lower
sudo mount -t overlay -olowerdir=tmp/lower,upperdir=tmp/upper,workdir=tmp/work overlay tmp/combined
QEMU=
if [ "$ARCH" != "$(uname -m)" ]; then
QEMU=/usr/bin/qemu-${ARCH}-static
sudo cp $(which qemu-$ARCH-static) tmp/combined${QEMU}
fi
echo "nameserver 8.8.8.8" | sudo tee tmp/combined/etc/resolv.conf # TODO - delegate to host resolver?
sudo chroot tmp/combined $QEMU /sbin/apk add nodejs
sudo mkdir -p tmp/combined/usr/lib/startos/
sudo rsync -a --copy-unsafe-links dist/ tmp/combined/usr/lib/startos/init/
sudo cp containerRuntime.rc tmp/combined/etc/init.d/containerRuntime
sudo cp ../core/target/$ARCH-unknown-linux-musl/release/containerbox tmp/combined/usr/bin/start-cli
sudo chmod +x tmp/combined/etc/init.d/containerRuntime
sudo chroot tmp/combined $QEMU /sbin/rc-update add containerRuntime default
if [ -n "$QEMU" ]; then
sudo rm tmp/combined${QEMU}
fi
sudo truncate -s 0 tmp/combined/etc/resolv.conf
sudo chown -R 0:0 tmp/combined
rm -f ../build/lib/container-runtime/rootfs.squashfs
mkdir -p ../build/lib/container-runtime
sudo mksquashfs tmp/combined ../build/lib/container-runtime/rootfs.squashfs
sudo umount tmp/combined
sudo umount tmp/lower
sudo rm -rf tmp