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

This commit is contained in:
Matt Hill
2024-11-25 19:02:07 -07:00
712 changed files with 83068 additions and 9240 deletions

View File

@@ -78,7 +78,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
python-version: "3.x"
- uses: actions/setup-node@v4
with:
@@ -156,7 +156,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
python-version: "3.x"
- name: Install dependencies
run: |
@@ -187,11 +187,27 @@ jobs:
run: |
mkdir -p web/node_modules
mkdir -p web/dist/raw
touch core/startos/bindings
touch sdk/lib/osBindings
mkdir -p core/startos/bindings
mkdir -p sdk/base/lib/osBindings
mkdir -p container-runtime/node_modules
mkdir -p container-runtime/dist
mkdir -p container-runtime/dist/node_modules
mkdir -p core/startos/bindings
mkdir -p sdk/dist
mkdir -p sdk/baseDist
mkdir -p patch-db/client/node_modules
mkdir -p patch-db/client/dist
mkdir -p web/.angular
mkdir -p web/dist/raw/ui
mkdir -p web/dist/raw/install-wizard
mkdir -p web/dist/raw/setup-wizard
mkdir -p web/dist/static/ui
mkdir -p web/dist/static/install-wizard
mkdir -p web/dist/static/setup-wizard
PLATFORM=${{ matrix.platform }} make -t compiled-${{ env.ARCH }}.tar
- run: git status
- name: Run iso build
run: PLATFORM=${{ matrix.platform }} make iso
if: ${{ matrix.platform != 'raspberrypi' }}

View File

@@ -11,7 +11,7 @@ on:
- next/*
env:
NODEJS_VERSION: "18.15.0"
NODEJS_VERSION: "20.16.0"
ENVIRONMENT: dev-unstable
jobs:

40
CLEARNET.md Normal file
View File

@@ -0,0 +1,40 @@
# Setting up clearnet for a service interface
NOTE: this guide is for HTTPS only! Other configurations may require a more bespoke setup depending on the service. Please consult the service documentation or the Start9 Community for help with non-HTTPS applications
## Initialize ACME certificate generation
The following command will register your device with an ACME certificate provider, such as letsencrypt
This only needs to be done once.
```
start-cli net acme init --provider=letsencrypt --contact="mailto:me@drbonez.dev"
```
- `provider` can be `letsencrypt`, `letsencrypt-staging` (useful if you're doing a lot of testing and want to avoid being rate limited), or the url of any provider that supports the [RFC8555](https://datatracker.ietf.org/doc/html/rfc8555) ACME api
- `contact` can be any valid contact url, typically `mailto:` urls. it can be specified multiple times to set multiple contacts
## Whitelist a domain for ACME certificate acquisition
The following command will tell the OS to use ACME certificates instead of system signed ones for the provided url. In this example, `testing.drbonez.dev`
This must be done for every domain you wish to host on clearnet.
```
start-cli net acme domain add "testing.drbonez.dev"
```
## Forward clearnet port
Go into your router settings, and map port 443 on your router to port 5443 on your start-os device. This one port should cover most use cases
## Add domain to service host
The following command will tell the OS to route https requests from the WAN to the provided hostname to the specified service. In this example, we are adding `testing.drbonez.dev` to the host `ui-multi` on the package `hello-world`. To see a list of available host IDs for a given package, run `start-cli package host <PACKAGE> list`
This must be done for every domain you wish to host on clearnet.
```
start-cli package host hello-world address ui-multi add testing.drbonez.dev
```

View File

@@ -27,6 +27,7 @@ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
source ~/.bashrc
nvm install 20
nvm use 20
nvm alias default 20 # this prevents your machine from reverting back to another version
```
## Cloning the repository

View File

@@ -6,7 +6,8 @@ BASENAME := $(shell ./basename.sh)
PLATFORM := $(shell if [ -f ./PLATFORM.txt ]; then cat ./PLATFORM.txt; else echo unknown; fi)
ARCH := $(shell if [ "$(PLATFORM)" = "raspberrypi" ]; then echo aarch64; else echo $(PLATFORM) | sed 's/-nonfree$$//g'; fi)
IMAGE_TYPE=$(shell if [ "$(PLATFORM)" = raspberrypi ]; then echo img; else echo iso; fi)
WEB_UIS := web/dist/raw/ui web/dist/raw/setup-wizard web/dist/raw/install-wizard
WEB_UIS := web/dist/raw/ui/index.html web/dist/raw/setup-wizard/index.html web/dist/raw/install-wizard/index.html
COMPRESSED_WEB_UIS := web/dist/static/ui/index.html web/dist/static/setup-wizard/index.html web/dist/static/install-wizard/index.html
FIRMWARE_ROMS := ./firmware/$(PLATFORM) $(shell jq --raw-output '.[] | select(.platform[] | contains("$(PLATFORM)")) | "./firmware/$(PLATFORM)/" + .id + ".rom.gz"' build/lib/firmware.json)
BUILD_SRC := $(shell git ls-files build) build/lib/depends build/lib/conflicts $(FIRMWARE_ROMS)
DEBIAN_SRC := $(shell git ls-files debian/)
@@ -16,7 +17,7 @@ COMPAT_SRC := $(shell git ls-files system-images/compat/)
UTILS_SRC := $(shell git ls-files system-images/utils/)
BINFMT_SRC := $(shell git ls-files system-images/binfmt/)
CORE_SRC := $(shell git ls-files core) $(shell git ls-files --recurse-submodules patch-db) $(GIT_HASH_FILE)
WEB_SHARED_SRC := $(shell git ls-files web/projects/shared) $(shell ls -p web/ | grep -v / | sed 's/^/web\//g') web/node_modules/.package-lock.json web/config.json patch-db/client/dist web/patchdb-ui-seed.json sdk/dist
WEB_SHARED_SRC := $(shell git ls-files web/projects/shared) $(shell ls -p web/ | grep -v / | sed 's/^/web\//g') web/node_modules/.package-lock.json web/config.json patch-db/client/dist/index.js sdk/baseDist/package.json web/patchdb-ui-seed.json sdk/dist/package.json
WEB_UI_SRC := $(shell git ls-files web/projects/ui)
WEB_SETUP_WIZARD_SRC := $(shell git ls-files web/projects/setup-wizard)
WEB_INSTALL_WIZARD_SRC := $(shell git ls-files web/projects/install-wizard)
@@ -47,7 +48,7 @@ endif
.DELETE_ON_ERROR:
.PHONY: all metadata install clean format cli uis ui reflash deb $(IMAGE_TYPE) squashfs sudo wormhole wormhole-deb test test-core test-sdk test-container-runtime
.PHONY: all metadata install clean format cli uis ui reflash deb $(IMAGE_TYPE) squashfs sudo wormhole wormhole-deb test test-core test-sdk test-container-runtime registry
all: $(ALL_TARGETS)
@@ -94,15 +95,18 @@ test: | test-core test-sdk test-container-runtime
test-core: $(CORE_SRC) $(ENVIRONMENT_FILE)
./core/run-tests.sh
test-sdk: $(shell git ls-files sdk) sdk/lib/osBindings
test-sdk: $(shell git ls-files sdk) sdk/base/lib/osBindings/index.ts
cd sdk && make test
test-container-runtime: container-runtime/node_modules $(shell git ls-files container-runtime/src) container-runtime/package.json container-runtime/tsconfig.json
test-container-runtime: container-runtime/node_modules/.package-lock.json $(shell git ls-files container-runtime/src) container-runtime/package.json container-runtime/tsconfig.json
cd container-runtime && npm test
cli:
cd core && ./install-cli.sh
registry:
cd core && ./build-registrybox.sh
deb: results/$(BASENAME).deb
debian/control: build/lib/depends build/lib/conflicts
@@ -209,7 +213,7 @@ emulate-reflash: $(ALL_TARGETS)
@if [ -z "$(REMOTE)" ]; then >&2 echo "Must specify REMOTE" && false; fi
$(call ssh,'sudo /usr/lib/startos/scripts/chroot-and-upgrade --create')
$(MAKE) install REMOTE=$(REMOTE) SSHPASS=$(SSHPASS) DESTDIR=/media/startos/next PLATFORM=$(PLATFORM)
$(call ssh,'sudo rm -f /media/startos/config/disk.guid')
$(call ssh,'sudo rm -f /media/startos/config/disk.guid /media/startos/config/overlay/etc/hostname')
$(call ssh,'sudo /media/startos/next/usr/lib/startos/scripts/chroot-and-upgrade --no-sync "apt-get install -y $(shell cat ./build/lib/depends)"')
upload-ota: results/$(BASENAME).squashfs
@@ -218,34 +222,36 @@ upload-ota: results/$(BASENAME).squashfs
container-runtime/debian.$(ARCH).squashfs:
ARCH=$(ARCH) ./container-runtime/download-base-image.sh
container-runtime/node_modules: container-runtime/package.json container-runtime/package-lock.json sdk/dist
container-runtime/node_modules/.package-lock.json: container-runtime/package.json container-runtime/package-lock.json sdk/dist/package.json
npm --prefix container-runtime ci
touch container-runtime/node_modules
touch container-runtime/node_modules/.package-lock.json
sdk/lib/osBindings: core/startos/bindings
mkdir -p sdk/lib/osBindings
ls core/startos/bindings/*.ts | sed 's/core\/startos\/bindings\/\([^.]*\)\.ts/export { \1 } from ".\/\1";/g' > core/startos/bindings/index.ts
npm --prefix sdk exec -- prettier --config ./sdk/package.json -w ./core/startos/bindings/*.ts
rsync -ac --delete core/startos/bindings/ sdk/lib/osBindings/
touch sdk/lib/osBindings
sdk/base/lib/osBindings/index.ts: core/startos/bindings/index.ts
mkdir -p sdk/base/lib/osBindings
rsync -ac --delete core/startos/bindings/ sdk/base/lib/osBindings/
touch sdk/base/lib/osBindings/index.ts
core/startos/bindings: $(shell git ls-files core) $(ENVIRONMENT_FILE)
core/startos/bindings/index.ts: $(shell git ls-files core) $(ENVIRONMENT_FILE)
rm -rf core/startos/bindings
./core/build-ts.sh
touch core/startos/bindings
ls core/startos/bindings/*.ts | sed 's/core\/startos\/bindings\/\([^.]*\)\.ts/export { \1 } from ".\/\1";/g' | grep -v '"./index"' | tee core/startos/bindings/index.ts
npm --prefix sdk exec -- prettier --config ./sdk/base/package.json -w ./core/startos/bindings/*.ts
touch core/startos/bindings/index.ts
sdk/dist: $(shell git ls-files sdk) sdk/lib/osBindings
sdk/dist/package.json sdk/baseDist/package.json: $(shell git ls-files sdk) sdk/base/lib/osBindings/index.ts
(cd sdk && make bundle)
touch sdk/dist/package.json
touch sdk/baseDist/package.json
# TODO: make container-runtime its own makefile?
container-runtime/dist/index.js: container-runtime/node_modules $(shell git ls-files container-runtime/src) container-runtime/package.json container-runtime/tsconfig.json
container-runtime/dist/index.js: container-runtime/node_modules/.package-lock.json $(shell git ls-files container-runtime/src) container-runtime/package.json container-runtime/tsconfig.json
npm --prefix container-runtime run build
container-runtime/dist/node_modules container-runtime/dist/package.json container-runtime/dist/package-lock.json: container-runtime/package.json container-runtime/package-lock.json sdk/dist container-runtime/install-dist-deps.sh
container-runtime/dist/node_modules/.package-lock.json container-runtime/dist/package.json container-runtime/dist/package-lock.json: container-runtime/package.json container-runtime/package-lock.json sdk/dist/package.json container-runtime/install-dist-deps.sh
./container-runtime/install-dist-deps.sh
touch container-runtime/dist/node_modules
touch container-runtime/dist/node_modules/.package-lock.json
container-runtime/rootfs.$(ARCH).squashfs: container-runtime/debian.$(ARCH).squashfs container-runtime/container-runtime.service container-runtime/update-image.sh container-runtime/deb-install.sh container-runtime/dist/index.js container-runtime/dist/node_modules core/target/$(ARCH)-unknown-linux-musl/release/containerbox | sudo
container-runtime/rootfs.$(ARCH).squashfs: container-runtime/debian.$(ARCH).squashfs container-runtime/container-runtime.service container-runtime/update-image.sh container-runtime/deb-install.sh container-runtime/dist/index.js container-runtime/dist/node_modules/.package-lock.json core/target/$(ARCH)-unknown-linux-musl/release/containerbox | sudo
ARCH=$(ARCH) ./container-runtime/update-image.sh
build/lib/depends build/lib/conflicts: build/dpkg-deps/*
@@ -263,7 +269,7 @@ system-images/utils/docker-images/$(ARCH).tar: $(UTILS_SRC)
system-images/binfmt/docker-images/$(ARCH).tar: $(BINFMT_SRC)
cd system-images/binfmt && make docker-images/$(ARCH).tar && touch docker-images/$(ARCH).tar
core/target/$(ARCH)-unknown-linux-musl/release/startbox: $(CORE_SRC) web/dist/static web/patchdb-ui-seed.json $(ENVIRONMENT_FILE)
core/target/$(ARCH)-unknown-linux-musl/release/startbox: $(CORE_SRC) $(COMPRESSED_WEB_UIS) web/patchdb-ui-seed.json $(ENVIRONMENT_FILE)
ARCH=$(ARCH) ./core/build-startbox.sh
touch core/target/$(ARCH)-unknown-linux-musl/release/startbox
@@ -271,27 +277,28 @@ core/target/$(ARCH)-unknown-linux-musl/release/containerbox: $(CORE_SRC) $(ENVIR
ARCH=$(ARCH) ./core/build-containerbox.sh
touch core/target/$(ARCH)-unknown-linux-musl/release/containerbox
web/node_modules/.package-lock.json: web/package.json sdk/dist
web/node_modules/.package-lock.json: web/package.json sdk/baseDist/package.json
npm --prefix web ci
touch web/node_modules/.package-lock.json
web/.angular: patch-db/client/dist sdk/dist web/node_modules/.package-lock.json
web/.angular/.updated: patch-db/client/dist/index.js sdk/baseDist/package.json web/node_modules/.package-lock.json
rm -rf web/.angular
mkdir -p web/.angular
touch web/.angular/.updated
web/dist/raw/ui: $(WEB_UI_SRC) $(WEB_SHARED_SRC) web/.angular
web/dist/raw/ui/index.html: $(WEB_UI_SRC) $(WEB_SHARED_SRC) web/.angular/.updated
npm --prefix web run build:ui
touch web/dist/raw/ui
touch web/dist/raw/ui/index.html
web/dist/raw/setup-wizard: $(WEB_SETUP_WIZARD_SRC) $(WEB_SHARED_SRC) web/.angular
web/dist/raw/setup-wizard/index.html: $(WEB_SETUP_WIZARD_SRC) $(WEB_SHARED_SRC) web/.angular/.updated
npm --prefix web run build:setup
touch web/dist/raw/setup-wizard
touch web/dist/raw/setup-wizard/index.html
web/dist/raw/install-wizard: $(WEB_INSTALL_WIZARD_SRC) $(WEB_SHARED_SRC) web/.angular
npm --prefix web run build:install
touch web/dist/raw/install-wizard
web/dist/raw/install-wizard/index.html: $(WEB_INSTALL_WIZARD_SRC) $(WEB_SHARED_SRC) web/.angular/.updated
npm --prefix web run build:install-wiz
touch web/dist/raw/install-wizard/index.html
web/dist/static: $(WEB_UIS) $(ENVIRONMENT_FILE)
$(COMPRESSED_WEB_UIS): $(WEB_UIS) $(ENVIRONMENT_FILE)
./compress-uis.sh
web/config.json: $(GIT_HASH_FILE) web/config-sample.json
@@ -301,13 +308,14 @@ web/patchdb-ui-seed.json: web/package.json
jq '."ack-welcome" = $(shell jq '.version' web/package.json)' web/patchdb-ui-seed.json > ui-seed.tmp
mv ui-seed.tmp web/patchdb-ui-seed.json
patch-db/client/node_modules: patch-db/client/package.json
patch-db/client/node_modules/.package-lock.json: patch-db/client/package.json
npm --prefix patch-db/client ci
touch patch-db/client/node_modules
touch patch-db/client/node_modules/.package-lock.json
patch-db/client/dist: $(PATCH_DB_CLIENT_SRC) patch-db/client/node_modules
patch-db/client/dist/index.js: $(PATCH_DB_CLIENT_SRC) patch-db/client/node_modules/.package-lock.json
rm -rf patch-db/client/dist
npm --prefix patch-db/client run build
touch patch-db/client/dist/index.js
# used by github actions
compiled-$(ARCH).tar: $(COMPILED_TARGETS) $(ENVIRONMENT_FILE) $(GIT_HASH_FILE) $(VERSION_FILE)

View File

@@ -9,6 +9,7 @@ ca-certificates
cifs-utils
cryptsetup
curl
dnsutils
dmidecode
dosfstools
e2fsprogs

View File

@@ -43,6 +43,8 @@ if [ -z "$NO_SYNC" ]; then
mount -t overlay \
-olowerdir=/media/startos/current,upperdir=/media/startos/upper/data,workdir=/media/startos/upper/work \
overlay /media/startos/next
mkdir -p /media/startos/next/media/startos/root
mount --bind /media/startos/root /media/startos/next/media/startos/root
fi
if [ -n "$ONLY_CREATE" ]; then
@@ -75,6 +77,7 @@ umount /media/startos/next/dev
umount /media/startos/next/sys
umount /media/startos/next/proc
umount /media/startos/next/boot
umount /media/startos/next/media/startos/root
if [ "$CHROOT_RES" -eq 0 ]; then
@@ -84,7 +87,12 @@ if [ "$CHROOT_RES" -eq 0 ]; then
echo 'Upgrading...'
time mksquashfs /media/startos/next /media/startos/images/next.squashfs -b 4096 -comp gzip
if ! time mksquashfs /media/startos/next /media/startos/images/next.squashfs -b 4096 -comp gzip; then
umount -R /media/startos/next
umount -R /media/startos/upper
rm -rf /media/startos/upper /media/startos/next
exit 1
fi
hash=$(b3sum /media/startos/images/next.squashfs | head -c 32)
mv /media/startos/images/next.squashfs /media/startos/images/${hash}.rootfs
ln -rsf /media/startos/images/${hash}.rootfs /media/startos/config/current.rootfs

View File

@@ -33,10 +33,11 @@ if [ -h /media/startos/config/current.rootfs ] && [ -e /media/startos/config/cur
echo 'Pruning...'
current="$(readlink -f /media/startos/config/current.rootfs)"
while [[ "$(df -B1 --output=avail --sync /media/startos/images | tail -n1)" -lt "$needed" ]]; do
to_prune="$(ls -t1 /media/startos/images/*.rootfs /media/startos/images/*.squashfs | grep -v "$current" | tail -n1)"
to_prune="$(ls -t1 /media/startos/images/*.rootfs /media/startos/images/*.squashfs 2> /dev/null | grep -v "$current" | tail -n1)"
if [ -e "$to_prune" ]; then
echo " Pruning $to_prune"
rm -rf "$to_prune"
sync
else
>&2 echo "Not enough space and nothing to prune!"
exit 1

690
code Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +0,0 @@
## Testing
So, we are going to
1. create a fake server
2. pretend socket server os (while the fake server is running)
3. Run a fake effects system (while 1/2 are running)
In order to simulate that we created a server like the start-os and
a fake server (in this case I am using syncthing-wrapper)
### TODO
Undo the packing that I have done earlier, and hijack the embassy.js to use the bundle service + code
Converting embassy.js -> service.js
```sequence {theme="hand"}
startOs ->> startInit.js: Rpc Call
startInit.js ->> service.js: Rpc Converted into js code
```
### Create a fake server
```bash
run_test () {
(
set -e
libs=/home/jh/Projects/start-os/libs/start_init
sockets=/tmp/start9
service=/home/jh/Projects/syncthing-wrapper
docker run \
-v $libs:/libs \
-v $service:/service \
-w /libs \
--rm node:18-alpine \
sh -c "
npm i &&
npm run bundle:esbuild &&
npm run bundle:service
"
docker run \
-v ./libs/start_init/:/libs \
-w /libs \
--rm node:18-alpine \
sh -c "
npm i &&
npm run bundle:esbuild
"
rm -rf $sockets || true
mkdir -p $sockets/sockets
cd $service
docker run \
-v $libs:/start-init \
-v $sockets:/start9 \
--rm -it $(docker build -q .) sh -c "
apk add nodejs &&
node /start-init/bundleEs.js
"
)
}
run_test
```
### Pretend Socket Server OS
First we are going to create our fake server client with the bash then send it the json possible data
```bash
sudo socat - unix-client:/tmp/start9/sockets/rpc.sock
```
<!-- prettier-ignore -->
```json
{"id":"a","method":"run","params":{"methodName":"/dependencyMounts","methodArgs":[]}}
{"id":"a","method":"run","params":{"methodName":"/actions/test","methodArgs":{"input":{"id": 1}}}}
{"id":"b","method":"run","params":{"methodName":"/actions/test","methodArgs":{"id": 1}}}
```

View File

@@ -4,7 +4,7 @@ import { object, string, number, literals, some, unknown } from "ts-matches"
import { Effects } from "../Models/Effects"
import { CallbackHolder } from "../Models/CallbackHolder"
import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk"
import { asError } from "@start9labs/start-sdk/base/lib/util"
const matchRpcError = object({
error: object(
{
@@ -35,7 +35,8 @@ let hostSystemId = 0
export type EffectContext = {
procedureId: string | null
callbacks: CallbackHolder | null
callbacks?: CallbackHolder
constRetry: () => void
}
const rpcRoundFor =
@@ -50,7 +51,7 @@ const rpcRoundFor =
JSON.stringify({
id,
method,
params: { ...params, procedureId },
params: { ...params, procedureId: procedureId || undefined },
}) + "\n",
)
})
@@ -67,7 +68,7 @@ const rpcRoundFor =
let message = res.error.message
console.error(
"Error in host RPC:",
utils.asError({ method, params }),
utils.asError({ method, params, error: res.error }),
)
if (string.test(res.error.data)) {
message += ": " + res.error.data
@@ -100,24 +101,64 @@ const rpcRoundFor =
})
}
function makeEffects(context: EffectContext): Effects {
export function makeEffects(context: EffectContext): Effects {
const rpcRound = rpcRoundFor(context.procedureId)
const self: Effects = {
constRetry: context.constRetry,
clearCallbacks(...[options]: Parameters<T.Effects["clearCallbacks"]>) {
return rpcRound("clear-callbacks", {
...options,
}) as ReturnType<T.Effects["clearCallbacks"]>
},
action: {
clear(...[options]: Parameters<T.Effects["action"]["clear"]>) {
return rpcRound("action.clear", {
...options,
}) as ReturnType<T.Effects["action"]["clear"]>
},
export(...[options]: Parameters<T.Effects["action"]["export"]>) {
return rpcRound("action.export", {
...options,
}) as ReturnType<T.Effects["action"]["export"]>
},
getInput(...[options]: Parameters<T.Effects["action"]["getInput"]>) {
return rpcRound("action.get-input", {
...options,
}) as ReturnType<T.Effects["action"]["getInput"]>
},
request(...[options]: Parameters<T.Effects["action"]["request"]>) {
return rpcRound("action.request", {
...options,
}) as ReturnType<T.Effects["action"]["request"]>
},
run(...[options]: Parameters<T.Effects["action"]["run"]>) {
return rpcRound("action.run", {
...options,
}) as ReturnType<T.Effects["action"]["run"]>
},
clearRequests(
...[options]: Parameters<T.Effects["action"]["clearRequests"]>
) {
return rpcRound("action.clear-requests", {
...options,
}) as ReturnType<T.Effects["action"]["clearRequests"]>
},
},
bind(...[options]: Parameters<T.Effects["bind"]>) {
return rpcRound("bind", {
...options,
stack: new Error().stack,
}) as ReturnType<T.Effects["bind"]>
},
clearBindings(...[]: Parameters<T.Effects["clearBindings"]>) {
return rpcRound("clear-bindings", {}) as ReturnType<
clearBindings(...[options]: Parameters<T.Effects["clearBindings"]>) {
return rpcRound("clear-bindings", { ...options }) as ReturnType<
T.Effects["clearBindings"]
>
},
clearServiceInterfaces(
...[]: Parameters<T.Effects["clearServiceInterfaces"]>
...[options]: Parameters<T.Effects["clearServiceInterfaces"]>
) {
return rpcRound("clear-service-interfaces", {}) as ReturnType<
return rpcRound("clear-service-interfaces", { ...options }) as ReturnType<
T.Effects["clearServiceInterfaces"]
>
},
@@ -127,27 +168,17 @@ function makeEffects(context: EffectContext): Effects {
>
},
subcontainer: {
createFs(options: { imageId: string }) {
createFs(options: { imageId: string; name: string }) {
return rpcRound("subcontainer.create-fs", options) as ReturnType<
T.Effects["subcontainer"]["createFs"]
>
},
destroyFs(options: { guid: string }): Promise<void> {
destroyFs(options: { guid: string }): Promise<null> {
return rpcRound("subcontainer.destroy-fs", options) as ReturnType<
T.Effects["subcontainer"]["destroyFs"]
>
},
},
executeAction(...[options]: Parameters<T.Effects["executeAction"]>) {
return rpcRound("execute-action", options) as ReturnType<
T.Effects["executeAction"]
>
},
exportAction(...[options]: Parameters<T.Effects["exportAction"]>) {
return rpcRound("export-action", options) as ReturnType<
T.Effects["exportAction"]
>
},
exportServiceInterface: ((
...[options]: Parameters<Effects["exportServiceInterface"]>
) => {
@@ -162,11 +193,6 @@ function makeEffects(context: EffectContext): Effects {
T.Effects["exposeForDependents"]
>
},
getConfigured(...[]: Parameters<T.Effects["getConfigured"]>) {
return rpcRound("get-configured", {}) as ReturnType<
T.Effects["getConfigured"]
>
},
getContainerIp(...[]: Parameters<T.Effects["getContainerIp"]>) {
return rpcRound("get-container-ip", {}) as ReturnType<
T.Effects["getContainerIp"]
@@ -230,19 +256,9 @@ function makeEffects(context: EffectContext): Effects {
mount(...[options]: Parameters<T.Effects["mount"]>) {
return rpcRound("mount", options) as ReturnType<T.Effects["mount"]>
},
clearActions(...[]: Parameters<T.Effects["clearActions"]>) {
return rpcRound("clear-actions", {}) as ReturnType<
T.Effects["clearActions"]
>
},
restart(...[]: Parameters<T.Effects["restart"]>) {
return rpcRound("restart", {}) as ReturnType<T.Effects["restart"]>
},
setConfigured(...[configured]: Parameters<T.Effects["setConfigured"]>) {
return rpcRound("set-configured", { configured }) as ReturnType<
T.Effects["setConfigured"]
>
},
setDependencies(
dependencies: Parameters<T.Effects["setDependencies"]>[0],
): ReturnType<T.Effects["setDependencies"]> {
@@ -268,7 +284,10 @@ function makeEffects(context: EffectContext): Effects {
>
},
setMainStatus(o: { status: "running" | "stopped" }): Promise<void> {
getStatus(...[o]: Parameters<T.Effects["getStatus"]>) {
return rpcRound("get-status", o) as ReturnType<T.Effects["getStatus"]>
},
setMainStatus(o: { status: "running" | "stopped" }): Promise<null> {
return rpcRound("set-main-status", o) as ReturnType<
T.Effects["setHealth"]
>
@@ -299,18 +318,3 @@ function makeEffects(context: EffectContext): Effects {
}
return self
}
export function makeProcedureEffects(procedureId: string): Effects {
return makeEffects({ procedureId, callbacks: null })
}
export function makeMainEffects(): MainEffects {
const rpcRound = rpcRoundFor(null)
return {
_type: "main",
clearCallbacks: () => {
return rpcRound("clearCallbacks", {}) as Promise<void>
},
...makeEffects({ procedureId: null, callbacks: new CallbackHolder() }),
}
}

View File

@@ -14,17 +14,14 @@ import {
anyOf,
} from "ts-matches"
import { types as T } from "@start9labs/start-sdk"
import { types as T, utils } from "@start9labs/start-sdk"
import * as fs from "fs"
import { CallbackHolder } from "../Models/CallbackHolder"
import { AllGetDependencies } from "../Interfaces/AllGetDependencies"
import { jsonPath, unNestPath } from "../Models/JsonPath"
import { RunningMain, System } from "../Interfaces/System"
import {
MakeMainEffects,
MakeProcedureEffects,
} from "../Interfaces/MakeEffects"
import { System } from "../Interfaces/System"
import { makeEffects } from "./EffectCreator"
type MaybePromise<T> = T | Promise<T>
export const matchRpcResult = anyOf(
object({ result: any }),
@@ -45,6 +42,7 @@ export const matchRpcResult = anyOf(
),
}),
)
export type RpcResult = typeof matchRpcResult._TYPE
type SocketResponse = ({ jsonrpc: "2.0"; id: IdType } & RpcResult) | null
@@ -55,73 +53,96 @@ const jsonrpc = "2.0" as const
const isResult = object({ result: any }).test
const idType = some(string, number, literal(null))
type IdType = null | string | number
const runType = object({
id: idType,
method: literal("execute"),
params: object(
{
id: string,
procedure: string,
input: any,
timeout: number,
},
["timeout"],
),
})
const sandboxRunType = object({
id: idType,
method: literal("sandbox"),
params: object(
{
id: string,
procedure: string,
input: any,
timeout: number,
},
["timeout"],
),
})
type IdType = null | string | number | undefined
const runType = object(
{
id: idType,
method: literal("execute"),
params: object(
{
id: string,
procedure: string,
input: any,
timeout: number,
},
["timeout"],
),
},
["id"],
)
const sandboxRunType = object(
{
id: idType,
method: literal("sandbox"),
params: object(
{
id: string,
procedure: string,
input: any,
timeout: number,
},
["timeout"],
),
},
["id"],
)
const callbackType = object({
method: literal("callback"),
params: object({
callback: number,
id: number,
args: array,
}),
})
const initType = object({
id: idType,
method: literal("init"),
})
const startType = object({
id: idType,
method: literal("start"),
})
const stopType = object({
id: idType,
method: literal("stop"),
})
const exitType = object({
id: idType,
method: literal("exit"),
})
const evalType = object({
id: idType,
method: literal("eval"),
params: object({
script: string,
}),
})
const initType = object(
{
id: idType,
method: literal("init"),
},
["id"],
)
const startType = object(
{
id: idType,
method: literal("start"),
},
["id"],
)
const stopType = object(
{
id: idType,
method: literal("stop"),
},
["id"],
)
const exitType = object(
{
id: idType,
method: literal("exit"),
},
["id"],
)
const evalType = object(
{
id: idType,
method: literal("eval"),
params: object({
script: string,
}),
},
["id"],
)
const jsonParse = (x: string) => JSON.parse(x)
const handleRpc = (id: IdType, result: Promise<RpcResult>) =>
result
.then((result) => ({
jsonrpc,
id,
...result,
}))
.then((result) => {
return {
jsonrpc,
id,
...result,
}
})
.then((x) => {
if (
("result" in x && x.result === undefined) ||
@@ -144,8 +165,7 @@ const hasId = object({ id: idType }).test
export class RpcListener {
unixSocketServer = net.createServer(async (server) => {})
private _system: System | undefined
private _makeProcedureEffects: MakeProcedureEffects | undefined
private _makeMainEffects: MakeMainEffects | undefined
private callbacks: CallbackHolder | undefined
constructor(readonly getDependencies: AllGetDependencies) {
if (!fs.existsSync(SOCKET_PARENT)) {
@@ -198,7 +218,11 @@ export class RpcListener {
.then((x) => this.dealWithInput(x))
.catch(mapError)
.then(logData("response"))
.then(writeDataToSocket),
.then(writeDataToSocket)
.catch((e) => {
console.error(`Major error in socket handling: ${e}`)
console.debug(`Data in: ${a.toString()}`)
}),
)
})
}
@@ -208,18 +232,33 @@ export class RpcListener {
return this._system
}
private get makeProcedureEffects() {
if (!this._makeProcedureEffects) {
this._makeProcedureEffects = this.getDependencies.makeProcedureEffects()
private callbackHolders: Map<string, CallbackHolder> = new Map()
private removeCallbackHolderFor(procedure: string) {
const prev = this.callbackHolders.get(procedure)
if (prev) {
this.callbackHolders.delete(procedure)
this.callbacks?.removeChild(prev)
}
return this._makeProcedureEffects
}
private callbackHolderFor(procedure: string): CallbackHolder {
this.removeCallbackHolderFor(procedure)
const callbackHolder = this.callbacks!.child()
this.callbackHolders.set(procedure, callbackHolder)
return callbackHolder
}
private get makeMainEffects() {
if (!this._makeMainEffects) {
this._makeMainEffects = this.getDependencies.makeMainEffects()
callCallback(callback: number, args: any[]): void {
if (this.callbacks) {
this.callbacks
.callCallback(callback, args)
.catch((error) =>
console.error(`callback ${callback} failed`, utils.asError(error)),
)
} else {
console.warn(
`callback ${callback} ignored because system is not initialized`,
)
}
return this._makeMainEffects
}
private dealWithInput(input: unknown): MaybePromise<SocketResponse> {
@@ -227,40 +266,49 @@ export class RpcListener {
.when(runType, async ({ id, params }) => {
const system = this.system
const procedure = jsonPath.unsafeCast(params.procedure)
const effects = this.getDependencies.makeProcedureEffects()(params.id)
const input = params.input
const timeout = params.timeout
const result = getResult(procedure, system, effects, timeout, input)
const { input, timeout, id: procedureId } = params
const result = this.getResult(
procedure,
system,
procedureId,
timeout,
input,
)
return handleRpc(id, result)
})
.when(sandboxRunType, async ({ id, params }) => {
const system = this.system
const procedure = jsonPath.unsafeCast(params.procedure)
const effects = this.makeProcedureEffects(params.id)
const result = getResult(
const { input, timeout, id: procedureId } = params
const result = this.getResult(
procedure,
system,
effects,
params.input,
params.input,
procedureId,
timeout,
input,
)
return handleRpc(id, result)
})
.when(callbackType, async ({ params: { callback, args } }) => {
this.system.callCallback(callback, args)
.when(callbackType, async ({ params: { id, args } }) => {
this.callCallback(id, args)
return null
})
.when(startType, async ({ id }) => {
const callbacks = this.callbackHolderFor("main")
const effects = makeEffects({
procedureId: null,
callbacks,
constRetry: () => {},
})
return handleRpc(
id,
this.system
.start(this.makeMainEffects())
.then((result) => ({ result })),
this.system.start(effects).then((result) => ({ result })),
)
})
.when(stopType, async ({ id }) => {
this.removeCallbackHolderFor("main")
return handleRpc(
id,
this.system.stop().then((result) => ({ result })),
@@ -280,7 +328,20 @@ export class RpcListener {
(async () => {
if (!this._system) {
const system = await this.getDependencies.system()
await system.containerInit()
this.callbacks = new CallbackHolder(
makeEffects({
procedureId: null,
constRetry: () => {},
}),
)
const callbacks = this.callbackHolderFor("containerInit")
await system.containerInit(
makeEffects({
procedureId: null,
callbacks,
constRetry: () => {},
}),
)
this._system = system
}
})().then((result) => ({ result })),
@@ -312,17 +373,20 @@ export class RpcListener {
})(),
)
})
.when(shape({ id: idType, method: string }), ({ id, method }) => ({
jsonrpc,
id,
error: {
code: -32601,
message: `Method not found`,
data: {
details: method,
.when(
shape({ id: idType, method: string }, ["id"]),
({ id, method }) => ({
jsonrpc,
id,
error: {
code: -32601,
message: `Method not found`,
data: {
details: method,
},
},
},
}))
}),
)
.defaultToLazy(() => {
console.warn(
@@ -341,98 +405,81 @@ export class RpcListener {
}
})
}
}
function getResult(
procedure: typeof jsonPath._TYPE,
system: System,
effects: T.Effects,
timeout: number | undefined,
input: any,
) {
const ensureResultTypeShape = (
result:
| void
| T.ConfigRes
| T.PropertiesReturn
| T.ActionMetadata[]
| T.ActionResult,
): { result: any } => {
if (isResult(result)) return result
return { result }
}
return (async () => {
switch (procedure) {
case "/backup/create":
return system.createBackup(effects, timeout || null)
case "/backup/restore":
return system.restoreBackup(effects, timeout || null)
case "/config/get":
return system.getConfig(effects, timeout || null)
case "/config/set":
return system.setConfig(effects, input, timeout || null)
case "/properties":
return system.properties(effects, timeout || null)
case "/actions/metadata":
return system.actionsMetadata(effects)
case "/init":
return system.packageInit(
effects,
string.optional().unsafeCast(input),
timeout || null,
)
case "/uninit":
return system.packageUninit(
effects,
string.optional().unsafeCast(input),
timeout || null,
)
default:
const procedures = unNestPath(procedure)
switch (true) {
case procedures[1] === "actions" && procedures[3] === "get":
return system.action(effects, procedures[2], input, timeout || null)
case procedures[1] === "actions" && procedures[3] === "run":
return system.action(effects, procedures[2], input, timeout || null)
case procedures[1] === "dependencies" && procedures[3] === "query":
return system.dependenciesAutoconfig(
effects,
procedures[2],
input,
timeout || null,
)
case procedures[1] === "dependencies" && procedures[3] === "update":
return system.dependenciesAutoconfig(
effects,
procedures[2],
input,
timeout || null,
)
}
private getResult(
procedure: typeof jsonPath._TYPE,
system: System,
procedureId: string,
timeout: number | undefined,
input: any,
) {
const ensureResultTypeShape = (
result: void | T.ActionInput | T.ActionResult | null,
): { result: any } => {
return { result }
}
})().then(ensureResultTypeShape, (error) =>
matches(error)
.when(
object(
{
error: string,
code: number,
},
["code"],
{ code: 0 },
),
(error) => ({
const callbacks = this.callbackHolderFor(procedure)
const effects = makeEffects({
procedureId,
callbacks,
constRetry: () => {},
})
return (async () => {
switch (procedure) {
case "/backup/create":
return system.createBackup(effects, timeout || null)
case "/backup/restore":
return system.restoreBackup(effects, timeout || null)
case "/packageInit":
return system.packageInit(effects, timeout || null)
case "/packageUninit":
return system.packageUninit(
effects,
string.optional().unsafeCast(input),
timeout || null,
)
default:
const procedures = unNestPath(procedure)
switch (true) {
case procedures[1] === "actions" && procedures[3] === "getInput":
return system.getActionInput(
effects,
procedures[2],
timeout || null,
)
case procedures[1] === "actions" && procedures[3] === "run":
return system.runAction(
effects,
procedures[2],
input.input,
timeout || null,
)
}
}
})().then(ensureResultTypeShape, (error) =>
matches(error)
.when(
object(
{
error: string,
code: number,
},
["code"],
{ code: 0 },
),
(error) => ({
error: {
code: error.code,
message: error.error,
},
}),
)
.defaultToLazy(() => ({
error: {
code: error.code,
message: error.error,
code: 0,
message: String(error),
},
}),
)
.defaultToLazy(() => ({
error: {
code: 0,
message: String(error),
},
})),
)
})),
)
}
}

View File

@@ -8,7 +8,7 @@ import {
CommandOptions,
ExecOptions,
ExecSpawnable,
} from "@start9labs/start-sdk/cjs/lib/util/SubContainer"
} from "@start9labs/start-sdk/package/lib/util/SubContainer"
export const exec = promisify(cp.exec)
export const execFile = promisify(cp.execFile)
@@ -20,6 +20,7 @@ export class DockerProcedureContainer {
packageId: string,
data: DockerProcedure,
volumes: { [id: VolumeId]: Volume },
name: string,
options: { subcontainer?: ExecSpawnable } = {},
) {
const subcontainer =
@@ -29,6 +30,7 @@ export class DockerProcedureContainer {
packageId,
data,
volumes,
name,
))
return new DockerProcedureContainer(subcontainer)
}
@@ -37,8 +39,13 @@ export class DockerProcedureContainer {
packageId: string,
data: DockerProcedure,
volumes: { [id: VolumeId]: Volume },
name: string,
) {
const subcontainer = await SubContainer.of(effects, { id: data.image })
const subcontainer = await SubContainer.of(
effects,
{ id: data.image },
name,
)
if (data.mounts) {
const mounts = data.mounts
@@ -144,7 +151,7 @@ export class DockerProcedureContainer {
}
}
async spawn(commands: string[]): Promise<cp.ChildProcessWithoutNullStreams> {
async spawn(commands: string[]): Promise<cp.ChildProcess> {
return await this.subcontainer.spawn(commands)
}
}

View File

@@ -2,11 +2,10 @@ import { polyfillEffects } from "./polyfillEffects"
import { DockerProcedureContainer } from "./DockerProcedureContainer"
import { SystemForEmbassy } from "."
import { T, utils } from "@start9labs/start-sdk"
import { Daemon } from "@start9labs/start-sdk/cjs/lib/mainFn/Daemon"
import { Daemon } from "@start9labs/start-sdk/package/lib/mainFn/Daemon"
import { Effects } from "../../../Models/Effects"
import { off } from "node:process"
import { CommandController } from "@start9labs/start-sdk/cjs/lib/mainFn/CommandController"
import { asError } from "@start9labs/start-sdk/cjs/lib/util"
import { CommandController } from "@start9labs/start-sdk/package/lib/mainFn/CommandController"
const EMBASSY_HEALTH_INTERVAL = 15 * 1000
const EMBASSY_PROPERTIES_LOOP = 30 * 1000
@@ -62,6 +61,7 @@ export class MainLoop {
this.system.manifest.id,
this.system.manifest.main,
this.system.manifest.volumes,
`Main - ${currentCommand.join(" ")}`,
)
return CommandController.of()(
this.effects,
@@ -136,7 +136,7 @@ export class MainLoop {
delete this.healthLoops
await main?.daemon
.stop()
.catch((e) => console.error(`Main loop error`, utils.asError(e)))
.catch((e: unknown) => console.error(`Main loop error`, utils.asError(e)))
this.effects.setMainStatus({ status: "stopped" })
if (healthLoops) healthLoops.forEach((x) => clearInterval(x.interval))
}
@@ -154,7 +154,7 @@ export class MainLoop {
result: "starting",
message: null,
})
.catch((e) => console.error(asError(e)))
.catch((e) => console.error(utils.asError(e)))
const interval = setInterval(async () => {
const actionProcedure = value
const timeChanged = Date.now() - start
@@ -162,21 +162,30 @@ export class MainLoop {
const subcontainer = actionProcedure.inject
? this.mainSubContainerHandle
: undefined
// prettier-ignore
const container =
await DockerProcedureContainer.of(
effects,
manifest.id,
actionProcedure,
manifest.volumes,
{
subcontainer,
}
)
const executed = await container.exec(
[actionProcedure.entrypoint, ...actionProcedure.args],
{ input: JSON.stringify(timeChanged) },
const commands = [
actionProcedure.entrypoint,
...actionProcedure.args,
]
const container = await DockerProcedureContainer.of(
effects,
manifest.id,
actionProcedure,
manifest.volumes,
`Health Check - ${commands.join(" ")}`,
{
subcontainer,
},
)
const env: Record<string, string> = actionProcedure.inject
? {
HOME: "/root",
}
: {}
const executed = await container.exec(commands, {
input: JSON.stringify(timeChanged),
env,
})
if (executed.exitCode === 0) {
await effects.setHealth({
id: healthId,
@@ -227,6 +236,18 @@ export class MainLoop {
})
return
}
if (executed.exitCode && executed.exitCode > 0) {
await effects.setHealth({
id: healthId,
name: value.name,
result: "failure",
message:
executed.stderr.toString() ||
executed.stdout.toString() ||
`Program exited with code ${executed.exitCode}:`,
})
return
}
await effects.setHealth({
id: healthId,
name: value.name,

View File

@@ -264,7 +264,6 @@ exports[`transformConfigSpec transformConfigSpec(bitcoind) 1`] = `
"disabled": false,
"immutable": false,
"name": "Pruning Mode",
"required": true,
"type": "union",
"variants": {
"automatic": {
@@ -524,7 +523,6 @@ exports[`transformConfigSpec transformConfigSpec(embassyPages) 1`] = `
"disabled": false,
"immutable": false,
"name": "Type",
"required": true,
"type": "union",
"variants": {
"index": {
@@ -589,7 +587,6 @@ exports[`transformConfigSpec transformConfigSpec(embassyPages) 1`] = `
"disabled": false,
"immutable": false,
"name": "Folder Location",
"required": false,
"type": "select",
"values": {
"filebrowser": "filebrowser",
@@ -644,7 +641,6 @@ exports[`transformConfigSpec transformConfigSpec(embassyPages) 1`] = `
"disabled": false,
"immutable": false,
"name": "Type",
"required": true,
"type": "union",
"variants": {
"redirect": {
@@ -705,7 +701,6 @@ exports[`transformConfigSpec transformConfigSpec(embassyPages) 1`] = `
"disabled": false,
"immutable": false,
"name": "Folder Location",
"required": false,
"type": "select",
"values": {
"filebrowser": "filebrowser",
@@ -758,7 +753,6 @@ exports[`transformConfigSpec transformConfigSpec(nostr2) 1`] = `
"disabled": false,
"immutable": false,
"name": "Relay Type",
"required": true,
"type": "union",
"variants": {
"private": {

View File

@@ -2,8 +2,8 @@ import { ExtendedVersion, types as T, utils } from "@start9labs/start-sdk"
import * as fs from "fs/promises"
import { polyfillEffects } from "./polyfillEffects"
import { Duration, duration, fromDuration } from "../../../Models/Duration"
import { System, Procedure } from "../../../Interfaces/System"
import { fromDuration } from "../../../Models/Duration"
import { System } from "../../../Interfaces/System"
import { matchManifest, Manifest } from "./matchManifest"
import * as childProcess from "node:child_process"
import { DockerProcedureContainer } from "./DockerProcedureContainer"
@@ -27,19 +27,12 @@ import {
Parser,
array,
} from "ts-matches"
import { JsonPath, unNestPath } from "../../../Models/JsonPath"
import { RpcResult, matchRpcResult } from "../../RpcListener"
import { CT } from "@start9labs/start-sdk"
import {
AddSslOptions,
BindOptions,
} from "@start9labs/start-sdk/cjs/lib/osBindings"
import { AddSslOptions } from "@start9labs/start-sdk/base/lib/osBindings"
import {
BindOptionsByProtocol,
Host,
MultiHost,
} from "@start9labs/start-sdk/cjs/lib/interfaces/Host"
import { ServiceInterfaceBuilder } from "@start9labs/start-sdk/cjs/lib/interfaces/ServiceInterfaceBuilder"
} from "@start9labs/start-sdk/base/lib/interfaces/Host"
import { ServiceInterfaceBuilder } from "@start9labs/start-sdk/base/lib/interfaces/ServiceInterfaceBuilder"
import { Effects } from "../../../Models/Effects"
import {
OldConfigSpec,
@@ -48,18 +41,16 @@ import {
transformNewConfigToOld,
transformOldConfigToNew,
} from "./transformConfigSpec"
import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk"
import { StorePath } from "@start9labs/start-sdk/cjs/lib/store/PathBuilder"
import { partialDiff } from "@start9labs/start-sdk/base/lib/util"
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"
export const EMBASSY_JS_LOCATION = "/usr/lib/startos/package/embassy.js"
const EMBASSY_POINTER_PATH_PREFIX = "/embassyConfig" as StorePath
const EMBASSY_POINTER_PATH_PREFIX = "/embassyConfig" as utils.StorePath
const matchResult = object({
result: any,
@@ -144,6 +135,34 @@ type OldGetConfigRes = {
spec: OldConfigSpec
}
export type PropertiesValue =
| {
/** The type of this value, either "string" or "object" */
type: "object"
/** A nested mapping of values. The user will experience this as a nested page with back button */
value: { [k: string]: PropertiesValue }
/** (optional) A human readable description of the new set of values */
description: string | null
}
| {
/** The type of this value, either "string" or "object" */
type: "string"
/** The value to display to the user */
value: string
/** A human readable description of the value */
description: string | null
/** Whether or not to mask the value, for example, when displaying a password */
masked: boolean | null
/** Whether or not to include a button for copying the value to clipboard */
copyable: boolean | null
/** Whether or not to include a button for displaying the value as a QR code */
qr: boolean | null
}
export type PropertiesReturn = {
[key: string]: PropertiesValue
}
export type PackagePropertiesV2 = {
[name: string]: PackagePropertyObject | PackagePropertyString
}
@@ -166,7 +185,7 @@ export type PackagePropertyObject = {
const asProperty_ = (
x: PackagePropertyString | PackagePropertyObject,
): T.PropertiesValue => {
): PropertiesValue => {
if (x.type === "object") {
return {
...x,
@@ -186,7 +205,7 @@ const asProperty_ = (
...x,
}
}
const asProperty = (x: PackagePropertiesV2): T.PropertiesReturn =>
const asProperty = (x: PackagePropertiesV2): PropertiesReturn =>
Object.fromEntries(
Object.entries(x).map(([key, value]) => [key, asProperty_(value)]),
)
@@ -223,6 +242,31 @@ const matchProperties = object({
data: matchPackageProperties,
})
function convertProperties(
name: string,
value: PropertiesValue,
): T.ActionResultMember {
if (value.type === "string") {
return {
type: "single",
name,
description: value.description,
copyable: value.copyable || false,
masked: value.masked || false,
qr: value.qr || false,
value: value.value,
}
}
return {
type: "group",
name,
description: value.description,
value: Object.entries(value.value).map(([name, value]) =>
convertProperties(name, value),
),
}
}
const DEFAULT_REGISTRY = "https://registry.start9.com"
export class SystemForEmbassy implements System {
currentRunning: MainLoop | undefined
@@ -248,50 +292,38 @@ export class SystemForEmbassy implements System {
readonly moduleCode: Partial<U.ExpectedExports>,
) {}
async actionsMetadata(effects: T.Effects): Promise<T.ActionMetadata[]> {
const actions = Object.entries(this.manifest.actions ?? {})
return Promise.all(
actions.map(async ([actionId, action]): Promise<T.ActionMetadata> => {
const name = action.name ?? actionId
const description = action.description
const warning = action.warning ?? null
const disabled = false
const input = (await convertToNewConfig(action["input-spec"] as any))
.spec
const hasRunning = !!action["allowed-statuses"].find(
(x) => x === "running",
)
const hasStopped = !!action["allowed-statuses"].find(
(x) => x === "stopped",
)
// prettier-ignore
const allowedStatuses =
hasRunning && hasStopped ? "any":
hasRunning ? "onlyRunning" :
"onlyStopped"
const group = null
return {
name,
description,
warning,
disabled,
allowedStatuses,
group,
input,
}
}),
)
async containerInit(effects: Effects): Promise<void> {
for (let depId in this.manifest.dependencies) {
if (this.manifest.dependencies[depId].config) {
await this.dependenciesAutoconfig(effects, depId, null)
}
}
await effects.setMainStatus({ status: "stopped" })
await this.exportActions(effects)
await this.exportNetwork(effects)
await this.containerSetDependencies(effects)
}
async containerSetDependencies(effects: T.Effects) {
const oldDeps: Record<string, string[]> = Object.fromEntries(
await effects
.getDependencies()
.then((x) =>
x.flatMap((x) =>
x.kind === "running" ? [[x.id, x?.healthChecks || []]] : [],
),
)
.catch(() => []),
)
await this.setDependencies(effects, oldDeps)
}
async containerInit(): Promise<void> {}
async exit(): Promise<void> {
if (this.currentRunning) await this.currentRunning.clean()
delete this.currentRunning
}
async start(effects: MainEffects): Promise<void> {
async start(effects: T.Effects): Promise<void> {
effects.constRetry = utils.once(() => effects.restart())
if (!!this.currentRunning) return
this.currentRunning = await MainLoop.of(this, effects)
@@ -308,16 +340,26 @@ export class SystemForEmbassy implements System {
}
}
async packageInit(
effects: Effects,
previousVersion: Optional<string>,
timeoutMs: number | null,
): Promise<void> {
if (previousVersion)
await this.migration(effects, previousVersion, timeoutMs)
await effects.setMainStatus({ status: "stopped" })
await this.exportActions(effects)
await this.exportNetwork(effects)
async packageInit(effects: Effects, timeoutMs: number | null): Promise<void> {
const previousVersion = await effects.getDataVersion()
if (previousVersion) {
if (
(await this.migration(effects, previousVersion, timeoutMs)).configured
) {
await effects.action.clearRequests({ only: ["needs-config"] })
}
await effects.setDataVersion({
version: ExtendedVersion.parseEmver(this.manifest.version).toString(),
})
} else if (this.manifest.config) {
await effects.action.request({
packageId: this.manifest.id,
actionId: "config",
severity: "critical",
replayId: "needs-config",
reason: "This service must be configured before it can be run",
})
}
}
async exportNetwork(effects: Effects) {
for (const [id, interfaceValue] of Object.entries(
@@ -400,10 +442,75 @@ export class SystemForEmbassy implements System {
)
}
}
async getActionInput(
effects: Effects,
actionId: string,
timeoutMs: number | null,
): Promise<T.ActionInput | null> {
if (actionId === "config") {
const config = await this.getConfig(effects, timeoutMs)
return { spec: config.spec, value: config.config }
} else if (actionId === "properties") {
return null
} else {
const oldSpec = this.manifest.actions?.[actionId]?.["input-spec"]
if (!oldSpec) return null
return {
spec: transformConfigSpec(oldSpec as OldConfigSpec),
value: null,
}
}
}
async runAction(
effects: Effects,
actionId: string,
input: unknown,
timeoutMs: number | null,
): Promise<T.ActionResult | null> {
if (actionId === "config") {
await this.setConfig(effects, input, timeoutMs)
return null
} else if (actionId === "properties") {
return {
version: "1",
title: "Properties",
message: null,
result: {
type: "group",
value: Object.entries(await this.properties(effects, timeoutMs)).map(
([name, value]) => convertProperties(name, value),
),
},
}
} else {
return this.action(effects, actionId, input, timeoutMs)
}
}
async exportActions(effects: Effects) {
const manifest = this.manifest
if (!manifest.actions) return
for (const [actionId, action] of Object.entries(manifest.actions)) {
const actions = {
...manifest.actions,
}
if (manifest.config) {
actions.config = {
name: "Configure",
description: `Customize ${manifest.title}`,
"allowed-statuses": ["running", "stopped"],
"input-spec": {},
implementation: { type: "script", args: [] },
}
}
if (manifest.properties) {
actions.properties = {
name: "Properties",
description:
"Runtime information, credentials, and other values of interest",
"allowed-statuses": ["running", "stopped"],
"input-spec": null,
implementation: { type: "script", args: [] },
}
}
for (const [actionId, action] of Object.entries(actions)) {
const hasRunning = !!action["allowed-statuses"].find(
(x) => x === "running",
)
@@ -412,21 +519,22 @@ export class SystemForEmbassy implements System {
)
// prettier-ignore
const allowedStatuses = hasRunning && hasStopped ? "any":
hasRunning ? "onlyRunning" :
"onlyStopped"
await effects.exportAction({
hasRunning ? "only-running" :
"only-stopped"
await effects.action.export({
id: actionId,
metadata: {
name: action.name,
description: action.description,
warning: action.warning || null,
input: action["input-spec"] as CT.InputSpec,
disabled: false,
visibility: "enabled",
allowedStatuses,
hasInput: !!action["input-spec"],
group: null,
},
})
}
await effects.action.clear({ except: Object.keys(actions) })
}
async packageUninit(
effects: Effects,
@@ -443,6 +551,7 @@ export class SystemForEmbassy implements System {
): Promise<void> {
const backup = this.manifest.backup.create
if (backup.type === "docker") {
const commands = [backup.entrypoint, ...backup.args]
const container = await DockerProcedureContainer.of(
effects,
this.manifest.id,
@@ -451,8 +560,9 @@ export class SystemForEmbassy implements System {
...this.manifest.volumes,
BACKUP: { type: "backup", readonly: false },
},
`Backup - ${commands.join(" ")}`,
)
await container.execFail([backup.entrypoint, ...backup.args], timeoutMs)
await container.execFail(commands, timeoutMs)
} else {
const moduleCode = await this.moduleCode
await moduleCode.createBackup?.(polyfillEffects(effects, this.manifest))
@@ -464,6 +574,7 @@ export class SystemForEmbassy implements System {
): Promise<void> {
const restoreBackup = this.manifest.backup.restore
if (restoreBackup.type === "docker") {
const commands = [restoreBackup.entrypoint, ...restoreBackup.args]
const container = await DockerProcedureContainer.of(
effects,
this.manifest.id,
@@ -472,20 +583,15 @@ export class SystemForEmbassy implements System {
...this.manifest.volumes,
BACKUP: { type: "backup", readonly: true },
},
`Restore Backup - ${commands.join(" ")}`,
)
await container.execFail(
[restoreBackup.entrypoint, ...restoreBackup.args],
timeoutMs,
)
await container.execFail(commands, timeoutMs)
} else {
const moduleCode = await this.moduleCode
await moduleCode.restoreBackup?.(polyfillEffects(effects, this.manifest))
}
}
async getConfig(
effects: Effects,
timeoutMs: number | null,
): Promise<T.ConfigRes> {
async getConfig(effects: Effects, timeoutMs: number | null) {
return this.getConfigUncleaned(effects, timeoutMs).then(convertToNewConfig)
}
private async getConfigUncleaned(
@@ -495,20 +601,17 @@ export class SystemForEmbassy implements System {
const config = this.manifest.config?.get
if (!config) return { spec: {} }
if (config.type === "docker") {
const commands = [config.entrypoint, ...config.args]
const container = await DockerProcedureContainer.of(
effects,
this.manifest.id,
config,
this.manifest.volumes,
`Get Config - ${commands.join(" ")}`,
)
// TODO: yaml
return JSON.parse(
(
await container.execFail(
[config.entrypoint, ...config.args],
timeoutMs,
)
).stdout.toString(),
(await container.execFail(commands, timeoutMs)).stdout.toString(),
)
} else {
const moduleCode = await this.moduleCode
@@ -543,28 +646,25 @@ export class SystemForEmbassy implements System {
const setConfigValue = this.manifest.config?.set
if (!setConfigValue) return
if (setConfigValue.type === "docker") {
const commands = [
setConfigValue.entrypoint,
...setConfigValue.args,
JSON.stringify(newConfig),
]
const container = await DockerProcedureContainer.of(
effects,
this.manifest.id,
setConfigValue,
this.manifest.volumes,
`Set Config - ${commands.join(" ")}`,
)
const answer = matchSetResult.unsafeCast(
JSON.parse(
(
await container.execFail(
[
setConfigValue.entrypoint,
...setConfigValue.args,
JSON.stringify(newConfig),
],
timeoutMs,
)
).stdout.toString(),
(await container.execFail(commands, timeoutMs)).stdout.toString(),
),
)
const dependsOn = answer["depends-on"] ?? answer.dependsOn ?? {}
await this.setConfigSetConfig(effects, dependsOn)
await this.setDependencies(effects, dependsOn)
return
} else if (setConfigValue.type === "script") {
const moduleCode = await this.moduleCode
@@ -587,31 +687,60 @@ export class SystemForEmbassy implements System {
}),
)
const dependsOn = answer["depends-on"] ?? answer.dependsOn ?? {}
await this.setConfigSetConfig(effects, dependsOn)
await this.setDependencies(effects, dependsOn)
return
}
}
private async setConfigSetConfig(
private async setDependencies(
effects: Effects,
dependsOn: { [x: string]: readonly string[] },
rawDepends: { [x: string]: readonly string[] },
) {
const dependsOn: Record<string, readonly string[] | null> = {
...Object.fromEntries(
Object.entries(this.manifest.dependencies || {})?.map((x) => [
x[0],
null,
]) || [],
),
...rawDepends,
}
await effects.setDependencies({
dependencies: Object.entries(dependsOn).flatMap(([key, value]) => {
const dependency = this.manifest.dependencies?.[key]
if (!dependency) return []
const versionRange = dependency.version
const registryUrl = DEFAULT_REGISTRY
const kind = "running"
return [
{
id: key,
versionRange,
registryUrl,
kind,
healthChecks: [...value],
},
]
}),
dependencies: Object.entries(dependsOn).flatMap(
([key, value]): T.Dependencies => {
const dependency = this.manifest.dependencies?.[key]
if (!dependency) return []
if (value == null) {
const versionRange = dependency.version
if (dependency.requirement.type === "required") {
return [
{
id: key,
versionRange,
kind: "running",
healthChecks: [],
},
]
}
return [
{
kind: "exists",
id: key,
versionRange,
},
]
}
const versionRange = dependency.version
const kind = "running"
return [
{
id: key,
versionRange,
kind,
healthChecks: [...value],
},
]
},
),
})
}
@@ -619,7 +748,7 @@ export class SystemForEmbassy implements System {
effects: Effects,
fromVersion: string,
timeoutMs: number | null,
): Promise<T.MigrationRes> {
): Promise<{ configured: boolean }> {
const fromEmver = ExtendedVersion.parseEmver(fromVersion)
const currentEmver = ExtendedVersion.parseEmver(this.manifest.version)
if (!this.manifest.migrations) return { configured: true }
@@ -652,23 +781,20 @@ export class SystemForEmbassy implements System {
if (migration) {
const [version, procedure] = migration
if (procedure.type === "docker") {
const commands = [
procedure.entrypoint,
...procedure.args,
JSON.stringify(fromVersion),
]
const container = await DockerProcedureContainer.of(
effects,
this.manifest.id,
procedure,
this.manifest.volumes,
`Migration - ${commands.join(" ")}`,
)
return JSON.parse(
(
await container.execFail(
[
procedure.entrypoint,
...procedure.args,
JSON.stringify(fromVersion),
],
timeoutMs,
)
).stdout.toString(),
(await container.execFail(commands, timeoutMs)).stdout.toString(),
)
} else if (procedure.type === "script") {
const moduleCode = await this.moduleCode
@@ -690,25 +816,22 @@ export class SystemForEmbassy implements System {
async properties(
effects: Effects,
timeoutMs: number | null,
): Promise<T.PropertiesReturn> {
): Promise<PropertiesReturn> {
// TODO BLU-J set the properties ever so often
const setConfigValue = this.manifest.properties
if (!setConfigValue) throw new Error("There is no properties")
if (setConfigValue.type === "docker") {
const commands = [setConfigValue.entrypoint, ...setConfigValue.args]
const container = await DockerProcedureContainer.of(
effects,
this.manifest.id,
setConfigValue,
this.manifest.volumes,
`Properties - ${commands.join(" ")}`,
)
const properties = matchProperties.unsafeCast(
JSON.parse(
(
await container.execFail(
[setConfigValue.entrypoint, ...setConfigValue.args],
timeoutMs,
)
).stdout.toString(),
(await container.execFail(commands, timeoutMs)).stdout.toString(),
),
)
return asProperty(properties.data)
@@ -735,13 +858,13 @@ export class SystemForEmbassy implements System {
const actionProcedure = this.manifest.actions?.[actionId]?.implementation
const toActionResult = ({
message,
value = "",
value,
copyable,
qr,
}: U.ActionResult): T.ActionResult => ({
version: "0",
message,
value,
value: value ?? null,
copyable,
qr,
})
@@ -750,11 +873,18 @@ export class SystemForEmbassy implements System {
const subcontainer = actionProcedure.inject
? this.currentRunning?.mainSubContainerHandle
: undefined
const env: Record<string, string> = actionProcedure.inject
? {
HOME: "/root",
}
: {}
const container = await DockerProcedureContainer.of(
effects,
this.manifest.id,
actionProcedure,
this.manifest.volumes,
`Action ${actionId}`,
{
subcontainer,
},
@@ -769,6 +899,7 @@ export class SystemForEmbassy implements System {
JSON.stringify(formData),
],
timeoutMs,
{ env },
)
).stdout.toString(),
),
@@ -794,23 +925,20 @@ export class SystemForEmbassy implements System {
const actionProcedure = this.manifest.dependencies?.[id]?.config?.check
if (!actionProcedure) return { message: "Action not found", value: null }
if (actionProcedure.type === "docker") {
const commands = [
actionProcedure.entrypoint,
...actionProcedure.args,
JSON.stringify(oldConfig),
]
const container = await DockerProcedureContainer.of(
effects,
this.manifest.id,
actionProcedure,
this.manifest.volumes,
`Dependencies Check - ${commands.join(" ")}`,
)
return JSON.parse(
(
await container.execFail(
[
actionProcedure.entrypoint,
...actionProcedure.args,
JSON.stringify(oldConfig),
],
timeoutMs,
)
).stdout.toString(),
(await container.execFail(commands, timeoutMs)).stdout.toString(),
)
} else if (actionProcedure.type === "script") {
const moduleCode = await this.moduleCode
@@ -834,24 +962,46 @@ export class SystemForEmbassy implements System {
async dependenciesAutoconfig(
effects: Effects,
id: string,
input: unknown,
timeoutMs: number | null,
): Promise<void> {
const oldConfig = object({ remoteConfig: any }).unsafeCast(
input,
).remoteConfig
// TODO: docker
const oldConfig = (await effects.store.get({
packageId: id,
path: EMBASSY_POINTER_PATH_PREFIX,
callback: () => {
this.dependenciesAutoconfig(effects, id, timeoutMs)
},
})) as U.Config
if (!oldConfig) return
const moduleCode = await this.moduleCode
const method = moduleCode.dependencies?.[id]?.autoConfigure
if (!method) return
return (await method(
const newConfig = (await method(
polyfillEffects(effects, this.manifest),
oldConfig,
JSON.parse(JSON.stringify(oldConfig)),
).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
const diff = partialDiff(oldConfig, newConfig)
if (diff) {
await effects.action.request({
actionId: "config",
packageId: id,
replayId: `${id}/config`,
severity: "important",
reason: `Configure this dependency for the needs of ${this.manifest.title}`,
input: {
kind: "partial",
value: diff.diff,
},
when: {
condition: "input-not-matches",
once: false,
},
})
}
}
}
@@ -1026,9 +1176,7 @@ function extractServiceInterfaceId(manifest: Manifest, specInterface: string) {
const serviceInterfaceId = `${specInterface}-${internalPort}`
return serviceInterfaceId
}
async function convertToNewConfig(
value: OldGetConfigRes,
): Promise<T.ConfigRes> {
async function convertToNewConfig(value: OldGetConfigRes) {
const valueSpec: OldConfigSpec = matchOldConfigSpec.unsafeCast(value.spec)
const spec = transformConfigSpec(valueSpec)
if (!value.config) return { spec, config: null }

View File

@@ -42,6 +42,7 @@ const matchAction = object(
export const matchManifest = object(
{
id: string,
title: string,
version: string,
main: matchDockerProcedure,
assets: object(

View File

@@ -105,12 +105,14 @@ export const polyfillEffects = (
args?: string[] | undefined
timeoutMillis?: number | undefined
}): Promise<oet.ResultType<string>> {
const commands: [string, ...string[]] = [command, ...(args || [])]
return startSdk
.runCommand(
effects,
{ id: manifest.main.image },
[command, ...(args || [])],
commands,
{},
commands.join(" "),
)
.then((x: any) => ({
stderr: x.stderr.toString(),
@@ -129,6 +131,7 @@ export const polyfillEffects = (
manifest.id,
manifest.main,
manifest.volumes,
[input.command, ...(input.args || [])].join(" "),
)
const daemon = promiseSubcontainer.then((subcontainer) =>
daemons.runCommand()(
@@ -153,11 +156,17 @@ export const polyfillEffects = (
path: string
uid: string
}): Promise<null> {
const commands: [string, ...string[]] = [
"chown",
"--recursive",
input.uid,
`/drive/${input.path}`,
]
await startSdk
.runCommand(
effects,
{ id: manifest.main.image },
["chown", "--recursive", input.uid, `/drive/${input.path}`],
commands,
{
mounts: [
{
@@ -171,6 +180,7 @@ export const polyfillEffects = (
},
],
},
commands.join(" "),
)
.then((x: any) => ({
stderr: x.stderr.toString(),
@@ -188,11 +198,17 @@ export const polyfillEffects = (
path: string
mode: string
}): Promise<null> {
const commands: [string, ...string[]] = [
"chmod",
"--recursive",
input.mode,
`/drive/${input.path}`,
]
await startSdk
.runCommand(
effects,
{ id: manifest.main.image },
["chmod", "--recursive", input.mode, `/drive/${input.path}`],
commands,
{
mounts: [
{
@@ -206,6 +222,7 @@ export const polyfillEffects = (
},
],
},
commands.join(" "),
)
.then((x: any) => ({
stderr: x.stderr.toString(),

View File

@@ -1,4 +1,4 @@
import { CT } from "@start9labs/start-sdk"
import { IST } from "@start9labs/start-sdk"
import {
dictionary,
object,
@@ -15,9 +15,9 @@ import {
literal,
} from "ts-matches"
export function transformConfigSpec(oldSpec: OldConfigSpec): CT.InputSpec {
export function transformConfigSpec(oldSpec: OldConfigSpec): IST.InputSpec {
return Object.entries(oldSpec).reduce((inputSpec, [key, oldVal]) => {
let newVal: CT.ValueSpec
let newVal: IST.ValueSpec
if (oldVal.type === "boolean") {
newVal = {
@@ -43,7 +43,6 @@ export function transformConfigSpec(oldSpec: OldConfigSpec): CT.InputSpec {
}),
{},
),
required: false,
disabled: false,
immutable: false,
}
@@ -124,10 +123,9 @@ export function transformConfigSpec(oldSpec: OldConfigSpec): CT.InputSpec {
spec: transformConfigSpec(matchOldConfigSpec.unsafeCast(spec)),
},
}),
{} as Record<string, { name: string; spec: CT.InputSpec }>,
{} as Record<string, { name: string; spec: IST.InputSpec }>,
),
disabled: false,
required: true,
default: oldVal.default,
immutable: false,
}
@@ -141,7 +139,7 @@ export function transformConfigSpec(oldSpec: OldConfigSpec): CT.InputSpec {
...inputSpec,
[key]: newVal,
}
}, {} as CT.InputSpec)
}, {} as IST.InputSpec)
}
export function transformOldConfigToNew(
@@ -233,10 +231,10 @@ export function transformNewConfigToOld(
function getListSpec(
oldVal: OldValueSpecList,
): CT.ValueSpecMultiselect | CT.ValueSpecList {
): IST.ValueSpecMultiselect | IST.ValueSpecList {
const range = Range.from(oldVal.range)
let partial: Omit<CT.ValueSpecList, "type" | "spec" | "default"> = {
let partial: Omit<IST.ValueSpecList, "type" | "spec" | "default"> = {
name: oldVal.name,
description: oldVal.description || null,
warning: oldVal.warning || null,

View File

@@ -1,21 +1,12 @@
import { ExecuteResult, Procedure, System } from "../../Interfaces/System"
import { unNestPath } from "../../Models/JsonPath"
import matches, { any, number, object, string, tuple } from "ts-matches"
import { System } from "../../Interfaces/System"
import { Effects } from "../../Models/Effects"
import { RpcResult, matchRpcResult } from "../RpcListener"
import { duration } from "../../Models/Duration"
import { T, utils } from "@start9labs/start-sdk"
import { Volume } from "../../Models/Volume"
import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk"
import { CallbackHolder } from "../../Models/CallbackHolder"
import { Optional } from "ts-matches/lib/parsers/interfaces"
export const STARTOS_JS_LOCATION = "/usr/lib/startos/package/index.js"
type RunningMain = {
effects: MainEffects
stop: () => Promise<void>
callbacks: CallbackHolder
}
export class SystemForStartOs implements System {
@@ -25,23 +16,24 @@ export class SystemForStartOs implements System {
return new SystemForStartOs(require(STARTOS_JS_LOCATION))
}
constructor(readonly abi: T.ABI) {}
containerInit(): Promise<void> {
throw new Error("Method not implemented.")
constructor(readonly abi: T.ABI) {
this
}
async containerInit(effects: Effects): Promise<void> {
return void (await this.abi.containerInit({ effects }))
}
async packageInit(
effects: Effects,
previousVersion: Optional<string> = null,
timeoutMs: number | null = null,
): Promise<void> {
return void (await this.abi.init({ effects }))
return void (await this.abi.packageInit({ effects }))
}
async packageUninit(
effects: Effects,
nextVersion: Optional<string> = null,
timeoutMs: number | null = null,
): Promise<void> {
return void (await this.abi.uninit({ effects, nextVersion }))
return void (await this.abi.packageUninit({ effects, nextVersion }))
}
async createBackup(
effects: T.Effects,
@@ -49,8 +41,6 @@ export class SystemForStartOs implements System {
): Promise<void> {
return void (await this.abi.createBackup({
effects,
pathMaker: ((options) =>
new Volume(options.volume, options.path).path) as T.PathMaker,
}))
}
async restoreBackup(
@@ -59,118 +49,56 @@ export class SystemForStartOs implements System {
): Promise<void> {
return void (await this.abi.restoreBackup({
effects,
pathMaker: ((options) =>
new Volume(options.volume, options.path).path) as T.PathMaker,
}))
}
getConfig(
effects: T.Effects,
timeoutMs: number | null,
): Promise<T.ConfigRes> {
return this.abi.getConfig({ effects })
}
async setConfig(
effects: Effects,
input: { effects: Effects; input: Record<string, unknown> },
timeoutMs: number | null,
): Promise<void> {
const _: unknown = await this.abi.setConfig({ effects, input })
return
}
migration(
effects: Effects,
fromVersion: string,
timeoutMs: number | null,
): Promise<T.MigrationRes> {
throw new Error("Method not implemented.")
}
properties(
effects: Effects,
timeoutMs: number | null,
): Promise<T.PropertiesReturn> {
throw new Error("Method not implemented.")
}
async action(
getActionInput(
effects: Effects,
id: string,
formData: unknown,
timeoutMs: number | null,
): Promise<T.ActionResult> {
const action = (await this.abi.actions({ effects }))[id]
): Promise<T.ActionInput | null> {
const action = this.abi.actions.get(id)
if (!action) throw new Error(`Action ${id} not found`)
return action.run({ effects })
return action.getInput({ effects })
}
dependenciesCheck(
runAction(
effects: Effects,
id: string,
oldConfig: unknown,
input: unknown,
timeoutMs: number | null,
): Promise<any> {
const dependencyConfig = this.abi.dependencyConfig[id]
if (!dependencyConfig) throw new Error(`dependencyConfig ${id} not found`)
return dependencyConfig.query({ effects })
): Promise<T.ActionResult | null> {
const action = this.abi.actions.get(id)
if (!action) throw new Error(`Action ${id} not found`)
return action.run({ effects, input })
}
async dependenciesAutoconfig(
effects: Effects,
id: string,
remoteConfig: unknown,
timeoutMs: number | null,
): Promise<void> {
const dependencyConfig = this.abi.dependencyConfig[id]
if (!dependencyConfig) throw new Error(`dependencyConfig ${id} not found`)
const queryResults = await this.getConfig(effects, timeoutMs)
return void (await dependencyConfig.update({
queryResults,
remoteConfig,
})) // TODO
}
async actionsMetadata(effects: T.Effects): Promise<T.ActionMetadata[]> {
return this.abi.actionsMetadata({ effects })
}
async init(): Promise<void> {}
async exit(): Promise<void> {}
async start(effects: MainEffects): Promise<void> {
async start(effects: Effects): Promise<void> {
effects.constRetry = utils.once(() => effects.restart())
if (this.runningMain) await this.stop()
let mainOnTerm: () => Promise<void> | undefined
const started = async (onTerm: () => Promise<void>) => {
await effects.setMainStatus({ status: "running" })
mainOnTerm = onTerm
return null
}
const daemons = await (
await this.abi.main({
effects: effects as MainEffects,
effects,
started,
})
).build()
this.runningMain = {
effects,
stop: async () => {
if (mainOnTerm) await mainOnTerm()
await daemons.term()
},
callbacks: new CallbackHolder(),
}
}
callCallback(callback: number, args: any[]): void {
if (this.runningMain) {
this.runningMain.callbacks
.callCallback(callback, args)
.catch((error) =>
console.error(`callback ${callback} failed`, utils.asError(error)),
)
} else {
console.warn(`callback ${callback} ignored because system is not running`)
}
}
async stop(): Promise<void> {
if (this.runningMain) {
await this.runningMain.stop()
await this.runningMain.effects.clearCallbacks()
this.runningMain = undefined
}
}

View File

@@ -1,7 +1,4 @@
import { GetDependency } from "./GetDependency"
import { System } from "./System"
import { MakeMainEffects, MakeProcedureEffects } from "./MakeEffects"
export type AllGetDependencies = GetDependency<"system", Promise<System>> &
GetDependency<"makeProcedureEffects", MakeProcedureEffects> &
GetDependency<"makeMainEffects", MakeMainEffects>
export type AllGetDependencies = GetDependency<"system", Promise<System>>

View File

@@ -1,4 +0,0 @@
import { Effects } from "../Models/Effects"
import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk"
export type MakeProcedureEffects = (procedureId: string) => Effects
export type MakeMainEffects = () => MainEffects

View File

@@ -1,39 +1,26 @@
import { types as T } from "@start9labs/start-sdk"
import { RpcResult } from "../Adapters/RpcListener"
import { Effects } from "../Models/Effects"
import { CallbackHolder } from "../Models/CallbackHolder"
import { MainEffects } from "@start9labs/start-sdk/cjs/lib/StartSdk"
import { Optional } from "ts-matches/lib/parsers/interfaces"
export type Procedure =
| "/init"
| "/uninit"
| "/config/set"
| "/config/get"
| "/packageInit"
| "/packageUninit"
| "/backup/create"
| "/backup/restore"
| "/actions/metadata"
| "/properties"
| `/actions/${string}/get`
| `/actions/${string}/getInput`
| `/actions/${string}/run`
| `/dependencies/${string}/query`
| `/dependencies/${string}/update`
export type ExecuteResult =
| { ok: unknown }
| { err: { code: number; message: string } }
export type System = {
containerInit(): Promise<void>
containerInit(effects: T.Effects): Promise<void>
start(effects: MainEffects): Promise<void>
callCallback(callback: number, args: any[]): void
start(effects: T.Effects): Promise<void>
stop(): Promise<void>
packageInit(
effects: Effects,
previousVersion: Optional<string>,
timeoutMs: number | null,
): Promise<void>
packageInit(effects: Effects, timeoutMs: number | null): Promise<void>
packageUninit(
effects: Effects,
nextVersion: Optional<string>,
@@ -42,41 +29,17 @@ export type System = {
createBackup(effects: T.Effects, timeoutMs: number | null): Promise<void>
restoreBackup(effects: T.Effects, timeoutMs: number | null): Promise<void>
getConfig(effects: T.Effects, timeoutMs: number | null): Promise<T.ConfigRes>
setConfig(
effects: Effects,
input: { effects: Effects; input: Record<string, unknown> },
timeoutMs: number | null,
): Promise<void>
migration(
effects: Effects,
fromVersion: string,
timeoutMs: number | null,
): Promise<T.MigrationRes>
properties(
effects: Effects,
timeoutMs: number | null,
): Promise<T.PropertiesReturn>
action(
runAction(
effects: Effects,
actionId: string,
formData: unknown,
input: unknown,
timeoutMs: number | null,
): Promise<T.ActionResult>
dependenciesCheck(
): Promise<T.ActionResult | null>
getActionInput(
effects: Effects,
id: string,
oldConfig: unknown,
actionId: string,
timeoutMs: number | null,
): Promise<any>
dependenciesAutoconfig(
effects: Effects,
id: string,
oldConfig: unknown,
timeoutMs: number | null,
): Promise<void>
actionsMetadata(effects: T.Effects): Promise<T.ActionMetadata[]>
): Promise<T.ActionInput | null>
exit(): Promise<void>
}

View File

@@ -1,22 +1,62 @@
import { T } from "@start9labs/start-sdk"
const CallbackIdCell = { inc: 1 }
const callbackRegistry = new FinalizationRegistry(
async (options: { cbs: Map<number, Function>; effects: T.Effects }) => {
await options.effects.clearCallbacks({
only: Array.from(options.cbs.keys()),
})
},
)
export class CallbackHolder {
constructor() {}
private inc = 0
constructor(private effects?: T.Effects) {}
private callbacks = new Map<number, Function>()
private children: WeakRef<CallbackHolder>[] = []
private newId() {
return this.inc++
return CallbackIdCell.inc++
}
addCallback(callback?: Function) {
if (!callback) {
return
}
const id = this.newId()
console.error("adding callback", id)
this.callbacks.set(id, callback)
if (this.effects)
callbackRegistry.register(this, {
cbs: this.callbacks,
effects: this.effects,
})
return id
}
child(): CallbackHolder {
const child = new CallbackHolder()
this.children.push(new WeakRef(child))
return child
}
removeChild(child: CallbackHolder) {
this.children = this.children.filter((c) => {
const ref = c.deref()
return ref && ref !== child
})
}
private getCallback(index: number): Function | undefined {
let callback = this.callbacks.get(index)
if (callback) this.callbacks.delete(index)
else {
for (let i = 0; i < this.children.length; i++) {
callback = this.children[i].deref()?.getCallback(index)
if (callback) return callback
}
}
return callback
}
callCallback(index: number, args: any[]): Promise<unknown> {
const callback = this.callbacks.get(index)
if (!callback) throw new Error(`Callback ${index} does not exist`)
this.callbacks.delete(index)
const callback = this.getCallback(index)
if (!callback) return Promise.resolve()
return Promise.resolve().then(() => callback(...args))
}
}

View File

@@ -1,9 +1,7 @@
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">
type NestedPaths = NestedPath<"actions", "run" | "getInput">
// prettier-ignore
type UnNestPaths<A> =
A extends `${infer A}/${infer B}` ? [...UnNestPaths<A>, ... UnNestPaths<B>] :
@@ -15,25 +13,16 @@ export function unNestPath<A extends string>(a: A): UnNestPaths<A> {
function isNestedPath(path: string): path is NestedPaths {
const paths = path.split("/")
if (paths.length !== 4) return false
if (paths[1] === "actions" && (paths[3] === "run" || paths[3] === "get"))
return true
if (
paths[1] === "dependencies" &&
(paths[3] === "query" || paths[3] === "update")
)
if (paths[1] === "actions" && (paths[3] === "run" || paths[3] === "getInput"))
return true
return false
}
export const jsonPath = some(
literals(
"/init",
"/uninit",
"/config/set",
"/config/get",
"/packageInit",
"/packageUninit",
"/backup/create",
"/backup/restore",
"/actions/metadata",
"/properties",
),
string.refine(isNestedPath, "isNestedPath"),
)

View File

@@ -1,13 +1,10 @@
import { RpcListener } from "./Adapters/RpcListener"
import { SystemForEmbassy } from "./Adapters/Systems/SystemForEmbassy"
import { makeMainEffects, makeProcedureEffects } from "./Adapters/EffectCreator"
import { AllGetDependencies } from "./Interfaces/AllGetDependencies"
import { getSystem } from "./Adapters/Systems"
const getDependencies: AllGetDependencies = {
system: getSystem,
makeProcedureEffects: () => makeProcedureEffects,
makeMainEffects: () => makeMainEffects,
}
new RpcListener(getDependencies)

1538
core/Cargo.lock generated

File diff suppressed because it is too large Load Diff

50
core/build-cli.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
cd "$(dirname "${BASH_SOURCE[0]}")"
set -ea
shopt -s expand_aliases
if [ -z "$ARCH" ]; then
ARCH=$(uname -m)
fi
if [ "$ARCH" = "arm64" ]; then
ARCH="aarch64"
fi
if [ -z "$KERNEL_NAME" ]; then
KERNEL_NAME=$(uname -s)
fi
if [ -z "$TARGET" ]; then
if [ "$KERNEL_NAME" = "Linux" ]; then
TARGET="$ARCH-unknown-linux-musl"
elif [ "$KERNEL_NAME" = "Darwin" ]; then
TARGET="$ARCH-apple-darwin"
else
>&2 echo "unknown kernel $KERNEL_NAME"
exit 1
fi
fi
USE_TTY=
if tty -s; then
USE_TTY="-it"
fi
cd ..
FEATURES="$(echo $ENVIRONMENT | sed 's/-/,/g')"
RUSTFLAGS=""
if [[ "${ENVIRONMENT}" =~ (^|-)unstable($|-) ]]; then
RUSTFLAGS="--cfg tokio_unstable"
fi
alias 'rust-zig-builder'='docker run $USE_TTY --rm -e "RUSTFLAGS=$RUSTFLAGS" -v "$HOME/.cargo/registry":/root/.cargo/registry -v "$HOME/.cargo/git":/root/.cargo/git -v "$(pwd)":/home/rust/src -w /home/rust/src -P messense/cargo-zigbuild'
echo "FEATURES=\"$FEATURES\""
echo "RUSTFLAGS=\"$RUSTFLAGS\""
rust-zig-builder sh -c "cd core && cargo zigbuild --release --no-default-features --features cli,daemon,$FEATURES --locked --bin start-cli --target=$TARGET"
if [ "$(ls -nd core/target/$TARGET/release/start-cli | awk '{ print $3 }')" != "$UID" ]; then
rust-zig-builder sh -c "cd core && chown -R $UID:$UID target && chown -R $UID:$UID /root/.cargo"
fi

View File

@@ -6,6 +6,7 @@ use std::time::Duration;
use color_eyre::eyre::{eyre, Context, Error};
use futures::future::BoxFuture;
use futures::FutureExt;
use models::ResultExt;
use tokio::fs::File;
use tokio::sync::oneshot;
use tokio::task::{JoinError, JoinHandle, LocalSet};
@@ -176,7 +177,7 @@ impl Drop for AtomicFile {
if let Some(file) = self.file.take() {
drop(file);
let path = std::mem::take(&mut self.tmp_path);
tokio::spawn(async move { tokio::fs::remove_file(path).await.unwrap() });
tokio::spawn(async move { tokio::fs::remove_file(path).await.log_err() });
}
}
}

View File

@@ -1,9 +1,8 @@
use std::marker::PhantomData;
use std::str::FromStr;
use clap::builder::TypedValueParser;
use crate::prelude::*;
use rpc_toolkit::clap;
use rpc_toolkit::clap::builder::TypedValueParser;
pub struct FromStrParser<T>(PhantomData<T>);
impl<T> FromStrParser<T> {

View File

@@ -322,6 +322,11 @@ impl From<reqwest::Error> for Error {
Error::new(e, kind)
}
}
impl From<torut::onion::OnionAddressParseError> for Error {
fn from(e: torut::onion::OnionAddressParseError) -> Self {
Error::new(e, ErrorKind::Tor)
}
}
impl From<patch_db::value::Error> for Error {
fn from(value: patch_db::value::Error) -> Self {
match value.kind {
@@ -351,6 +356,14 @@ impl Debug for ErrorData {
}
}
impl std::error::Error for ErrorData {}
impl From<Error> for ErrorData {
fn from(value: Error) -> Self {
Self {
details: value.to_string(),
debug: format!("{:?}", value),
}
}
}
impl From<&RpcError> for ErrorData {
fn from(value: &RpcError) -> Self {
Self {

View File

@@ -11,6 +11,7 @@ mod host;
mod image;
mod invalid_id;
mod package;
mod replay;
mod service_interface;
mod volume;
@@ -20,6 +21,7 @@ pub use host::HostId;
pub use image::ImageId;
pub use invalid_id::InvalidId;
pub use package::{PackageId, SYSTEM_PACKAGE_ID};
pub use replay::ReplayId;
pub use service_interface::ServiceInterfaceId;
pub use volume::VolumeId;

View File

@@ -0,0 +1,45 @@
use std::convert::Infallible;
use std::path::Path;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yasi::InternedString;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, TS)]
#[ts(type = "string")]
pub struct ReplayId(InternedString);
impl FromStr for ReplayId {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(ReplayId(InternedString::intern(s)))
}
}
impl AsRef<ReplayId> for ReplayId {
fn as_ref(&self) -> &ReplayId {
self
}
}
impl std::fmt::Display for ReplayId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", &self.0)
}
}
impl AsRef<str> for ReplayId {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl AsRef<Path> for ReplayId {
fn as_ref(&self) -> &Path {
self.0.as_ref()
}
}
impl<'de> Deserialize<'de> for ReplayId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
Ok(ReplayId(serde::Deserialize::deserialize(deserializer)?))
}
}

View File

@@ -1,9 +1,11 @@
use std::path::Path;
use std::str::FromStr;
use rpc_toolkit::clap::builder::ValueParserFactory;
use serde::{Deserialize, Deserializer, Serialize};
use ts_rs::TS;
use crate::Id;
use crate::{FromStrParser, Id};
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, TS)]
#[ts(export, type = "string")]
@@ -59,3 +61,15 @@ impl sqlx::Type<sqlx::Postgres> for ServiceInterfaceId {
<&str as sqlx::Type<sqlx::Postgres>>::compatible(ty)
}
}
impl FromStr for ServiceInterfaceId {
type Err = <Id as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Id::from_str(s).map(Self)
}
}
impl ValueParserFactory for ServiceInterfaceId {
type Parser = FromStrParser<Self>;
fn value_parser() -> Self::Parser {
FromStrParser::new()
}
}

View File

@@ -1,3 +1,4 @@
mod clap;
mod data_url;
mod errors;
mod id;
@@ -5,6 +6,7 @@ mod mime;
mod procedure_name;
mod version;
pub use clap::*;
pub use data_url::*;
pub use errors::*;
pub use id::*;

View File

@@ -1,38 +1,30 @@
use serde::{Deserialize, Serialize};
use crate::{ActionId, PackageId};
use crate::ActionId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProcedureName {
GetConfig,
SetConfig,
CreateBackup,
Properties,
RestoreBackup,
ActionMetadata,
GetActionInput(ActionId),
RunAction(ActionId),
GetAction(ActionId),
QueryDependency(PackageId),
UpdateDependency(PackageId),
Init,
Uninit,
PackageInit,
PackageUninit,
}
impl ProcedureName {
pub fn js_function_name(&self) -> String {
match self {
ProcedureName::Init => "/init".to_string(),
ProcedureName::Uninit => "/uninit".to_string(),
ProcedureName::PackageInit => "/packageInit".to_string(),
ProcedureName::PackageUninit => "/packageUninit".to_string(),
ProcedureName::SetConfig => "/config/set".to_string(),
ProcedureName::GetConfig => "/config/get".to_string(),
ProcedureName::CreateBackup => "/backup/create".to_string(),
ProcedureName::Properties => "/properties".to_string(),
ProcedureName::RestoreBackup => "/backup/restore".to_string(),
ProcedureName::ActionMetadata => "/actions/metadata".to_string(),
ProcedureName::RunAction(id) => format!("/actions/{}/run", id),
ProcedureName::GetAction(id) => format!("/actions/{}/get", id),
ProcedureName::QueryDependency(id) => format!("/dependencies/{}/query", id),
ProcedureName::UpdateDependency(id) => format!("/dependencies/{}/update", id),
ProcedureName::GetActionInput(id) => format!("/actions/{}/getInput", id),
}
}
}

View File

@@ -14,7 +14,7 @@ keywords = [
name = "start-os"
readme = "README.md"
repository = "https://github.com/Start9Labs/start-os"
version = "0.3.6-alpha.5"
version = "0.3.6-alpha.8"
license = "MIT"
[lib]
@@ -39,10 +39,10 @@ path = "src/main.rs"
[features]
cli = []
container-runtime = ["procfs", "unshare"]
container-runtime = ["procfs", "tty-spawn"]
daemon = []
registry = []
default = ["cli", "daemon"]
default = ["cli", "daemon", "registry", "container-runtime"]
dev = []
unstable = ["console-subscriber", "tokio/tracing"]
docker = []
@@ -50,6 +50,10 @@ test = []
[dependencies]
aes = { version = "0.7.5", features = ["ctr"] }
async-acme = { version = "0.5.0", git = "https://github.com/dr-bonez/async-acme.git", features = [
"use_rustls",
"use_tokio",
] }
async-compression = { version = "0.4.4", features = [
"gzip",
"brotli",
@@ -156,6 +160,7 @@ prettytable-rs = "0.10.0"
procfs = { version = "0.16.0", optional = true }
proptest = "1.3.1"
proptest-derive = "0.5.0"
qrcode = "0.14.1"
rand = { version = "0.8.5", features = ["std"] }
regex = "1.10.2"
reqwest = { version = "0.12.4", features = ["stream", "json", "socks"] }
@@ -197,7 +202,7 @@ tokio-util = { version = "0.7.9", features = ["io"] }
torut = { git = "https://github.com/Start9Labs/torut.git", branch = "update/dependencies", features = [
"serialize",
] }
tower-service = "0.3.2"
tower-service = "0.3.3"
tracing = "0.1.39"
tracing-error = "0.2.0"
tracing-futures = "0.2.5"
@@ -205,10 +210,9 @@ tracing-journald = "0.3.0"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
trust-dns-server = "0.23.1"
ts-rs = { git = "https://github.com/dr-bonez/ts-rs.git", branch = "feature/top-level-as" } # "8.1.0"
tty-spawn = { version = "0.4.0", optional = true }
typed-builder = "0.18.0"
which = "6.0.3"
unix-named-pipe = "0.2.0"
unshare = { version = "0.7.0", optional = true }
url = { version = "2.4.1", features = ["serde"] }
urlencoding = "2.1.3"
uuid = { version = "1.4.1", features = ["v4"] }

View File

@@ -0,0 +1,13 @@
[Unit]
Description=StartOS Registry
[Service]
Type=simple
Environment=RUST_LOG=startos=debug,patch_db=warn
ExecStart=/usr/local/bin/registry
Restart=always
RestartSec=3
ManagedOOMPreference=avoid
[Install]
WantedBy=multi-user.target

View File

@@ -1,87 +1,302 @@
use clap::Parser;
use std::collections::BTreeMap;
use std::fmt;
use clap::{CommandFactory, FromArgMatches, Parser};
pub use models::ActionId;
use models::PackageId;
use qrcode::QrCode;
use rpc_toolkit::{from_fn_async, Context, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use tracing::instrument;
use ts_rs::TS;
use crate::config::Config;
use crate::context::RpcContext;
use crate::context::{CliContext, RpcContext};
use crate::prelude::*;
use crate::rpc_continuations::Guid;
use crate::util::serde::{display_serializable, StdinDeserializable, WithIoFormat};
use crate::util::serde::{
display_serializable, HandlerExtSerde, StdinDeserializable, WithIoFormat,
};
#[derive(Debug, Serialize, Deserialize)]
pub fn action_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand(
"get-input",
from_fn_async(get_action_input)
.with_display_serializable()
.with_about("Get action input spec")
.with_call_remote::<CliContext>(),
)
.subcommand(
"run",
from_fn_async(run_action)
.with_display_serializable()
.with_custom_display_fn(|_, res| {
if let Some(res) = res {
println!("{res}")
}
Ok(())
})
.with_about("Run service action")
.with_call_remote::<CliContext>(),
)
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct ActionInput {
#[ts(type = "Record<string, unknown>")]
pub spec: Value,
#[ts(type = "Record<string, unknown> | null")]
pub value: Option<Value>,
}
#[derive(Deserialize, Serialize, TS, Parser)]
#[serde(rename_all = "camelCase")]
pub struct GetActionInputParams {
pub package_id: PackageId,
pub action_id: ActionId,
}
#[instrument(skip_all)]
pub async fn get_action_input(
ctx: RpcContext,
GetActionInputParams {
package_id,
action_id,
}: GetActionInputParams,
) -> Result<Option<ActionInput>, Error> {
ctx.services
.get(&package_id)
.await
.as_ref()
.or_not_found(lazy_format!("Manager for {}", package_id))?
.get_action_input(Guid::new(), action_id)
.await
}
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(tag = "version")]
#[ts(export)]
pub enum ActionResult {
#[serde(rename = "0")]
V0(ActionResultV0),
#[serde(rename = "1")]
V1(ActionResultV1),
}
impl fmt::Display for ActionResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::V0(res) => res.fmt(f),
Self::V1(res) => res.fmt(f),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, TS)]
pub struct ActionResultV0 {
pub message: String,
pub value: Option<String>,
pub copyable: bool,
pub qr: bool,
}
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum DockerStatus {
Running,
Stopped,
impl fmt::Display for ActionResultV0 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)?;
if let Some(value) = &self.value {
write!(f, ":\n{value}")?;
if self.qr {
use qrcode::render::unicode;
write!(
f,
"\n{}",
QrCode::new(value.as_bytes())
.unwrap()
.render::<unicode::Dense1x2>()
.build()
)?;
}
}
Ok(())
}
}
pub fn display_action_result(params: WithIoFormat<ActionParams>, result: ActionResult) {
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct ActionResultV1 {
pub title: String,
pub message: Option<String>,
pub result: Option<ActionResultValue>,
}
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct ActionResultMember {
pub name: String,
pub description: Option<String>,
#[serde(flatten)]
#[ts(flatten)]
pub value: ActionResultValue,
}
#[derive(Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all_fields = "camelCase")]
#[serde(tag = "type")]
pub enum ActionResultValue {
Single {
value: String,
copyable: bool,
qr: bool,
masked: bool,
},
Group {
value: Vec<ActionResultMember>,
},
}
impl ActionResultValue {
fn fmt_rec(&self, f: &mut fmt::Formatter<'_>, indent: usize) -> fmt::Result {
match self {
Self::Single { value, qr, .. } => {
for _ in 0..indent {
write!(f, " ")?;
}
write!(f, "{value}")?;
if *qr {
use qrcode::render::unicode;
writeln!(f)?;
for _ in 0..indent {
write!(f, " ")?;
}
write!(
f,
"{}",
QrCode::new(value.as_bytes())
.unwrap()
.render::<unicode::Dense1x2>()
.build()
)?;
}
}
Self::Group { value } => {
for ActionResultMember {
name,
description,
value,
} in value
{
for _ in 0..indent {
write!(f, " ")?;
}
write!(f, "{name}")?;
if let Some(description) = description {
write!(f, ": {description}")?;
}
writeln!(f, ":")?;
value.fmt_rec(f, indent + 1)?;
}
}
}
Ok(())
}
}
impl fmt::Display for ActionResultV1 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{}:", self.title)?;
if let Some(message) = &self.message {
writeln!(f, "{message}")?;
}
if let Some(result) = &self.result {
result.fmt_rec(f, 1)?;
}
Ok(())
}
}
pub fn display_action_result<T: Serialize>(params: WithIoFormat<T>, result: Option<ActionResult>) {
let Some(result) = result else {
return;
};
if let Some(format) = params.format {
return display_serializable(format, result);
}
match result {
ActionResult::V0(ar) => {
println!(
"{}: {}",
ar.message,
serde_json::to_string(&ar.value).unwrap()
);
}
}
println!("{result}")
}
#[derive(Deserialize, Serialize, Parser, TS)]
#[derive(Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct ActionParams {
#[arg(id = "id")]
#[serde(rename = "id")]
pub struct RunActionParams {
pub package_id: PackageId,
pub action_id: ActionId,
#[ts(optional, type = "any")]
pub input: Option<Value>,
}
#[derive(Parser)]
struct CliRunActionParams {
pub package_id: PackageId,
pub action_id: ActionId,
#[command(flatten)]
#[ts(type = "{ [key: string]: any } | null")]
#[serde(default)]
pub input: StdinDeserializable<Option<Config>>,
pub input: StdinDeserializable<Option<Value>>,
}
impl From<CliRunActionParams> for RunActionParams {
fn from(
CliRunActionParams {
package_id,
action_id,
input,
}: CliRunActionParams,
) -> Self {
Self {
package_id,
action_id,
input: input.0,
}
}
}
impl CommandFactory for RunActionParams {
fn command() -> clap::Command {
CliRunActionParams::command()
}
fn command_for_update() -> clap::Command {
CliRunActionParams::command_for_update()
}
}
impl FromArgMatches for RunActionParams {
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
CliRunActionParams::from_arg_matches(matches).map(Self::from)
}
fn from_arg_matches_mut(matches: &mut clap::ArgMatches) -> Result<Self, clap::Error> {
CliRunActionParams::from_arg_matches_mut(matches).map(Self::from)
}
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
*self = CliRunActionParams::from_arg_matches(matches).map(Self::from)?;
Ok(())
}
fn update_from_arg_matches_mut(
&mut self,
matches: &mut clap::ArgMatches,
) -> Result<(), clap::Error> {
*self = CliRunActionParams::from_arg_matches_mut(matches).map(Self::from)?;
Ok(())
}
}
// impl C
// #[command(about = "Executes an action", display(display_action_result))]
#[instrument(skip_all)]
pub async fn action(
pub async fn run_action(
ctx: RpcContext,
ActionParams {
RunActionParams {
package_id,
action_id,
input: StdinDeserializable(input),
}: ActionParams,
) -> Result<ActionResult, Error> {
input,
}: RunActionParams,
) -> Result<Option<ActionResult>, Error> {
ctx.services
.get(&package_id)
.await
.as_ref()
.or_not_found(lazy_format!("Manager for {}", package_id))?
.action(
Guid::new(),
action_id,
input.map(|c| to_value(&c)).transpose()?.unwrap_or_default(),
)
.run_action(Guid::new(), action_id, input.unwrap_or_default())
.await
}

View File

@@ -91,28 +91,40 @@ pub fn auth<C: Context>() -> ParentHandler<C> {
.with_metadata("login", Value::Bool(true))
.no_cli(),
)
.subcommand("login", from_fn_async(cli_login).no_display())
.subcommand(
"login",
from_fn_async(cli_login)
.no_display()
.with_about("Log in to StartOS server"),
)
.subcommand(
"logout",
from_fn_async(logout)
.with_metadata("get_session", Value::Bool(true))
.no_display()
.with_about("Log out of StartOS server")
.with_call_remote::<CliContext>(),
)
.subcommand("session", session::<C>())
.subcommand(
"session",
session::<C>().with_about("List or kill StartOS sessions"),
)
.subcommand(
"reset-password",
from_fn_async(reset_password_impl).no_cli(),
)
.subcommand(
"reset-password",
from_fn_async(cli_reset_password).no_display(),
from_fn_async(cli_reset_password)
.no_display()
.with_about("Reset StartOS password"),
)
.subcommand(
"get-pubkey",
from_fn_async(get_pubkey)
.with_metadata("authenticated", Value::Bool(false))
.no_display()
.with_about("Get public key derived from server private key")
.with_call_remote::<CliContext>(),
)
}
@@ -275,8 +287,8 @@ pub struct Session {
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SessionList {
#[ts(type = "string")]
current: InternedString,
#[ts(type = "string | null")]
current: Option<InternedString>,
sessions: Sessions,
}
@@ -290,12 +302,14 @@ pub fn session<C: Context>() -> ParentHandler<C> {
.with_custom_display_fn(|handle, result| {
Ok(display_sessions(handle.params, result))
})
.with_about("Display all server sessions")
.with_call_remote::<CliContext>(),
)
.subcommand(
"kill",
from_fn_async(kill)
.no_display()
.with_about("Terminate existing server session(s)")
.with_call_remote::<CliContext>(),
)
}
@@ -323,7 +337,7 @@ fn display_sessions(params: WithIoFormat<ListParams>, arg: SessionList) {
session.user_agent.as_deref().unwrap_or("N/A"),
&format!("{}", session.metadata),
];
if id == arg.current {
if Some(id) == arg.current {
row.iter_mut()
.map(|c| c.style(Attr::ForegroundColor(color::GREEN)))
.collect::<()>()
@@ -340,7 +354,7 @@ pub struct ListParams {
#[arg(skip)]
#[ts(skip)]
#[serde(rename = "__auth_session")] // from Auth middleware
session: InternedString,
session: Option<InternedString>,
}
// #[command(display(display_sessions))]

View File

@@ -141,7 +141,7 @@ impl Drop for BackupStatusGuard {
.ser(&None)
})
.await
.unwrap()
.log_err()
});
}
}
@@ -332,10 +332,10 @@ async fn perform_backup(
let timestamp = Utc::now();
backup_guard.unencrypted_metadata.version = crate::version::Current::new().semver().into();
backup_guard.unencrypted_metadata.version = crate::version::Current::default().semver().into();
backup_guard.unencrypted_metadata.hostname = ctx.account.read().await.hostname.clone();
backup_guard.unencrypted_metadata.timestamp = timestamp.clone();
backup_guard.metadata.version = crate::version::Current::new().semver().into();
backup_guard.metadata.version = crate::version::Current::default().semver().into();
backup_guard.metadata.timestamp = Some(timestamp);
backup_guard.metadata.package_backups = package_backups;

View File

@@ -40,9 +40,13 @@ pub fn backup<C: Context>() -> ParentHandler<C> {
"create",
from_fn_async(backup_bulk::backup_all)
.no_display()
.with_about("Create backup for all packages")
.with_call_remote::<CliContext>(),
)
.subcommand("target", target::target::<C>())
.subcommand(
"target",
target::target::<C>().with_about("Commands related to a backup target"),
)
}
pub fn package_backup<C: Context>() -> ParentHandler<C> {
@@ -50,6 +54,7 @@ pub fn package_backup<C: Context>() -> ParentHandler<C> {
"restore",
from_fn_async(restore::restore_packages_rpc)
.no_display()
.with_about("Restore package(s) from backup")
.with_call_remote::<CliContext>(),
)
}

View File

@@ -52,18 +52,21 @@ pub fn cifs<C: Context>() -> ParentHandler<C> {
"add",
from_fn_async(add)
.no_display()
.with_about("Add a new backup target")
.with_call_remote::<CliContext>(),
)
.subcommand(
"update",
from_fn_async(update)
.no_display()
.with_about("Update an existing backup target")
.with_call_remote::<CliContext>(),
)
.subcommand(
"remove",
from_fn_async(remove)
.no_display()
.with_about("Remove an existing backup target")
.with_call_remote::<CliContext>(),
)
}

View File

@@ -9,7 +9,7 @@ use digest::generic_array::GenericArray;
use digest::OutputSizeUser;
use exver::Version;
use imbl_value::InternedString;
use models::PackageId;
use models::{FromStrParser, PackageId};
use rpc_toolkit::{from_fn_async, Context, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
@@ -27,7 +27,6 @@ use crate::disk::mount::filesystem::{FileSystem, MountType, ReadWrite};
use crate::disk::mount::guard::{GenericMountGuard, TmpMountGuard};
use crate::disk::util::PartitionInfo;
use crate::prelude::*;
use crate::util::clap::FromStrParser;
use crate::util::serde::{
deserialize_from_str, display_serializable, serialize_display, HandlerExtSerde, WithIoFormat,
};
@@ -142,11 +141,15 @@ impl FileSystem for BackupTargetFS {
// #[command(subcommands(cifs::cifs, list, info, mount, umount))]
pub fn target<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("cifs", cifs::cifs::<C>())
.subcommand(
"cifs",
cifs::cifs::<C>().with_about("Add, remove, or update a backup target"),
)
.subcommand(
"list",
from_fn_async(list)
.with_display_serializable()
.with_about("List existing backup targets")
.with_call_remote::<CliContext>(),
)
.subcommand(
@@ -156,16 +159,20 @@ pub fn target<C: Context>() -> ParentHandler<C> {
.with_custom_display_fn::<CliContext, _>(|params, info| {
Ok(display_backup_info(params.params, info))
})
.with_about("Display package backup information")
.with_call_remote::<CliContext>(),
)
.subcommand(
"mount",
from_fn_async(mount).with_call_remote::<CliContext>(),
from_fn_async(mount)
.with_about("Mount backup target")
.with_call_remote::<CliContext>(),
)
.subcommand(
"umount",
from_fn_async(umount)
.no_display()
.with_about("Unmount backup target")
.with_call_remote::<CliContext>(),
)
}

View File

@@ -8,7 +8,7 @@ use crate::util::logger::EmbassyLogger;
use crate::version::{Current, VersionT};
lazy_static::lazy_static! {
static ref VERSION_STRING: String = Current::new().semver().to_string();
static ref VERSION_STRING: String = Current::default().semver().to_string();
}
pub fn main(args: impl IntoIterator<Item = OsString>) {

View File

@@ -28,6 +28,16 @@ fn select_executable(name: &str) -> Option<fn(VecDeque<OsString>)> {
"embassy-sdk" => Some(|_| deprecated::renamed("embassy-sdk", "start-sdk")),
"embassyd" => Some(|_| deprecated::renamed("embassyd", "startd")),
"embassy-init" => Some(|_| deprecated::removed("embassy-init")),
"contents" => Some(|_| {
#[cfg(feature = "cli")]
println!("start-cli");
#[cfg(feature = "container-runtime")]
println!("start-cli (container)");
#[cfg(feature = "daemon")]
println!("startd");
#[cfg(feature = "registry")]
println!("registry");
}),
_ => None,
}
}

View File

@@ -9,7 +9,7 @@ use crate::util::logger::EmbassyLogger;
use crate::version::{Current, VersionT};
lazy_static::lazy_static! {
static ref VERSION_STRING: String = Current::new().semver().to_string();
static ref VERSION_STRING: String = Current::default().semver().to_string();
}
pub fn main(args: impl IntoIterator<Item = OsString>) {

View File

@@ -1,22 +0,0 @@
use std::collections::{BTreeMap, BTreeSet};
use models::PackageId;
use serde::{Deserialize, Serialize};
use super::{Config, ConfigSpec};
#[allow(unused_imports)]
use crate::prelude::*;
use crate::status::health_check::HealthCheckId;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigRes {
pub config: Option<Config>,
pub spec: ConfigSpec,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetResult {
pub depends_on: BTreeMap<PackageId, BTreeSet<HealthCheckId>>,
}

View File

@@ -1,281 +0,0 @@
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use color_eyre::eyre::eyre;
use indexmap::{IndexMap, IndexSet};
use itertools::Itertools;
use models::{ErrorKind, OptionExt, PackageId};
use patch_db::value::InternedString;
use patch_db::Value;
use regex::Regex;
use rpc_toolkit::{from_fn_async, Context, Empty, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use tracing::instrument;
use ts_rs::TS;
use crate::context::{CliContext, RpcContext};
use crate::prelude::*;
use crate::rpc_continuations::Guid;
use crate::util::serde::{HandlerExtSerde, StdinDeserializable};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ConfigSpec(pub IndexMap<InternedString, Value>);
pub mod action;
pub mod util;
use util::NumRange;
use self::action::ConfigRes;
pub type Config = patch_db::value::InOMap<InternedString, Value>;
pub trait TypeOf {
fn type_of(&self) -> &'static str;
}
impl TypeOf for Value {
fn type_of(&self) -> &'static str {
match self {
Value::Array(_) => "list",
Value::Bool(_) => "boolean",
Value::Null => "null",
Value::Number(_) => "number",
Value::Object(_) => "object",
Value::String(_) => "string",
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigurationError {
#[error("Timeout Error")]
TimeoutError(#[from] TimeoutError),
#[error("No Match: {0}")]
NoMatch(#[from] NoMatchWithPath),
#[error("System Error: {0}")]
SystemError(Error),
}
impl From<ConfigurationError> for Error {
fn from(err: ConfigurationError) -> Self {
let kind = match &err {
ConfigurationError::SystemError(e) => e.kind,
_ => crate::ErrorKind::ConfigGen,
};
crate::Error::new(err, kind)
}
}
#[derive(Clone, Copy, Debug, thiserror::Error)]
#[error("Timeout Error")]
pub struct TimeoutError;
#[derive(Clone, Debug, thiserror::Error)]
pub struct NoMatchWithPath {
pub path: Vec<InternedString>,
pub error: MatchError,
}
impl NoMatchWithPath {
pub fn new(error: MatchError) -> Self {
NoMatchWithPath {
path: Vec::new(),
error,
}
}
pub fn prepend(mut self, seg: InternedString) -> Self {
self.path.push(seg);
self
}
}
impl std::fmt::Display for NoMatchWithPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.path.iter().rev().join("."), self.error)
}
}
impl From<NoMatchWithPath> for Error {
fn from(e: NoMatchWithPath) -> Self {
ConfigurationError::from(e).into()
}
}
#[derive(Clone, Debug, thiserror::Error)]
pub enum MatchError {
#[error("String {0:?} Does Not Match Pattern {1}")]
Pattern(Arc<String>, Regex),
#[error("String {0:?} Is Not In Enum {1:?}")]
Enum(Arc<String>, IndexSet<String>),
#[error("Field Is Not Nullable")]
NotNullable,
#[error("Length Mismatch: expected {0}, actual: {1}")]
LengthMismatch(NumRange<usize>, usize),
#[error("Invalid Type: expected {0}, actual: {1}")]
InvalidType(&'static str, &'static str),
#[error("Number Out Of Range: expected {0}, actual: {1}")]
OutOfRange(NumRange<f64>, f64),
#[error("Number Is Not Integral: {0}")]
NonIntegral(f64),
#[error("Variant {0:?} Is Not In Union {1:?}")]
Union(Arc<String>, IndexSet<String>),
#[error("Variant Is Missing Tag {0:?}")]
MissingTag(InternedString),
#[error("Property {0:?} Of Variant {1:?} Conflicts With Union Tag")]
PropertyMatchesUnionTag(InternedString, String),
#[error("Name of Property {0:?} Conflicts With Map Tag Name")]
PropertyNameMatchesMapTag(String),
#[error("Object Key Is Invalid: {0}")]
InvalidKey(String),
#[error("Value In List Is Not Unique")]
ListUniquenessViolation,
}
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct ConfigParams {
pub id: PackageId,
}
// #[command(subcommands(get, set))]
pub fn config<C: Context>() -> ParentHandler<C, ConfigParams> {
ParentHandler::new()
.subcommand(
"get",
from_fn_async(get)
.with_inherited(|ConfigParams { id }, _| id)
.with_display_serializable()
.with_call_remote::<CliContext>(),
)
.subcommand(
"set",
set::<C>().with_inherited(|ConfigParams { id }, _| id),
)
}
#[instrument(skip_all)]
pub async fn get(ctx: RpcContext, _: Empty, id: PackageId) -> Result<ConfigRes, Error> {
ctx.services
.get(&id)
.await
.as_ref()
.or_not_found(lazy_format!("Manager for {id}"))?
.get_config(Guid::new())
.await
}
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
pub struct SetParams {
#[arg(long = "timeout")]
pub timeout: Option<crate::util::serde::Duration>,
#[command(flatten)]
#[ts(type = "{ [key: string]: any } | null")]
pub config: StdinDeserializable<Option<Config>>,
}
// #[command(
// subcommands(self(set_impl(async, context(RpcContext))), set_dry),
// display(display_none),
// metadata(sync_db = true)
// )]
#[instrument(skip_all)]
pub fn set<C: Context>() -> ParentHandler<C, SetParams, PackageId> {
ParentHandler::new()
.root_handler(
from_fn_async(set_impl)
.with_metadata("sync_db", Value::Bool(true))
.with_inherited(|set_params, id| (id, set_params))
.no_display()
.with_call_remote::<CliContext>(),
)
.subcommand(
"dry",
from_fn_async(set_dry)
.with_inherited(|set_params, id| (id, set_params))
.no_display()
.with_call_remote::<CliContext>(),
)
}
pub async fn set_dry(
ctx: RpcContext,
_: Empty,
(
id,
SetParams {
timeout,
config: StdinDeserializable(config),
},
): (PackageId, SetParams),
) -> Result<BTreeSet<PackageId>, Error> {
let mut breakages = BTreeSet::new();
let procedure_id = Guid::new();
let db = ctx.db.peek().await;
for dep in db
.as_public()
.as_package_data()
.as_entries()?
.into_iter()
.filter_map(
|(k, v)| match v.as_current_dependencies().contains_key(&id) {
Ok(true) => Some(Ok(k)),
Ok(false) => None,
Err(e) => Some(Err(e)),
},
)
{
let dep_id = dep?;
let Some(dependent) = &*ctx.services.get(&dep_id).await else {
continue;
};
if dependent
.dependency_config(procedure_id.clone(), id.clone(), config.clone())
.await?
.is_some()
{
breakages.insert(dep_id);
}
}
Ok(breakages)
}
#[derive(Default)]
pub struct ConfigureContext {
pub timeout: Option<Duration>,
pub config: Option<Config>,
}
#[instrument(skip_all)]
pub async fn set_impl(
ctx: RpcContext,
_: Empty,
(
id,
SetParams {
timeout,
config: StdinDeserializable(config),
},
): (PackageId, SetParams),
) -> Result<(), Error> {
let configure_context = ConfigureContext {
timeout: timeout.map(|t| *t),
config,
};
ctx.services
.get(&id)
.await
.as_ref()
.ok_or_else(|| {
Error::new(
eyre!("There is no manager running for {id}"),
ErrorKind::Unknown,
)
})?
.configure(Guid::new(), configure_context)
.await?;
Ok(())
}

View File

@@ -1,406 +0,0 @@
use std::borrow::Cow;
use std::ops::{Bound, RangeBounds, RangeInclusive};
use patch_db::Value;
use rand::distributions::Distribution;
use rand::Rng;
use super::Config;
pub const STATIC_NULL: Value = Value::Null;
#[derive(Clone, Debug)]
pub struct CharSet(pub Vec<(RangeInclusive<char>, usize)>, usize);
impl CharSet {
pub fn contains(&self, c: &char) -> bool {
self.0.iter().any(|r| r.0.contains(c))
}
pub fn gen<R: Rng>(&self, rng: &mut R) -> char {
let mut idx = rng.gen_range(0..self.1);
for r in &self.0 {
if idx < r.1 {
return std::convert::TryFrom::try_from(
rand::distributions::Uniform::new_inclusive(
u32::from(*r.0.start()),
u32::from(*r.0.end()),
)
.sample(rng),
)
.unwrap();
} else {
idx -= r.1;
}
}
unreachable!()
}
}
impl Default for CharSet {
fn default() -> Self {
CharSet(vec![('!'..='~', 94)], 94)
}
}
impl<'de> serde::de::Deserialize<'de> for CharSet {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let mut res = Vec::new();
let mut len = 0;
let mut a: Option<char> = None;
let mut b: Option<char> = None;
let mut in_range = false;
for c in s.chars() {
match c {
',' => match (a, b, in_range) {
(Some(start), Some(end), _) => {
if !end.is_ascii() {
return Err(serde::de::Error::custom("Invalid Character"));
}
if start >= end {
return Err(serde::de::Error::custom("Invalid Bounds"));
}
let l = u32::from(end) - u32::from(start) + 1;
res.push((start..=end, l as usize));
len += l as usize;
a = None;
b = None;
in_range = false;
}
(Some(start), None, false) => {
len += 1;
res.push((start..=start, 1));
a = None;
}
(Some(_), None, true) => {
b = Some(',');
}
(None, None, false) => {
a = Some(',');
}
_ => {
return Err(serde::de::Error::custom("Syntax Error"));
}
},
'-' => {
if a.is_none() {
a = Some('-');
} else if !in_range {
in_range = true;
} else if b.is_none() {
b = Some('-')
} else {
return Err(serde::de::Error::custom("Syntax Error"));
}
}
_ => {
if a.is_none() {
a = Some(c);
} else if in_range && b.is_none() {
b = Some(c);
} else {
return Err(serde::de::Error::custom("Syntax Error"));
}
}
}
}
match (a, b) {
(Some(start), Some(end)) => {
if !end.is_ascii() {
return Err(serde::de::Error::custom("Invalid Character"));
}
if start >= end {
return Err(serde::de::Error::custom("Invalid Bounds"));
}
let l = u32::from(end) - u32::from(start) + 1;
res.push((start..=end, l as usize));
len += l as usize;
}
(Some(c), None) => {
len += 1;
res.push((c..=c, 1));
}
_ => (),
}
Ok(CharSet(res, len))
}
}
impl serde::ser::Serialize for CharSet {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
<&str>::serialize(
&self
.0
.iter()
.map(|r| match r.1 {
1 => format!("{}", r.0.start()),
_ => format!("{}-{}", r.0.start(), r.0.end()),
})
.collect::<Vec<_>>()
.join(",")
.as_str(),
serializer,
)
}
}
pub trait MergeWith {
fn merge_with(&mut self, other: &serde_json::Value);
}
impl MergeWith for serde_json::Value {
fn merge_with(&mut self, other: &serde_json::Value) {
use serde_json::Value::Object;
if let (Object(orig), Object(ref other)) = (self, other) {
for (key, val) in other.into_iter() {
match (orig.get_mut(key), val) {
(Some(new_orig @ Object(_)), other @ Object(_)) => {
new_orig.merge_with(other);
}
(None, _) => {
orig.insert(key.clone(), val.clone());
}
_ => (),
}
}
}
}
}
#[test]
fn merge_with_tests() {
use serde_json::json;
let mut a = json!(
{"a": 1, "c": {"d": "123"}, "i": [1,2,3], "j": {}, "k":[1,2,3], "l": "test"}
);
a.merge_with(
&json!({"a":"a", "b": "b", "c":{"d":"d", "e":"e"}, "f":{"g":"g"}, "h": [1,2,3], "i":"i", "j":[1,2,3], "k":{}}),
);
assert_eq!(
a,
json!({"a": 1, "c": {"d": "123", "e":"e"}, "b":"b", "f": {"g":"g"}, "h":[1,2,3], "i":[1,2,3], "j": {}, "k":[1,2,3], "l": "test"})
)
}
pub mod serde_regex {
use regex::Regex;
use serde::*;
pub fn serialize<S>(regex: &Regex, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
<&str>::serialize(&regex.as_str(), serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Regex, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Regex::new(&s).map_err(|e| de::Error::custom(e))
}
}
#[derive(Clone, Debug)]
pub struct NumRange<T: std::str::FromStr + std::fmt::Display + std::cmp::PartialOrd>(
pub (Bound<T>, Bound<T>),
);
impl<T> std::ops::Deref for NumRange<T>
where
T: std::str::FromStr + std::fmt::Display + std::cmp::PartialOrd,
{
type Target = (Bound<T>, Bound<T>);
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'de, T> serde::de::Deserialize<'de> for NumRange<T>
where
T: std::str::FromStr + std::fmt::Display + std::cmp::PartialOrd,
<T as std::str::FromStr>::Err: std::fmt::Display,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let mut split = s.split(",");
let start = split
.next()
.map(|s| match s.get(..1) {
Some("(") => match s.get(1..2) {
Some("*") => Ok(Bound::Unbounded),
_ => s[1..]
.trim()
.parse()
.map(Bound::Excluded)
.map_err(|e| serde::de::Error::custom(e)),
},
Some("[") => s[1..]
.trim()
.parse()
.map(Bound::Included)
.map_err(|e| serde::de::Error::custom(e)),
_ => Err(serde::de::Error::custom(format!(
"Could not parse left bound: {}",
s
))),
})
.transpose()?
.unwrap();
let end = split
.next()
.map(|s| match s.get(s.len() - 1..) {
Some(")") => match s.get(s.len() - 2..s.len() - 1) {
Some("*") => Ok(Bound::Unbounded),
_ => s[..s.len() - 1]
.trim()
.parse()
.map(Bound::Excluded)
.map_err(|e| serde::de::Error::custom(e)),
},
Some("]") => s[..s.len() - 1]
.trim()
.parse()
.map(Bound::Included)
.map_err(|e| serde::de::Error::custom(e)),
_ => Err(serde::de::Error::custom(format!(
"Could not parse right bound: {}",
s
))),
})
.transpose()?
.unwrap_or(Bound::Unbounded);
Ok(NumRange((start, end)))
}
}
impl<T> std::fmt::Display for NumRange<T>
where
T: std::str::FromStr + std::fmt::Display + std::cmp::PartialOrd,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.start_bound() {
Bound::Excluded(n) => write!(f, "({},", n)?,
Bound::Included(n) => write!(f, "[{},", n)?,
Bound::Unbounded => write!(f, "(*,")?,
};
match self.end_bound() {
Bound::Excluded(n) => write!(f, "{})", n),
Bound::Included(n) => write!(f, "{}]", n),
Bound::Unbounded => write!(f, "*)"),
}
}
}
impl<T> serde::ser::Serialize for NumRange<T>
where
T: std::str::FromStr + std::fmt::Display + std::cmp::PartialOrd,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
<&str>::serialize(&format!("{}", self).as_str(), serializer)
}
}
#[derive(Clone, Debug)]
pub enum UniqueBy {
Any(Vec<UniqueBy>),
All(Vec<UniqueBy>),
Exactly(String),
NotUnique,
}
impl UniqueBy {
pub fn eq(&self, lhs: &Config, rhs: &Config) -> bool {
match self {
UniqueBy::Any(any) => any.iter().any(|u| u.eq(lhs, rhs)),
UniqueBy::All(all) => all.iter().all(|u| u.eq(lhs, rhs)),
UniqueBy::Exactly(key) => lhs.get(&**key) == rhs.get(&**key),
UniqueBy::NotUnique => false,
}
}
}
impl Default for UniqueBy {
fn default() -> Self {
UniqueBy::NotUnique
}
}
impl<'de> serde::de::Deserialize<'de> for UniqueBy {
fn deserialize<D: serde::de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = UniqueBy;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "a key, an \"any\" object, or an \"all\" object")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(UniqueBy::Exactly(v.to_owned()))
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
Ok(UniqueBy::Exactly(v))
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<Self::Value, A::Error> {
let mut variant = None;
while let Some(key) = map.next_key::<Cow<str>>()? {
match key.as_ref() {
"any" => {
return Ok(UniqueBy::Any(map.next_value()?));
}
"all" => {
return Ok(UniqueBy::All(map.next_value()?));
}
_ => {
variant = Some(key);
}
}
}
Err(serde::de::Error::unknown_variant(
variant.unwrap_or_default().as_ref(),
&["any", "all"],
))
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(UniqueBy::NotUnique)
}
fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(UniqueBy::NotUnique)
}
}
deserializer.deserialize_any(Visitor)
}
}
impl serde::ser::Serialize for UniqueBy {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
use serde::ser::SerializeMap;
match self {
UniqueBy::Any(any) => {
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_key("any")?;
map.serialize_value(any)?;
map.end()
}
UniqueBy::All(all) => {
let mut map = serializer.serialize_map(Some(1))?;
map.serialize_key("all")?;
map.serialize_value(all)?;
map.end()
}
UniqueBy::Exactly(key) => serializer.serialize_str(key),
UniqueBy::NotUnique => serializer.serialize_unit(),
}
}
}

View File

@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::ops::Deref;
@@ -9,8 +9,11 @@ use std::time::Duration;
use chrono::{TimeDelta, Utc};
use helpers::NonDetachingJoinHandle;
use imbl::OrdMap;
use imbl_value::InternedString;
use itertools::Itertools;
use josekit::jwk::Jwk;
use models::{ActionId, PackageId};
use reqwest::{Client, Proxy};
use rpc_toolkit::yajrc::RpcError;
use rpc_toolkit::{CallRemote, Context, Empty};
@@ -23,7 +26,6 @@ use crate::account::AccountInfo;
use crate::auth::Sessions;
use crate::context::config::ServerConfig;
use crate::db::model::Database;
use crate::dependencies::compute_dependency_config_errs;
use crate::disk::OsPartitionInfo;
use crate::init::check_time_is_synchronized;
use crate::lxc::{ContainerId, LxcContainer, LxcManager};
@@ -33,11 +35,11 @@ use crate::net::wifi::WpaCli;
use crate::prelude::*;
use crate::progress::{FullProgressTracker, PhaseProgressTrackerHandle};
use crate::rpc_continuations::{Guid, OpenAuthedContinuations, RpcContinuations};
use crate::service::action::update_requested_actions;
use crate::service::effects::callbacks::ServiceCallbacks;
use crate::service::ServiceMap;
use crate::shutdown::Shutdown;
use crate::system::get_mem_info;
use crate::util::lshw::{lshw, LshwDevice};
use crate::util::lshw::LshwDevice;
use crate::util::sync::SyncMutex;
pub struct RpcContextSeed {
@@ -58,16 +60,15 @@ pub struct RpcContextSeed {
pub shutdown: broadcast::Sender<Option<Shutdown>>,
pub tor_socks: SocketAddr,
pub lxc_manager: Arc<LxcManager>,
pub open_authed_continuations: OpenAuthedContinuations<InternedString>,
pub open_authed_continuations: OpenAuthedContinuations<Option<InternedString>>,
pub rpc_continuations: RpcContinuations,
pub callbacks: ServiceCallbacks,
pub wifi_manager: Option<Arc<RwLock<WpaCli>>>,
pub current_secret: Arc<Jwk>,
pub client: Client,
pub hardware: Hardware,
pub start_time: Instant,
pub crons: SyncMutex<BTreeMap<Guid, NonDetachingJoinHandle<()>>>,
#[cfg(feature = "dev")]
// #[cfg(feature = "dev")]
pub dev: Dev,
}
@@ -83,15 +84,14 @@ pub struct Hardware {
pub struct InitRpcContextPhases {
load_db: PhaseProgressTrackerHandle,
init_net_ctrl: PhaseProgressTrackerHandle,
read_device_info: PhaseProgressTrackerHandle,
cleanup_init: CleanupInitPhases,
// TODO: migrations
}
impl InitRpcContextPhases {
pub fn new(handle: &FullProgressTracker) -> Self {
Self {
load_db: handle.add_phase("Loading database".into(), Some(5)),
init_net_ctrl: handle.add_phase("Initializing network".into(), Some(1)),
read_device_info: handle.add_phase("Reading device information".into(), Some(1)),
cleanup_init: CleanupInitPhases::new(handle),
}
}
@@ -100,14 +100,14 @@ impl InitRpcContextPhases {
pub struct CleanupInitPhases {
cleanup_sessions: PhaseProgressTrackerHandle,
init_services: PhaseProgressTrackerHandle,
check_dependencies: PhaseProgressTrackerHandle,
check_requested_actions: PhaseProgressTrackerHandle,
}
impl CleanupInitPhases {
pub fn new(handle: &FullProgressTracker) -> Self {
Self {
cleanup_sessions: handle.add_phase("Cleaning up sessions".into(), Some(1)),
init_services: handle.add_phase("Initializing services".into(), Some(10)),
check_dependencies: handle.add_phase("Checking dependencies".into(), Some(1)),
check_requested_actions: handle.add_phase("Checking action requests".into(), Some(1)),
}
}
}
@@ -123,7 +123,6 @@ impl RpcContext {
InitRpcContextPhases {
mut load_db,
mut init_net_ctrl,
mut read_device_info,
cleanup_init,
}: InitRpcContextPhases,
) -> Result<Self, Error> {
@@ -175,11 +174,6 @@ impl RpcContext {
let metrics_cache = RwLock::<Option<crate::system::Metrics>>::new(None);
let tor_proxy_url = format!("socks5h://{tor_proxy}");
read_device_info.start();
let devices = lshw().await?;
let ram = get_mem_info().await?.total.0 as u64 * 1024 * 1024;
read_device_info.complete();
let crons = SyncMutex::new(BTreeMap::new());
if !db
@@ -271,10 +265,9 @@ impl RpcContext {
}))
.build()
.with_kind(crate::ErrorKind::ParseUrl)?,
hardware: Hardware { devices, ram },
start_time: Instant::now(),
crons,
#[cfg(feature = "dev")]
// #[cfg(feature = "dev")]
dev: Dev {
lxc: Mutex::new(BTreeMap::new()),
},
@@ -283,6 +276,7 @@ impl RpcContext {
let res = Self(seed.clone());
res.cleanup_and_initialize(cleanup_init).await?;
tracing::info!("Cleaned up transient states");
crate::version::post_init(&res).await?;
Ok(res)
}
@@ -309,7 +303,7 @@ impl RpcContext {
CleanupInitPhases {
mut cleanup_sessions,
init_services,
mut check_dependencies,
mut check_requested_actions,
}: CleanupInitPhases,
) -> Result<(), Error> {
cleanup_sessions.start();
@@ -366,35 +360,68 @@ impl RpcContext {
cleanup_sessions.complete();
self.services.init(&self, init_services).await?;
tracing::info!("Initialized Package Managers");
tracing::info!("Initialized Services");
check_dependencies.start();
let mut updated_current_dependents = BTreeMap::new();
// TODO
check_requested_actions.start();
let peek = self.db.peek().await;
for (package_id, package) in peek.as_public().as_package_data().as_entries()?.into_iter() {
let package = package.clone();
let mut current_dependencies = package.as_current_dependencies().de()?;
compute_dependency_config_errs(self, &package_id, &mut current_dependencies)
.await
.log_err();
updated_current_dependents.insert(package_id.clone(), current_dependencies);
let mut action_input: OrdMap<PackageId, BTreeMap<ActionId, Value>> = OrdMap::new();
let requested_actions: BTreeSet<_> = peek
.as_public()
.as_package_data()
.as_entries()?
.into_iter()
.map(|(_, pde)| {
Ok(pde
.as_requested_actions()
.as_entries()?
.into_iter()
.map(|(_, r)| {
Ok::<_, Error>((
r.as_request().as_package_id().de()?,
r.as_request().as_action_id().de()?,
))
}))
})
.flatten_ok()
.map(|a| a.and_then(|a| a))
.try_collect()?;
let procedure_id = Guid::new();
for (package_id, action_id) in requested_actions {
if let Some(service) = self.services.get(&package_id).await.as_ref() {
if let Some(input) = service
.get_action_input(procedure_id.clone(), action_id.clone())
.await?
.and_then(|i| i.value)
{
action_input
.entry(package_id)
.or_default()
.insert(action_id, input);
}
}
}
self.db
.mutate(|v| {
for (package_id, deps) in updated_current_dependents {
if let Some(model) = v
.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package_id)
.map(|i| i.as_current_dependencies_mut())
{
model.ser(&deps)?;
.mutate(|db| {
for (package_id, action_input) in &action_input {
for (action_id, input) in action_input {
for (_, pde) in db.as_public_mut().as_package_data_mut().as_entries_mut()? {
pde.as_requested_actions_mut().mutate(|requested_actions| {
Ok(update_requested_actions(
requested_actions,
package_id,
action_id,
input,
false,
))
})?;
}
}
}
Ok(())
})
.await?;
check_dependencies.complete();
check_requested_actions.complete();
Ok(())
}
@@ -431,8 +458,8 @@ impl AsRef<RpcContinuations> for RpcContext {
&self.rpc_continuations
}
}
impl AsRef<OpenAuthedContinuations<InternedString>> for RpcContext {
fn as_ref(&self) -> &OpenAuthedContinuations<InternedString> {
impl AsRef<OpenAuthedContinuations<Option<InternedString>>> for RpcContext {
fn as_ref(&self) -> &OpenAuthedContinuations<Option<InternedString>> {
&self.open_authed_continuations
}
}

View File

@@ -21,6 +21,7 @@ use crate::account::AccountInfo;
use crate::context::config::ServerConfig;
use crate::context::RpcContext;
use crate::disk::OsPartitionInfo;
use crate::hostname::Hostname;
use crate::init::init_postgres;
use crate::prelude::*;
use crate::progress::FullProgressTracker;
@@ -42,6 +43,8 @@ lazy_static::lazy_static! {
pub struct SetupResult {
pub tor_address: String,
#[ts(type = "string")]
pub hostname: Hostname,
#[ts(type = "string")]
pub lan_address: InternedString,
pub root_ca: String,
}
@@ -50,6 +53,7 @@ impl TryFrom<&AccountInfo> for SetupResult {
fn try_from(value: &AccountInfo) -> Result<Self, Self::Error> {
Ok(Self {
tor_address: format!("https://{}", value.tor_key.public().get_onion_address()),
hostname: value.hostname.clone(),
lan_address: value.hostname.lan_address(),
root_ca: String::from_utf8(value.root_ca_cert.to_pem()?)?,
})

View File

@@ -31,7 +31,12 @@ lazy_static::lazy_static! {
pub fn db<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("dump", from_fn_async(cli_dump).with_display_serializable())
.subcommand(
"dump",
from_fn_async(cli_dump)
.with_display_serializable()
.with_about("Filter/query db to display tables and records"),
)
.subcommand("dump", from_fn_async(dump).no_cli())
.subcommand(
"subscribe",
@@ -39,8 +44,16 @@ pub fn db<C: Context>() -> ParentHandler<C> {
.with_metadata("get_session", Value::Bool(true))
.no_cli(),
)
.subcommand("put", put::<C>())
.subcommand("apply", from_fn_async(cli_apply).no_display())
.subcommand(
"put",
put::<C>().with_about("Command for adding UI record to db"),
)
.subcommand(
"apply",
from_fn_async(cli_apply)
.no_display()
.with_about("Update a db record"),
)
.subcommand("apply", from_fn_async(apply).no_cli())
}
@@ -115,7 +128,7 @@ pub struct SubscribeParams {
pointer: Option<JsonPointer>,
#[ts(skip)]
#[serde(rename = "__auth_session")]
session: InternedString,
session: Option<InternedString>,
}
#[derive(Deserialize, Serialize, TS)]
@@ -215,6 +228,8 @@ pub async fn subscribe(
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct CliApplyParams {
#[arg(long)]
allow_model_mismatch: bool,
expr: String,
path: Option<PathBuf>,
}
@@ -225,7 +240,12 @@ async fn cli_apply(
context,
parent_method,
method,
params: CliApplyParams { expr, path },
params:
CliApplyParams {
allow_model_mismatch,
expr,
path,
},
..
}: HandlerArgs<CliContext, CliApplyParams>,
) -> Result<(), RpcError> {
@@ -240,7 +260,14 @@ async fn cli_apply(
&expr,
)?;
Ok::<_, Error>((
let value = if allow_model_mismatch {
serde_json::from_value::<Value>(res.clone().into()).with_ctx(|_| {
(
crate::ErrorKind::Deserialization,
"result does not match database model",
)
})?
} else {
to_value(
&serde_json::from_value::<model::Database>(res.clone().into()).with_ctx(
|_| {
@@ -250,9 +277,9 @@ async fn cli_apply(
)
},
)?,
)?,
(),
))
)?
};
Ok::<_, Error>((value, ()))
})
.await?;
} else {
@@ -299,6 +326,7 @@ pub fn put<C: Context>() -> ParentHandler<C> {
"ui",
from_fn_async(ui)
.with_display_serializable()
.with_about("Add path and value to db")
.with_call_remote::<CliContext>(),
)
}

View File

@@ -4,7 +4,8 @@ use chrono::{DateTime, Utc};
use exver::VersionRange;
use imbl_value::InternedString;
use models::{
ActionId, DataUrl, HealthCheckId, HostId, PackageId, ServiceInterfaceId, VersionString,
ActionId, DataUrl, HealthCheckId, HostId, PackageId, ReplayId, ServiceInterfaceId,
VersionString,
};
use patch_db::json_ptr::JsonPointer;
use patch_db::HasModel;
@@ -17,8 +18,8 @@ use crate::net::service_interface::ServiceInterface;
use crate::prelude::*;
use crate::progress::FullProgress;
use crate::s9pk::manifest::Manifest;
use crate::status::Status;
use crate::util::serde::Pem;
use crate::status::MainStatus;
use crate::util::serde::{is_partial_of, Pem};
#[derive(Debug, Default, Deserialize, Serialize, TS)]
#[ts(export)]
@@ -310,9 +311,9 @@ pub struct InstallingInfo {
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "kebab-case")]
pub enum AllowedStatuses {
OnlyRunning, // onlyRunning
OnlyRunning,
OnlyStopped,
Any,
}
@@ -324,13 +325,28 @@ pub struct ActionMetadata {
pub name: String,
pub description: String,
pub warning: Option<String>,
#[ts(type = "any")]
pub input: Value,
pub disabled: bool,
#[serde(default)]
pub visibility: ActionVisibility,
pub allowed_statuses: AllowedStatuses,
pub has_input: bool,
pub group: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "kebab-case")]
#[serde(rename_all_fields = "camelCase")]
pub enum ActionVisibility {
Hidden,
Disabled(String),
Enabled,
}
impl Default for ActionVisibility {
fn default() -> Self {
Self::Enabled
}
}
#[derive(Debug, Deserialize, Serialize, HasModel, TS)]
#[serde(rename_all = "camelCase")]
#[model = "Model<Self>"]
@@ -338,7 +354,7 @@ pub struct ActionMetadata {
pub struct PackageDataEntry {
pub state_info: PackageState,
pub data_version: Option<VersionString>,
pub status: Status,
pub status: MainStatus,
#[ts(type = "string | null")]
pub registry: Option<Url>,
#[ts(type = "string")]
@@ -348,6 +364,8 @@ pub struct PackageDataEntry {
pub last_backup: Option<DateTime<Utc>>,
pub current_dependencies: CurrentDependencies,
pub actions: BTreeMap<ActionId, ActionMetadata>,
#[ts(as = "BTreeMap::<String, ActionRequestEntry>")]
pub requested_actions: BTreeMap<ReplayId, ActionRequestEntry>,
pub service_interfaces: BTreeMap<ServiceInterfaceId, ServiceInterface>,
pub hosts: Hosts,
#[ts(type = "string[]")]
@@ -384,8 +402,9 @@ impl Map for CurrentDependencies {
}
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[derive(Clone, Debug, Deserialize, Serialize, TS, HasModel)]
#[serde(rename_all = "camelCase")]
#[model = "Model<Self>"]
pub struct CurrentDependencyInfo {
#[ts(type = "string | null")]
pub title: Option<InternedString>,
@@ -394,11 +413,10 @@ pub struct CurrentDependencyInfo {
pub kind: CurrentDependencyKind,
#[ts(type = "string")]
pub version_range: VersionRange,
pub config_satisfied: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "kebab-case")]
#[serde(tag = "kind")]
pub enum CurrentDependencyKind {
Exists,
@@ -410,6 +428,81 @@ pub enum CurrentDependencyKind {
},
}
#[derive(Clone, Debug, Deserialize, Serialize, TS, HasModel)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[model = "Model<Self>"]
pub struct ActionRequestEntry {
pub request: ActionRequest,
pub active: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS, HasModel)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
#[model = "Model<Self>"]
pub struct ActionRequest {
pub package_id: PackageId,
pub action_id: ActionId,
#[serde(default)]
pub severity: ActionSeverity,
#[ts(optional)]
pub reason: Option<String>,
#[ts(optional)]
pub when: Option<ActionRequestTrigger>,
#[ts(optional)]
pub input: Option<ActionRequestInput>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export)]
pub enum ActionSeverity {
Critical,
Important,
}
impl Default for ActionSeverity {
fn default() -> Self {
ActionSeverity::Important
}
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct ActionRequestTrigger {
#[serde(default)]
pub once: bool,
pub condition: ActionRequestCondition,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(export)]
pub enum ActionRequestCondition {
InputNotMatches,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "kebab-case")]
#[serde(tag = "kind")]
pub enum ActionRequestInput {
Partial {
#[ts(type = "Record<string, unknown>")]
value: Value,
},
}
impl ActionRequestInput {
pub fn matches(&self, input: Option<&Value>) -> bool {
match self {
Self::Partial { value } => match input {
None => false,
Some(full) => is_partial_of(value, full),
},
}
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct InterfaceAddressMap(pub BTreeMap<HostId, InterfaceAddresses>);
impl Map for InterfaceAddressMap {

View File

@@ -31,6 +31,6 @@ pub struct Private {
pub package_stores: BTreeMap<PackageId, Value>,
}
fn generate_compat_key() -> Pem<ed25519_dalek::SigningKey> {
pub fn generate_compat_key() -> Pem<ed25519_dalek::SigningKey> {
Pem(ed25519_dalek::SigningKey::generate(&mut rand::thread_rng()))
}

View File

@@ -22,6 +22,7 @@ use crate::prelude::*;
use crate::progress::FullProgress;
use crate::system::SmtpValue;
use crate::util::cpupower::Governor;
use crate::util::lshw::LshwDevice;
use crate::version::{Current, VersionT};
use crate::{ARCH, PLATFORM};
@@ -43,16 +44,18 @@ impl Public {
arch: get_arch(),
platform: get_platform(),
id: account.server_id.clone(),
version: Current::new().semver(),
version: Current::default().semver(),
hostname: account.hostname.no_dot_host_name(),
last_backup: None,
eos_version_compat: Current::new().compat().clone(),
package_version_compat: Current::default().compat().clone(),
post_init_migration_todos: BTreeSet::new(),
lan_address,
onion_address: account.tor_key.public().get_onion_address(),
tor_address: format!("https://{}", account.tor_key.public().get_onion_address())
.parse()
.unwrap(),
ip_info: BTreeMap::new(),
acme: None,
status_info: ServerStatus {
backup_progress: None,
updated: false,
@@ -77,6 +80,8 @@ impl Public {
zram: true,
governor: None,
smtp: None,
ram: 0,
devices: Vec::new(),
},
package_data: AllPackageData::default(),
ui: serde_json::from_str(include_str!(concat!(
@@ -112,11 +117,13 @@ pub struct ServerInfo {
pub hostname: InternedString,
#[ts(type = "string")]
pub version: Version,
#[ts(type = "string")]
pub package_version_compat: VersionRange,
#[ts(type = "string[]")]
pub post_init_migration_todos: BTreeSet<Version>,
#[ts(type = "string | null")]
pub last_backup: Option<DateTime<Utc>>,
#[ts(type = "string")]
pub eos_version_compat: VersionRange,
#[ts(type = "string")]
pub lan_address: Url,
#[ts(type = "string")]
pub onion_address: OnionAddressV3,
@@ -124,6 +131,7 @@ pub struct ServerInfo {
#[ts(type = "string")]
pub tor_address: Url,
pub ip_info: BTreeMap<String, IpInfo>,
pub acme: Option<AcmeSettings>,
#[serde(default)]
pub status_info: ServerStatus,
pub wifi: WifiInfo,
@@ -138,6 +146,9 @@ pub struct ServerInfo {
pub zram: bool,
pub governor: Option<Governor>,
pub smtp: Option<SmtpValue>,
#[ts(type = "number")]
pub ram: u64,
pub devices: Vec<LshwDevice>,
}
#[derive(Debug, Deserialize, Serialize, HasModel, TS)]
@@ -165,6 +176,20 @@ impl IpInfo {
}
}
#[derive(Debug, Deserialize, Serialize, HasModel, TS)]
#[serde(rename_all = "camelCase")]
#[model = "Model<Self>"]
#[ts(export)]
pub struct AcmeSettings {
#[ts(type = "string")]
pub provider: Url,
/// email addresses for letsencrypt
pub contact: Vec<String>,
#[ts(type = "string[]")]
/// domains to get letsencrypt certs for
pub domains: BTreeSet<InternedString>,
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
#[model = "Model<Self>"]
#[ts(export)]

View File

@@ -1,28 +1,14 @@
use std::collections::BTreeMap;
use std::time::Duration;
use clap::Parser;
use imbl_value::InternedString;
use models::PackageId;
use patch_db::json_patch::merge;
use rpc_toolkit::{from_fn_async, Context, Empty, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use tracing::instrument;
use ts_rs::TS;
use crate::config::{Config, ConfigSpec, ConfigureContext};
use crate::context::{CliContext, RpcContext};
use crate::db::model::package::CurrentDependencies;
use crate::prelude::*;
use crate::rpc_continuations::Guid;
use crate::util::serde::HandlerExtSerde;
use crate::util::PathOrUrl;
use crate::Error;
pub fn dependency<C: Context>() -> ParentHandler<C> {
ParentHandler::new().subcommand("configure", configure::<C>())
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, HasModel, TS)]
#[model = "Model<Self>"]
#[ts(export)]
@@ -56,129 +42,3 @@ pub struct DependencyMetadata {
#[ts(type = "string")]
pub title: InternedString,
}
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct ConfigureParams {
dependent_id: PackageId,
dependency_id: PackageId,
}
pub fn configure<C: Context>() -> ParentHandler<C, ConfigureParams> {
ParentHandler::new()
.root_handler(
from_fn_async(configure_impl)
.with_inherited(|params, _| params)
.no_display()
.with_call_remote::<CliContext>(),
)
.subcommand(
"dry",
from_fn_async(configure_dry)
.with_inherited(|params, _| params)
.with_display_serializable()
.with_call_remote::<CliContext>(),
)
}
pub async fn configure_impl(
ctx: RpcContext,
_: Empty,
ConfigureParams {
dependent_id,
dependency_id,
}: ConfigureParams,
) -> Result<(), Error> {
let ConfigDryRes {
old_config: _,
new_config,
spec: _,
} = configure_logic(ctx.clone(), (dependent_id, dependency_id.clone())).await?;
let configure_context = ConfigureContext {
timeout: Some(Duration::from_secs(3).into()),
config: Some(new_config),
};
ctx.services
.get(&dependency_id)
.await
.as_ref()
.ok_or_else(|| {
Error::new(
eyre!("There is no manager running for {dependency_id}"),
ErrorKind::Unknown,
)
})?
.configure(Guid::new(), configure_context)
.await?;
Ok(())
}
pub async fn configure_dry(
ctx: RpcContext,
_: Empty,
ConfigureParams {
dependent_id,
dependency_id,
}: ConfigureParams,
) -> Result<ConfigDryRes, Error> {
configure_logic(ctx.clone(), (dependent_id, dependency_id.clone())).await
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigDryRes {
pub old_config: Config,
pub new_config: Config,
pub spec: ConfigSpec,
}
pub async fn configure_logic(
ctx: RpcContext,
(dependent_id, dependency_id): (PackageId, PackageId),
) -> Result<ConfigDryRes, Error> {
let procedure_id = Guid::new();
let dependency_guard = ctx.services.get(&dependency_id).await;
let dependency = dependency_guard.as_ref().or_not_found(&dependency_id)?;
let dependent_guard = ctx.services.get(&dependent_id).await;
let dependent = dependent_guard.as_ref().or_not_found(&dependent_id)?;
let config_res = dependency.get_config(procedure_id.clone()).await?;
let diff = Value::Object(
dependent
.dependency_config(procedure_id, dependency_id, config_res.config.clone())
.await?
.unwrap_or_default(),
);
let mut new_config = Value::Object(config_res.config.clone().unwrap_or_default());
merge(&mut new_config, &diff);
Ok(ConfigDryRes {
old_config: config_res.config.unwrap_or_default(),
new_config: new_config.as_object().cloned().unwrap_or_default(),
spec: config_res.spec,
})
}
#[instrument(skip_all)]
pub async fn compute_dependency_config_errs(
ctx: &RpcContext,
id: &PackageId,
current_dependencies: &mut CurrentDependencies,
) -> Result<(), Error> {
let procedure_id = Guid::new();
let service_guard = ctx.services.get(id).await;
let service = service_guard.as_ref().or_not_found(id)?;
for (dep_id, dep_info) in current_dependencies.0.iter_mut() {
// check if config passes dependency check
let Some(dependency) = &*ctx.services.get(dep_id).await else {
continue;
};
let dep_config = dependency.get_config(procedure_id.clone()).await?.config;
dep_info.config_satisfied = service
.dependency_config(procedure_id.clone(), dep_id.clone(), dep_config)
.await?
.is_none();
}
Ok(())
}

View File

@@ -9,35 +9,53 @@ use rpc_toolkit::{
use crate::context::{CliContext, DiagnosticContext, RpcContext};
use crate::init::SYSTEM_REBUILD_PATH;
use crate::shutdown::Shutdown;
use crate::util::io::delete_file;
use crate::Error;
pub fn diagnostic<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("error", from_fn(error).with_call_remote::<CliContext>())
.subcommand("logs", crate::system::logs::<DiagnosticContext>())
.subcommand(
"error",
from_fn(error)
.with_about("Display diagnostic error")
.with_call_remote::<CliContext>(),
)
.subcommand(
"logs",
from_fn_async(crate::logs::cli_logs::<DiagnosticContext, Empty>).no_display(),
crate::system::logs::<DiagnosticContext>().with_about("Display OS logs"),
)
.subcommand(
"logs",
from_fn_async(crate::logs::cli_logs::<DiagnosticContext, Empty>)
.no_display()
.with_about("Display OS logs"),
)
.subcommand(
"kernel-logs",
crate::system::kernel_logs::<DiagnosticContext>(),
crate::system::kernel_logs::<DiagnosticContext>().with_about("Display kernel logs"),
)
.subcommand(
"kernel-logs",
from_fn_async(crate::logs::cli_logs::<DiagnosticContext, Empty>).no_display(),
from_fn_async(crate::logs::cli_logs::<DiagnosticContext, Empty>)
.no_display()
.with_about("Display kernal logs"),
)
.subcommand(
"restart",
from_fn(restart)
.no_display()
.with_about("Restart the server")
.with_call_remote::<CliContext>(),
)
.subcommand("disk", disk::<C>())
.subcommand(
"disk",
disk::<C>().with_about("Command to remove disk from filesystem"),
)
.subcommand(
"rebuild",
from_fn_async(rebuild)
.no_display()
.with_about("Teardown and rebuild service containers")
.with_call_remote::<CliContext>(),
)
}
@@ -72,14 +90,13 @@ pub fn disk<C: Context>() -> ParentHandler<C> {
CallRemoteHandler::<CliContext, _, _>::new(
from_fn_async(forget_disk::<RpcContext>).no_display(),
)
.no_display(),
.no_display()
.with_about("Remove disk from filesystem"),
)
}
pub async fn forget_disk<C: Context>(_: C) -> Result<(), Error> {
let disk_guid = Path::new("/media/startos/config/disk.guid");
if tokio::fs::metadata(disk_guid).await.is_ok() {
tokio::fs::remove_file(disk_guid).await?;
}
delete_file("/media/startos/config/overlay/etc/hostname").await?;
delete_file("/media/startos/config/disk.guid").await?;
Ok(())
}

View File

@@ -51,13 +51,16 @@ pub fn disk<C: Context>() -> ParentHandler<C> {
.with_custom_display_fn(|handle, result| {
Ok(display_disk_info(handle.params, result))
})
.with_about("List disk info")
.with_call_remote::<CliContext>(),
)
.subcommand("repair", from_fn_async(|_: C| repair()).no_cli())
.subcommand(
"repair",
CallRemoteHandler::<CliContext, _, _>::new(
from_fn_async(|_: RpcContext| repair()).no_display(),
from_fn_async(|_: RpcContext| repair())
.no_display()
.with_about("Repair disk in the event of corruption"),
),
)
}

View File

@@ -219,10 +219,10 @@ impl<G: GenericMountGuard> Drop for BackupMountGuard<G> {
let second = self.backup_disk_mount_guard.take();
tokio::spawn(async move {
if let Some(guard) = first {
guard.unmount().await.unwrap();
guard.unmount().await.log_err();
}
if let Some(guard) = second {
guard.unmount().await.unwrap();
guard.unmount().await.log_err();
}
});
}

View File

@@ -151,12 +151,12 @@ impl<G: GenericMountGuard> Drop for OverlayGuard<G> {
let guard = self.inner_guard.take();
if lower.is_some() || upper.is_some() || guard.mounted {
tokio::spawn(async move {
guard.unmount(false).await.unwrap();
guard.unmount(false).await.log_err();
if let Some(lower) = lower {
lower.unmount().await.unwrap();
lower.unmount().await.log_err();
}
if let Some(upper) = upper {
upper.delete().await.unwrap();
upper.delete().await.log_err();
}
});
}

View File

@@ -96,7 +96,7 @@ impl Drop for MountGuard {
fn drop(&mut self) {
if self.mounted {
let mountpoint = std::mem::take(&mut self.mountpoint);
tokio::spawn(async move { unmount(mountpoint, true).await.unwrap() });
tokio::spawn(async move { unmount(mountpoint, true).await.log_err() });
}
}
}

View File

@@ -5,6 +5,16 @@ use tracing::instrument;
use crate::util::Invoke;
use crate::Error;
pub async fn is_mountpoint(path: impl AsRef<Path>) -> Result<bool, Error> {
let is_mountpoint = tokio::process::Command::new("mountpoint")
.arg(path.as_ref())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await?;
Ok(is_mountpoint.success())
}
#[instrument(skip_all)]
pub async fn bind<P0: AsRef<Path>, P1: AsRef<Path>>(
src: P0,
@@ -16,13 +26,7 @@ pub async fn bind<P0: AsRef<Path>, P1: AsRef<Path>>(
src.as_ref().display(),
dst.as_ref().display()
);
let is_mountpoint = tokio::process::Command::new("mountpoint")
.arg(dst.as_ref())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await?;
if is_mountpoint.success() {
if is_mountpoint(&dst).await? {
unmount(dst.as_ref(), true).await?;
}
tokio::fs::create_dir_all(&src).await?;

View File

@@ -32,7 +32,9 @@ use crate::progress::{
use crate::rpc_continuations::{Guid, RpcContinuation};
use crate::s9pk::v2::pack::{CONTAINER_DATADIR, CONTAINER_TOOL};
use crate::ssh::SSH_AUTHORIZED_KEYS_FILE;
use crate::system::get_mem_info;
use crate::util::io::{create_file, IOHook};
use crate::util::lshw::lshw;
use crate::util::net::WebSocketExt;
use crate::util::{cpupower, Invoke};
use crate::Error;
@@ -323,7 +325,9 @@ pub async fn init(
local_auth.complete();
load_database.start();
let db = TypedPatchDb::<Database>::load_unchecked(cfg.db().await?);
let db = cfg.db().await?;
crate::version::Current::default().pre_init(&db).await?;
let db = TypedPatchDb::<Database>::load_unchecked(db);
let peek = db.peek().await;
load_database.complete();
tracing::info!("Opened PatchDB");
@@ -506,6 +510,8 @@ pub async fn init(
update_server_info.start();
server_info.ip_info = crate::net::dhcp::init_ips().await?;
server_info.ram = get_mem_info().await?.total.0 as u64 * 1024 * 1024;
server_info.devices = lshw().await?;
server_info.status_info = ServerStatus {
updated: false,
update_progress: None,
@@ -528,8 +534,6 @@ pub async fn init(
.await?;
launch_service_network.complete();
crate::version::init(&db, run_migrations).await?;
validate_db.start();
db.mutate(|d| {
let model = d.de()?;
@@ -549,18 +553,33 @@ pub async fn init(
pub fn init_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("logs", crate::system::logs::<InitContext>())
.subcommand(
"logs",
from_fn_async(crate::logs::cli_logs::<InitContext, Empty>).no_display(),
crate::system::logs::<InitContext>().with_about("Disply OS logs"),
)
.subcommand(
"logs",
from_fn_async(crate::logs::cli_logs::<InitContext, Empty>)
.no_display()
.with_about("Display OS logs"),
)
.subcommand("kernel-logs", crate::system::kernel_logs::<InitContext>())
.subcommand(
"kernel-logs",
from_fn_async(crate::logs::cli_logs::<InitContext, Empty>).no_display(),
crate::system::kernel_logs::<InitContext>().with_about("Display kernel logs"),
)
.subcommand(
"kernel-logs",
from_fn_async(crate::logs::cli_logs::<InitContext, Empty>)
.no_display()
.with_about("Display kernel logs"),
)
.subcommand("subscribe", from_fn_async(init_progress).no_cli())
.subcommand("subscribe", from_fn_async(cli_init_progress).no_display())
.subcommand(
"subscribe",
from_fn_async(cli_init_progress)
.no_display()
.with_about("Get initialization progress"),
)
}
#[derive(Debug, Deserialize, Serialize, TS)]

View File

@@ -9,7 +9,7 @@ use exver::VersionRange;
use futures::{AsyncWriteExt, StreamExt};
use imbl_value::{json, InternedString};
use itertools::Itertools;
use models::VersionString;
use models::{FromStrParser, VersionString};
use reqwest::header::{HeaderMap, CONTENT_LENGTH};
use reqwest::Url;
use rpc_toolkit::yajrc::{GenericRpcMethod, RpcError};
@@ -17,6 +17,7 @@ use rpc_toolkit::HandlerArgs;
use rustyline_async::ReadlineEvent;
use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use tracing::instrument;
use ts_rs::TS;
@@ -29,7 +30,6 @@ use crate::registry::package::get::GetPackageResponse;
use crate::rpc_continuations::{Guid, RpcContinuation};
use crate::s9pk::manifest::PackageId;
use crate::upload::upload;
use crate::util::clap::FromStrParser;
use crate::util::io::open_file;
use crate::util::net::WebSocketExt;
use crate::util::Never;
@@ -172,7 +172,7 @@ pub async fn install(
pub struct SideloadParams {
#[ts(skip)]
#[serde(rename = "__auth_session")]
session: InternedString,
session: Option<InternedString>,
}
#[derive(Deserialize, Serialize, TS)]
@@ -188,7 +188,7 @@ pub async fn sideload(
SideloadParams { session }: SideloadParams,
) -> Result<SideloadResponse, Error> {
let (upload, file) = upload(&ctx, session.clone()).await?;
let (err_send, err_recv) = oneshot::channel();
let (err_send, err_recv) = oneshot::channel::<Error>();
let progress = Guid::new();
let progress_tracker = FullProgressTracker::new();
let mut progress_listener = progress_tracker.stream(Some(Duration::from_millis(200)));
@@ -202,12 +202,14 @@ pub async fn sideload(
use axum::extract::ws::Message;
async move {
if let Err(e) = async {
type RpcResponse = rpc_toolkit::yajrc::RpcResponse::<GenericRpcMethod<&'static str, (), FullProgress>>;
type RpcResponse = rpc_toolkit::yajrc::RpcResponse<
GenericRpcMethod<&'static str, (), FullProgress>,
>;
tokio::select! {
res = async {
while let Some(progress) = progress_listener.next().await {
ws.send(Message::Text(
serde_json::to_string(&RpcResponse::from_result::<RpcError>(Ok(progress)))
serde_json::to_string(&progress)
.with_kind(ErrorKind::Serialization)?,
))
.await
@@ -217,12 +219,8 @@ pub async fn sideload(
} => res?,
err = err_recv => {
if let Ok(e) = err {
ws.send(Message::Text(
serde_json::to_string(&RpcResponse::from_result::<RpcError>(Err(e)))
.with_kind(ErrorKind::Serialization)?,
))
.await
.with_kind(ErrorKind::Network)?;
ws.close_result(Err::<&str, _>(e.clone_output())).await?;
return Err(e)
}
}
}
@@ -260,7 +258,7 @@ pub async fn sideload(
}
.await
{
let _ = err_send.send(RpcError::from(e.clone_output()));
let _ = err_send.send(e.clone_output());
tracing::error!("Error sideloading package: {e}");
tracing::debug!("{e:?}");
}
@@ -407,19 +405,21 @@ pub async fn cli_install(
let mut progress = FullProgress::new();
type RpcResponse = rpc_toolkit::yajrc::RpcResponse<
GenericRpcMethod<&'static str, (), FullProgress>,
>;
loop {
tokio::select! {
msg = ws.next() => {
if let Some(msg) = msg {
if let Message::Text(t) = msg.with_kind(ErrorKind::Network)? {
progress =
serde_json::from_str::<RpcResponse>(&t)
.with_kind(ErrorKind::Deserialization)?.result?;
bar.update(&progress);
match msg.with_kind(ErrorKind::Network)? {
Message::Text(t) => {
progress =
serde_json::from_str::<FullProgress>(&t)
.with_kind(ErrorKind::Deserialization)?;
bar.update(&progress);
}
Message::Close(Some(c)) if c.code != CloseCode::Normal => {
return Err(Error::new(eyre!("{}", c.reason), ErrorKind::Network))
}
_ => (),
}
} else {
break;

View File

@@ -29,7 +29,6 @@ pub mod action;
pub mod auth;
pub mod backup;
pub mod bins;
pub mod config;
pub mod context;
pub mod control;
pub mod db;
@@ -50,7 +49,6 @@ pub mod notifications;
pub mod os_install;
pub mod prelude;
pub mod progress;
pub mod properties;
pub mod registry;
pub mod rpc_continuations;
pub mod s9pk;
@@ -70,7 +68,6 @@ pub mod volume;
use std::time::SystemTime;
use clap::Parser;
pub use config::Config;
pub use error::{Error, ErrorKind, ResultExt};
use imbl_value::Value;
use rpc_toolkit::yajrc::RpcError;
@@ -116,29 +113,70 @@ impl std::fmt::Display for ApiState {
pub fn main_api<C: Context>() -> ParentHandler<C> {
let api = ParentHandler::new()
.subcommand::<C, _>("git-info", from_fn(version::git_info))
.subcommand(
"git-info",
from_fn(|_: C| version::git_info()).with_about("Display the githash of StartOS CLI"),
)
.subcommand(
"echo",
from_fn(echo::<RpcContext>)
.with_metadata("authenticated", Value::Bool(false))
.with_about("Echo a message")
.with_call_remote::<CliContext>(),
)
.subcommand(
"state",
from_fn(|_: RpcContext| Ok::<_, Error>(ApiState::Running))
.with_metadata("authenticated", Value::Bool(false))
.with_about("Display the API that is currently serving")
.with_call_remote::<CliContext>(),
)
.subcommand("server", server::<C>())
.subcommand("package", package::<C>())
.subcommand("net", net::net::<C>())
.subcommand("auth", auth::auth::<C>())
.subcommand("db", db::db::<C>())
.subcommand("ssh", ssh::ssh::<C>())
.subcommand("wifi", net::wifi::wifi::<C>())
.subcommand("disk", disk::disk::<C>())
.subcommand("notification", notifications::notification::<C>())
.subcommand("backup", backup::backup::<C>())
.subcommand(
"server",
server::<C>()
.with_about("Commands related to the server i.e. restart, update, and shutdown"),
)
.subcommand(
"package",
package::<C>().with_about("Commands related to packages"),
)
.subcommand(
"net",
net::net::<C>().with_about("Network commands related to tor and dhcp"),
)
.subcommand(
"auth",
auth::auth::<C>().with_about(
"Commands related to Authentication i.e. login, logout, reset-password",
),
)
.subcommand(
"db",
db::db::<C>().with_about("Commands to interact with the db i.e. dump, put, apply"),
)
.subcommand(
"ssh",
ssh::ssh::<C>()
.with_about("Commands for interacting with ssh keys i.e. add, delete, list"),
)
.subcommand(
"wifi",
net::wifi::wifi::<C>()
.with_about("Commands related to wifi networks i.e. add, connect, delete"),
)
.subcommand(
"disk",
disk::disk::<C>().with_about("Commands for listing disk info and repairing"),
)
.subcommand(
"notification",
notifications::notification::<C>().with_about("Create, delete, or list notifications"),
)
.subcommand(
"backup",
backup::backup::<C>()
.with_about("Commands related to backup creation and backup targets"),
)
.subcommand(
"registry",
CallRemoteHandler::<RpcContext, _, _, RegistryUrlParams>::new(
@@ -146,10 +184,20 @@ pub fn main_api<C: Context>() -> ParentHandler<C> {
)
.no_cli(),
)
.subcommand("s9pk", s9pk::rpc::s9pk())
.subcommand("util", util::rpc::util::<C>());
.subcommand(
"s9pk",
s9pk::rpc::s9pk().with_about("Commands for interacting with s9pk files"),
)
.subcommand(
"util",
util::rpc::util::<C>().with_about("Command for calculating the blake3 hash of a file"),
);
#[cfg(feature = "dev")]
let api = api.subcommand("lxc", lxc::dev::lxc::<C>());
let api = api.subcommand(
"lxc",
lxc::dev::lxc::<C>()
.with_about("Commands related to lxc containers i.e. create, list, remove, connect"),
);
api
}
@@ -162,42 +210,57 @@ pub fn server<C: Context>() -> ParentHandler<C> {
.with_custom_display_fn(|handle, result| {
Ok(system::display_time(handle.params, result))
})
.with_call_remote::<CliContext>(),
.with_about("Display current time and server uptime")
.with_call_remote::<CliContext>()
)
.subcommand(
"experimental",
system::experimental::<C>()
.with_about("Commands related to configuring experimental options such as zram and cpu governor"),
)
.subcommand("experimental", system::experimental::<C>())
.subcommand("logs", system::logs::<RpcContext>())
.subcommand(
"logs",
from_fn_async(logs::cli_logs::<RpcContext, Empty>).no_display(),
system::logs::<RpcContext>().with_about("Display OS logs"),
)
.subcommand(
"logs",
from_fn_async(logs::cli_logs::<RpcContext, Empty>).no_display().with_about("Display OS logs"),
)
.subcommand("kernel-logs", system::kernel_logs::<RpcContext>())
.subcommand(
"kernel-logs",
from_fn_async(logs::cli_logs::<RpcContext, Empty>).no_display(),
system::kernel_logs::<RpcContext>().with_about("Display Kernel logs"),
)
.subcommand(
"kernel-logs",
from_fn_async(logs::cli_logs::<RpcContext, Empty>).no_display().with_about("Display Kernel logs"),
)
.subcommand(
"metrics",
from_fn_async(system::metrics)
.with_display_serializable()
.with_call_remote::<CliContext>(),
.with_about("Display information about the server i.e. temperature, RAM, CPU, and disk usage")
.with_call_remote::<CliContext>()
)
.subcommand(
"shutdown",
from_fn_async(shutdown::shutdown)
.no_display()
.with_call_remote::<CliContext>(),
.with_about("Shutdown the server")
.with_call_remote::<CliContext>()
)
.subcommand(
"restart",
from_fn_async(shutdown::restart)
.no_display()
.with_call_remote::<CliContext>(),
.with_about("Restart the server")
.with_call_remote::<CliContext>()
)
.subcommand(
"rebuild",
from_fn_async(shutdown::rebuild)
.no_display()
.with_call_remote::<CliContext>(),
.with_about("Teardown and rebuild service containers")
.with_call_remote::<CliContext>()
)
.subcommand(
"update",
@@ -207,7 +270,7 @@ pub fn server<C: Context>() -> ParentHandler<C> {
)
.subcommand(
"update",
from_fn_async(update::cli_update_system).no_display(),
from_fn_async(update::cli_update_system).no_display().with_about("Check a given registry for StartOS updates and update if available"),
)
.subcommand(
"update-firmware",
@@ -222,19 +285,22 @@ pub fn server<C: Context>() -> ParentHandler<C> {
.with_custom_display_fn(|_handle, result| {
Ok(firmware::display_firmware_update_result(result))
})
.with_call_remote::<CliContext>(),
.with_about("Update the mainboard's firmware to the latest firmware available in this version of StartOS if available. Note: This command does not reach out to the Internet")
.with_call_remote::<CliContext>()
)
.subcommand(
"set-smtp",
from_fn_async(system::set_system_smtp)
.no_display()
.with_call_remote::<CliContext>(),
.with_about("Set system smtp server and credentials")
.with_call_remote::<CliContext>()
)
.subcommand(
"clear-smtp",
from_fn_async(system::clear_system_smtp)
.no_display()
.with_call_remote::<CliContext>(),
.with_about("Remove system smtp server and credentials")
.with_call_remote::<CliContext>()
)
}
@@ -242,12 +308,7 @@ pub fn package<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand(
"action",
from_fn_async(action::action)
.with_display_serializable()
.with_custom_display_fn(|handle, result| {
Ok(action::display_action_result(handle.params, result))
})
.with_call_remote::<CliContext>(),
action::action_api::<C>().with_about("Commands to get action input or run an action"),
)
.subcommand(
"install",
@@ -261,32 +322,40 @@ pub fn package<C: Context>() -> ParentHandler<C> {
.with_metadata("get_session", Value::Bool(true))
.no_cli(),
)
.subcommand("install", from_fn_async(install::cli_install).no_display())
.subcommand(
"install",
from_fn_async(install::cli_install)
.no_display()
.with_about("Install a package from a marketplace or via sideloading"),
)
.subcommand(
"uninstall",
from_fn_async(install::uninstall)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_about("Remove a package")
.with_call_remote::<CliContext>(),
)
.subcommand(
"list",
from_fn_async(install::list)
.with_display_serializable()
.with_about("List installed packages")
.with_call_remote::<CliContext>(),
)
.subcommand(
"installed-version",
from_fn_async(install::installed_version)
.with_display_serializable()
.with_about("Display installed version for a PackageId")
.with_call_remote::<CliContext>(),
)
.subcommand("config", config::config::<C>())
.subcommand(
"start",
from_fn_async(control::start)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_about("Start a service")
.with_call_remote::<CliContext>(),
)
.subcommand(
@@ -294,6 +363,7 @@ pub fn package<C: Context>() -> ParentHandler<C> {
from_fn_async(control::stop)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_about("Stop a service")
.with_call_remote::<CliContext>(),
)
.subcommand(
@@ -301,100 +371,174 @@ pub fn package<C: Context>() -> ParentHandler<C> {
from_fn_async(control::restart)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_about("Restart a service")
.with_call_remote::<CliContext>(),
)
.subcommand(
"rebuild",
from_fn_async(service::rebuild)
.with_metadata("sync_db", Value::Bool(true))
.no_display()
.with_about("Rebuild service container")
.with_call_remote::<CliContext>(),
)
.subcommand("logs", logs::package_logs())
.subcommand(
"logs",
from_fn_async(logs::cli_logs::<RpcContext, logs::PackageIdParams>).no_display(),
logs::package_logs().with_about("Display package logs"),
)
.subcommand(
"properties",
from_fn_async(properties::properties)
.with_custom_display_fn(|_handle, result| {
Ok(properties::display_properties(result))
})
.with_call_remote::<CliContext>(),
"logs",
from_fn_async(logs::cli_logs::<RpcContext, logs::PackageIdParams>)
.no_display()
.with_about("Display package logs"),
)
.subcommand(
"backup",
backup::package_backup::<C>()
.with_about("Commands for restoring package(s) from backup"),
)
.subcommand("dependency", dependencies::dependency::<C>())
.subcommand("backup", backup::package_backup::<C>())
.subcommand("connect", from_fn_async(service::connect_rpc).no_cli())
.subcommand(
"connect",
from_fn_async(service::connect_rpc_cli).no_display(),
from_fn_async(service::connect_rpc_cli)
.no_display()
.with_about("Connect to a LXC container"),
)
.subcommand(
"attach",
from_fn_async(service::attach)
.with_metadata("get_session", Value::Bool(true))
.with_about("Execute commands within a service container")
.no_cli(),
)
.subcommand("attach", from_fn_async(service::cli_attach).no_display())
.subcommand(
"host",
net::host::host::<C>().with_about("Manage network hosts for a package"),
)
}
pub fn diagnostic_api() -> ParentHandler<DiagnosticContext> {
ParentHandler::new()
.subcommand::<DiagnosticContext, _>(
.subcommand(
"git-info",
from_fn(version::git_info).with_metadata("authenticated", Value::Bool(false)),
from_fn(|_: DiagnosticContext| version::git_info())
.with_metadata("authenticated", Value::Bool(false))
.with_about("Display the githash of StartOS CLI"),
)
.subcommand(
"echo",
from_fn(echo::<DiagnosticContext>).with_call_remote::<CliContext>(),
from_fn(echo::<DiagnosticContext>)
.with_about("Echo a message")
.with_call_remote::<CliContext>(),
)
.subcommand(
"state",
from_fn(|_: DiagnosticContext| Ok::<_, Error>(ApiState::Error))
.with_metadata("authenticated", Value::Bool(false))
.with_about("Display the API that is currently serving")
.with_call_remote::<CliContext>(),
)
.subcommand("diagnostic", diagnostic::diagnostic::<DiagnosticContext>())
.subcommand(
"diagnostic",
diagnostic::diagnostic::<DiagnosticContext>()
.with_about("Diagnostic commands i.e. logs, restart, rebuild"),
)
}
pub fn init_api() -> ParentHandler<InitContext> {
ParentHandler::new()
.subcommand::<InitContext, _>(
.subcommand(
"git-info",
from_fn(version::git_info).with_metadata("authenticated", Value::Bool(false)),
from_fn(|_: InitContext| version::git_info())
.with_metadata("authenticated", Value::Bool(false))
.with_about("Display the githash of StartOS CLI"),
)
.subcommand(
"echo",
from_fn(echo::<InitContext>).with_call_remote::<CliContext>(),
from_fn(echo::<InitContext>)
.with_about("Echo a message")
.with_call_remote::<CliContext>(),
)
.subcommand(
"state",
from_fn(|_: InitContext| Ok::<_, Error>(ApiState::Initializing))
.with_metadata("authenticated", Value::Bool(false))
.with_about("Display the API that is currently serving")
.with_call_remote::<CliContext>(),
)
.subcommand("init", init::init_api::<InitContext>())
.subcommand(
"init",
init::init_api::<InitContext>()
.with_about("Commands to get logs or initialization progress"),
)
}
pub fn setup_api() -> ParentHandler<SetupContext> {
ParentHandler::new()
.subcommand::<SetupContext, _>(
.subcommand(
"git-info",
from_fn(version::git_info).with_metadata("authenticated", Value::Bool(false)),
from_fn(|_: SetupContext| version::git_info())
.with_metadata("authenticated", Value::Bool(false))
.with_about("Display the githash of StartOS CLI"),
)
.subcommand(
"echo",
from_fn(echo::<SetupContext>).with_call_remote::<CliContext>(),
from_fn(echo::<SetupContext>)
.with_about("Echo a message")
.with_call_remote::<CliContext>(),
)
.subcommand("setup", setup::setup::<SetupContext>())
}
pub fn install_api() -> ParentHandler<InstallContext> {
ParentHandler::new()
.subcommand::<InstallContext, _>(
.subcommand(
"git-info",
from_fn(version::git_info).with_metadata("authenticated", Value::Bool(false)),
from_fn(|_: InstallContext| version::git_info())
.with_metadata("authenticated", Value::Bool(false))
.with_about("Display the githash of StartOS CLI"),
)
.subcommand(
"echo",
from_fn(echo::<InstallContext>).with_call_remote::<CliContext>(),
from_fn(echo::<InstallContext>)
.with_about("Echo a message")
.with_call_remote::<CliContext>(),
)
.subcommand(
"install",
os_install::install::<InstallContext>()
.with_about("Commands to list disk info, install StartOS, and reboot"),
)
.subcommand("install", os_install::install::<InstallContext>())
}
pub fn expanded_api() -> ParentHandler<CliContext> {
main_api()
.subcommand("init", from_fn_blocking(developer::init).no_display())
.subcommand("pubkey", from_fn_blocking(developer::pubkey))
.subcommand("diagnostic", diagnostic::diagnostic::<CliContext>())
.subcommand(
"init",
from_fn_blocking(developer::init)
.no_display()
.with_about("Create developer key if it doesn't exist"),
)
.subcommand(
"pubkey",
from_fn_blocking(developer::pubkey)
.with_about("Get public key for developer private key"),
)
.subcommand(
"diagnostic",
diagnostic::diagnostic::<CliContext>()
.with_about("Commands to display logs, restart the server, etc"),
)
.subcommand("setup", setup::setup::<CliContext>())
.subcommand("install", os_install::install::<CliContext>())
.subcommand("registry", registry::registry_api::<CliContext>())
.subcommand(
"install",
os_install::install::<CliContext>()
.with_about("Commands to list disk info, install StartOS, and reboot"),
)
.subcommand(
"registry",
registry::registry_api::<CliContext>().with_about("Commands related to the registry"),
)
}

View File

@@ -12,7 +12,7 @@ use color_eyre::eyre::eyre;
use futures::stream::BoxStream;
use futures::{Future, FutureExt, Stream, StreamExt, TryStreamExt};
use itertools::Itertools;
use models::PackageId;
use models::{FromStrParser, PackageId};
use rpc_toolkit::yajrc::RpcError;
use rpc_toolkit::{
from_fn_async, CallRemote, Context, Empty, HandlerArgs, HandlerExt, HandlerFor, ParentHandler,
@@ -30,7 +30,6 @@ use crate::error::ResultExt;
use crate::lxc::ContainerId;
use crate::prelude::*;
use crate::rpc_continuations::{Guid, RpcContinuation, RpcContinuations};
use crate::util::clap::FromStrParser;
use crate::util::serde::Reversible;
use crate::util::Invoke;
@@ -114,7 +113,7 @@ async fn ws_handler(
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LogResponse {
entries: Reversible<LogEntry>,
pub entries: Reversible<LogEntry>,
start_cursor: Option<String>,
end_cursor: Option<String>,
}
@@ -361,11 +360,7 @@ pub fn logs<
source: impl for<'a> LogSourceFn<'a, C, Extra>,
) -> ParentHandler<C, LogsParams<Extra>> {
ParentHandler::new()
.root_handler(
logs_nofollow::<C, Extra>(source.clone())
.with_inherited(|params, _| params)
.no_cli(),
)
.root_handler(logs_nofollow::<C, Extra>(source.clone()).no_cli())
.subcommand(
"follow",
logs_follow::<C, Extra>(source)
@@ -437,7 +432,7 @@ where
fn logs_nofollow<C, Extra>(
f: impl for<'a> LogSourceFn<'a, C, Extra>,
) -> impl HandlerFor<C, Params = Empty, InheritedParams = LogsParams<Extra>, Ok = LogResponse, Err = Error>
) -> impl HandlerFor<C, Params = LogsParams<Extra>, InheritedParams = Empty, Ok = LogResponse, Err = Error>
where
C: Context,
Extra: FromArgMatches + Args + Send + Sync + 'static,
@@ -445,7 +440,7 @@ where
from_fn_async(
move |HandlerArgs {
context,
inherited_params:
params:
LogsParams {
extra,
limit,
@@ -454,7 +449,7 @@ where
before,
},
..
}: HandlerArgs<C, Empty, LogsParams<Extra>>| {
}: HandlerArgs<C, LogsParams<Extra>>| {
let f = f.clone();
async move {
fetch_logs(
@@ -487,14 +482,18 @@ fn logs_follow<
context,
inherited_params:
LogsParams {
extra, limit, boot, ..
extra,
cursor,
limit,
boot,
..
},
..
}: HandlerArgs<C, Empty, LogsParams<Extra>>| {
let f = f.clone();
async move {
let src = f.call(&context, extra).await?;
follow_logs(context, src, limit, boot.map(String::from)).await
follow_logs(context, src, cursor, limit, boot.map(String::from)).await
}
},
)
@@ -525,7 +524,7 @@ pub fn package_logs() -> ParentHandler<RpcContext, LogsParams<PackageIdParams>>
pub async fn journalctl(
id: LogSource,
limit: usize,
limit: Option<usize>,
cursor: Option<&str>,
boot: Option<&str>,
before: bool,
@@ -533,11 +532,12 @@ pub async fn journalctl(
) -> Result<LogStream, Error> {
let mut cmd = gen_journalctl_command(&id);
cmd.arg(format!("--lines={}", limit));
if let Some(limit) = limit {
cmd.arg(format!("--lines={}", limit));
}
let cursor_formatted = format!("--after-cursor={}", cursor.unwrap_or(""));
if cursor.is_some() {
cmd.arg(&cursor_formatted);
if let Some(cursor) = cursor {
cmd.arg(&format!("--after-cursor={}", cursor));
if before {
cmd.arg("--reverse");
}
@@ -638,8 +638,15 @@ pub async fn fetch_logs(
before: bool,
) -> Result<LogResponse, Error> {
let limit = limit.unwrap_or(50);
let mut stream =
journalctl(id, limit, cursor.as_deref(), boot.as_deref(), before, false).await?;
let mut stream = journalctl(
id,
Some(limit),
cursor.as_deref(),
boot.as_deref(),
before,
false,
)
.await?;
let mut entries = Vec::with_capacity(limit);
let mut start_cursor = None;
@@ -682,11 +689,16 @@ pub async fn fetch_logs(
pub async fn follow_logs<Context: AsRef<RpcContinuations>>(
ctx: Context,
id: LogSource,
cursor: Option<String>,
limit: Option<usize>,
boot: Option<String>,
) -> Result<LogFollowResponse, Error> {
let limit = limit.unwrap_or(50);
let mut stream = journalctl(id, limit, None, boot.as_deref(), false, true).await?;
let limit = if cursor.is_some() {
None
} else {
Some(limit.unwrap_or(50))
};
let mut stream = journalctl(id, limit, cursor.as_deref(), boot.as_deref(), false, true).await?;
let mut start_cursor = None;
let mut first_entry = None;

View File

@@ -8,16 +8,21 @@ use rpc_toolkit::{
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::context::{CliContext, RpcContext};
use crate::lxc::{ContainerId, LxcConfig};
use crate::prelude::*;
use crate::rpc_continuations::Guid;
use crate::{
context::{CliContext, RpcContext},
service::ServiceStats,
};
pub fn lxc<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand(
"create",
from_fn_async(create).with_call_remote::<CliContext>(),
from_fn_async(create)
.with_about("Create lxc container")
.with_call_remote::<CliContext>(),
)
.subcommand(
"list",
@@ -31,16 +36,59 @@ pub fn lxc<C: Context>() -> ParentHandler<C> {
table.printstd();
Ok(())
})
.with_about("List lxc containers")
.with_call_remote::<CliContext>(),
)
.subcommand(
"stats",
from_fn_async(stats)
.with_custom_display_fn(|_, res| {
use prettytable::*;
let mut table = table!([
"Container ID",
"Name",
"Memory Usage",
"Memory Limit",
"Memory %"
]);
for ServiceStats {
container_id,
package_id,
memory_usage,
memory_limit,
} in res
{
table.add_row(row![
&*container_id,
&*package_id,
memory_usage,
memory_limit,
format!(
"{:.2}",
memory_usage.0 as f64 / memory_limit.0 as f64 * 100.0
)
]);
}
table.printstd();
Ok(())
})
.with_about("List information related to the lxc containers i.e. CPU, Memory, Disk")
.with_call_remote::<CliContext>(),
)
.subcommand(
"remove",
from_fn_async(remove)
.no_display()
.with_about("Remove lxc container")
.with_call_remote::<CliContext>(),
)
.subcommand("connect", from_fn_async(connect_rpc).no_cli())
.subcommand("connect", from_fn_async(connect_rpc_cli).no_display())
.subcommand(
"connect",
from_fn_async(connect_rpc_cli)
.no_display()
.with_about("Connect to a lxc container"),
)
}
pub async fn create(ctx: RpcContext) -> Result<ContainerId, Error> {
@@ -54,6 +102,22 @@ pub async fn list(ctx: RpcContext) -> Result<Vec<ContainerId>, Error> {
Ok(ctx.dev.lxc.lock().await.keys().cloned().collect())
}
pub async fn stats(ctx: RpcContext) -> Result<Vec<ServiceStats>, Error> {
let ids = ctx.db.peek().await.as_public().as_package_data().keys()?;
let guids: Vec<_> = ctx.dev.lxc.lock().await.keys().cloned().collect();
let mut stats = Vec::with_capacity(guids.len());
for id in ids {
let service: tokio::sync::OwnedRwLockReadGuard<Option<crate::service::ServiceRef>> =
ctx.services.get(&id).await;
let service_ref = service.as_ref().or_not_found(&id)?;
stats.push(service_ref.stats().await?);
}
Ok(stats)
}
#[derive(Deserialize, Serialize, Parser, TS)]
pub struct RemoveParams {
#[ts(type = "string")]

View File

@@ -1,13 +1,13 @@
use std::collections::BTreeSet;
use std::net::Ipv4Addr;
use std::path::Path;
use std::sync::{Arc, Weak};
use std::time::Duration;
use std::{collections::BTreeSet, ffi::OsString};
use clap::builder::ValueParserFactory;
use futures::{AsyncWriteExt, StreamExt};
use imbl_value::{InOMap, InternedString};
use models::InvalidId;
use models::{FromStrParser, InvalidId};
use rpc_toolkit::yajrc::RpcError;
use rpc_toolkit::{GenericRpcMethod, RpcRequest, RpcResponse};
use rustyline_async::{ReadlineEvent, SharedWriter};
@@ -28,12 +28,11 @@ use crate::disk::mount::guard::{GenericMountGuard, MountGuard, TmpMountGuard};
use crate::disk::mount::util::unmount;
use crate::prelude::*;
use crate::rpc_continuations::{Guid, RpcContinuation};
use crate::util::clap::FromStrParser;
use crate::util::io::open_file;
use crate::util::rpc_client::UnixRpcClient;
use crate::util::{new_guid, Invoke};
#[cfg(feature = "dev")]
// #[cfg(feature = "dev")]
pub mod dev;
const LXC_CONTAINER_DIR: &str = "/var/lib/lxc";
@@ -127,7 +126,8 @@ impl LxcManager {
Path::new(LXC_CONTAINER_DIR).join(container).join("rootfs"),
true,
)
.await?;
.await
.log_err();
if tokio_stream::wrappers::ReadDirStream::new(
tokio::fs::read_dir(&rootfs_path).await?,
)
@@ -287,6 +287,30 @@ impl LxcContainer {
self.rpc_bind.path()
}
pub async fn command(&self, commands: &[&str]) -> Result<String, Error> {
let mut cmd = Command::new("lxc-attach");
cmd.kill_on_drop(true);
let output = cmd
.arg(&**self.guid)
.arg("--")
.args(commands)
.output()
.await?;
if !output.status.success() {
return Err(Error::new(
eyre!(
"Command failed with exit code: {:?} \n Message: {:?}",
output.status.code(),
String::from_utf8(output.stderr)
),
ErrorKind::Docker,
));
}
Ok(String::from_utf8(output.stdout)?)
}
#[instrument(skip_all)]
pub async fn exit(mut self) -> Result<(), Error> {
Command::new("lxc-stop")
@@ -365,7 +389,7 @@ impl Drop for LxcContainer {
tracing::error!("Error reading logs from crashed container: {e}");
tracing::debug!("{e:?}")
}
rootfs.unmount(true).await.unwrap();
rootfs.unmount(true).await.log_err();
drop(guid);
if let Err(e) = manager.gc().await {
tracing::error!("Error cleaning up dangling LXC containers: {e}");

View File

@@ -49,7 +49,7 @@ impl HasLoggedOutSessions {
.map(|s| s.as_logout_session_id())
.collect();
for sid in &to_log_out {
ctx.open_authed_continuations.kill(sid)
ctx.open_authed_continuations.kill(&Some(sid.clone()))
}
ctx.ephemeral_sessions.mutate(|s| {
for sid in &to_log_out {

View File

@@ -0,0 +1,324 @@
use std::collections::{BTreeMap, BTreeSet};
use std::str::FromStr;
use clap::builder::ValueParserFactory;
use clap::Parser;
use imbl_value::InternedString;
use itertools::Itertools;
use models::{ErrorData, FromStrParser};
use openssl::pkey::{PKey, Private};
use openssl::x509::X509;
use rpc_toolkit::{from_fn_async, Context, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::context::{CliContext, RpcContext};
use crate::db::model::public::AcmeSettings;
use crate::db::model::Database;
use crate::prelude::*;
use crate::util::serde::{Pem, Pkcs8Doc};
#[derive(Debug, Default, Deserialize, Serialize, HasModel)]
#[model = "Model<Self>"]
pub struct AcmeCertStore {
pub accounts: BTreeMap<JsonKey<Vec<String>>, Pem<Pkcs8Doc>>,
pub certs: BTreeMap<Url, BTreeMap<JsonKey<BTreeSet<InternedString>>, AcmeCert>>,
}
impl AcmeCertStore {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct AcmeCert {
pub key: Pem<PKey<Private>>,
pub fullchain: Vec<Pem<X509>>,
}
pub struct AcmeCertCache<'a>(pub &'a TypedPatchDb<Database>);
#[async_trait::async_trait]
impl<'a> async_acme::cache::AcmeCache for AcmeCertCache<'a> {
type Error = ErrorData;
async fn read_account(&self, contacts: &[&str]) -> Result<Option<Vec<u8>>, Self::Error> {
let contacts = JsonKey::new(contacts.into_iter().map(|s| (*s).to_owned()).collect_vec());
let Some(account) = self
.0
.peek()
.await
.into_private()
.into_key_store()
.into_acme()
.into_accounts()
.into_idx(&contacts)
else {
return Ok(None);
};
Ok(Some(account.de()?.0.document.into_vec()))
}
async fn write_account(&self, contacts: &[&str], contents: &[u8]) -> Result<(), Self::Error> {
let contacts = JsonKey::new(contacts.into_iter().map(|s| (*s).to_owned()).collect_vec());
let key = Pkcs8Doc {
tag: "EC PRIVATE KEY".into(),
document: pkcs8::Document::try_from(contents).with_kind(ErrorKind::Pem)?,
};
self.0
.mutate(|db| {
db.as_private_mut()
.as_key_store_mut()
.as_acme_mut()
.as_accounts_mut()
.insert(&contacts, &Pem::new(key))
})
.await?;
Ok(())
}
async fn read_certificate(
&self,
domains: &[String],
directory_url: &str,
) -> Result<Option<(String, String)>, Self::Error> {
let domains = JsonKey::new(domains.into_iter().map(InternedString::intern).collect());
let directory_url = directory_url
.parse::<Url>()
.with_kind(ErrorKind::ParseUrl)?;
let Some(cert) = self
.0
.peek()
.await
.into_private()
.into_key_store()
.into_acme()
.into_certs()
.into_idx(&directory_url)
.and_then(|a| a.into_idx(&domains))
else {
return Ok(None);
};
let cert = cert.de()?;
Ok(Some((
String::from_utf8(
cert.key
.0
.private_key_to_pem_pkcs8()
.with_kind(ErrorKind::OpenSsl)?,
)
.with_kind(ErrorKind::Utf8)?,
cert.fullchain
.into_iter()
.map(|cert| {
String::from_utf8(cert.0.to_pem().with_kind(ErrorKind::OpenSsl)?)
.with_kind(ErrorKind::Utf8)
})
.collect::<Result<Vec<_>, _>>()?
.join("\n"),
)))
}
async fn write_certificate(
&self,
domains: &[String],
directory_url: &str,
key_pem: &str,
certificate_pem: &str,
) -> Result<(), Self::Error> {
tracing::info!("Saving new certificate for {domains:?}");
let domains = JsonKey::new(domains.into_iter().map(InternedString::intern).collect());
let directory_url = directory_url
.parse::<Url>()
.with_kind(ErrorKind::ParseUrl)?;
let cert = AcmeCert {
key: Pem(PKey::<Private>::private_key_from_pem(key_pem.as_bytes())
.with_kind(ErrorKind::OpenSsl)?),
fullchain: X509::stack_from_pem(certificate_pem.as_bytes())
.with_kind(ErrorKind::OpenSsl)?
.into_iter()
.map(Pem)
.collect(),
};
self.0
.mutate(|db| {
db.as_private_mut()
.as_key_store_mut()
.as_acme_mut()
.as_certs_mut()
.upsert(&directory_url, || Ok(BTreeMap::new()))?
.insert(&domains, &cert)
})
.await?;
Ok(())
}
}
pub fn acme<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand(
"init",
from_fn_async(init)
.no_display()
.with_about("Setup ACME certificate acquisition")
.with_call_remote::<CliContext>(),
)
.subcommand(
"domain",
domain::<C>()
.with_about("Add, remove, or view domains for which to acquire ACME certificates"),
)
}
#[derive(Clone, Deserialize, Serialize)]
pub struct AcmeProvider(pub Url);
impl FromStr for AcmeProvider {
type Err = <Url as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"letsencrypt" => async_acme::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY.parse(),
"letsencrypt-staging" => async_acme::acme::LETS_ENCRYPT_STAGING_DIRECTORY.parse(),
s => s.parse(),
}
.map(Self)
}
}
impl ValueParserFactory for AcmeProvider {
type Parser = FromStrParser<Self>;
fn value_parser() -> Self::Parser {
Self::Parser::new()
}
}
#[derive(Deserialize, Serialize, Parser)]
pub struct InitAcmeParams {
#[arg(long)]
pub provider: AcmeProvider,
#[arg(long)]
pub contact: Vec<String>,
}
pub async fn init(
ctx: RpcContext,
InitAcmeParams {
provider: AcmeProvider(provider),
contact,
}: InitAcmeParams,
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
.as_acme_mut()
.map_mutate(|acme| {
Ok(Some(AcmeSettings {
provider,
contact,
domains: acme.map(|acme| acme.domains).unwrap_or_default(),
}))
})
})
.await?;
Ok(())
}
pub fn domain<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand(
"add",
from_fn_async(add_domain)
.no_display()
.with_about("Add a domain for which to acquire ACME certificates")
.with_call_remote::<CliContext>(),
)
.subcommand(
"remove",
from_fn_async(remove_domain)
.no_display()
.with_about("Remove a domain for which to acquire ACME certificates")
.with_call_remote::<CliContext>(),
)
.subcommand(
"list",
from_fn_async(list_domains)
.with_custom_display_fn(|_, res| {
for domain in res {
println!("{domain}")
}
Ok(())
})
.with_about("List domains for which to acquire ACME certificates")
.with_call_remote::<CliContext>(),
)
}
#[derive(Deserialize, Serialize, Parser)]
pub struct DomainParams {
pub domain: InternedString,
}
pub async fn add_domain(
ctx: RpcContext,
DomainParams { domain }: DomainParams,
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_server_info_mut()
.as_acme_mut()
.transpose_mut()
.ok_or_else(|| {
Error::new(
eyre!("Please call `start-cli net acme init` before adding a domain"),
ErrorKind::InvalidRequest,
)
})?
.as_domains_mut()
.mutate(|domains| {
domains.insert(domain);
Ok(())
})
})
.await?;
Ok(())
}
pub async fn remove_domain(
ctx: RpcContext,
DomainParams { domain }: DomainParams,
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
if let Some(acme) = db
.as_public_mut()
.as_server_info_mut()
.as_acme_mut()
.transpose_mut()
{
acme.as_domains_mut().mutate(|domains| {
domains.remove(&domain);
Ok(())
})
} else {
Ok(())
}
})
.await?;
Ok(())
}
pub async fn list_domains(ctx: RpcContext) -> Result<BTreeSet<InternedString>, Error> {
if let Some(acme) = ctx
.db
.peek()
.await
.into_public()
.into_server_info()
.into_acme()
.transpose()
{
acme.into_domains().de()
} else {
Ok(BTreeSet::new())
}
}

View File

@@ -58,6 +58,7 @@ pub fn dhcp<C: Context>() -> ParentHandler<C> {
"update",
from_fn_async::<_, _, (), Error, (RpcContext, UpdateParams)>(update)
.no_display()
.with_about("Update IP assigned by dhcp")
.with_call_remote::<CliContext>(),
)
}

View File

@@ -98,16 +98,8 @@ impl RequestHandler for Resolver {
)
.await
}
a => {
if a != RecordType::AAAA {
tracing::warn!(
"Non A-Record requested for {}: {:?}",
query.name(),
query.query_type()
);
}
let mut res = Header::response_from_request(request.header());
res.set_response_code(ResponseCode::NXDomain);
_ => {
let res = Header::response_from_request(request.header());
response_handle
.send_response(
MessageResponseBuilder::from_message_request(&*request).build(

View File

@@ -1,7 +1,9 @@
use std::fmt;
use std::str::FromStr;
use clap::builder::ValueParserFactory;
use imbl_value::InternedString;
use models::FromStrParser;
use serde::{Deserialize, Serialize};
use torut::onion::OnionAddressV3;
use ts_rs::TS;
@@ -46,3 +48,10 @@ impl fmt::Display for HostAddress {
}
}
}
impl ValueParserFactory for HostAddress {
type Parser = FromStrParser<Self>;
fn value_parser() -> Self::Parser {
Self::Parser::new()
}
}

View File

@@ -1,3 +1,7 @@
use std::str::FromStr;
use clap::builder::ValueParserFactory;
use models::{FromStrParser, HostId};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
@@ -5,10 +9,37 @@ use crate::net::forward::AvailablePorts;
use crate::net::vhost::AlpnInfo;
use crate::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)]
#[ts(export)]
#[serde(rename_all = "camelCase")]
pub struct BindId {
pub id: HostId,
pub internal_port: u16,
}
impl ValueParserFactory for BindId {
type Parser = FromStrParser<Self>;
fn value_parser() -> Self::Parser {
FromStrParser::new()
}
}
impl FromStr for BindId {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (id, port) = s
.split_once(":")
.ok_or_else(|| Error::new(eyre!("expected <id>:<port>"), ErrorKind::ParseUrl))?;
Ok(Self {
id: id.parse()?,
internal_port: port.parse()?,
})
}
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct BindInfo {
pub enabled: bool,
pub options: BindOptions,
pub lan: LanInfo,
}
@@ -30,6 +61,7 @@ impl BindInfo {
assigned_ssl_port = Some(available_ports.alloc()?);
}
Ok(Self {
enabled: true,
options,
lan: LanInfo {
assigned_port,
@@ -69,7 +101,14 @@ impl BindInfo {
available_ports.free([port]);
}
}
Ok(Self { options, lan })
Ok(Self {
enabled: true,
options,
lan,
})
}
pub fn disable(&mut self) {
self.enabled = false;
}
}

View File

@@ -1,10 +1,13 @@
use std::collections::{BTreeMap, BTreeSet};
use clap::Parser;
use imbl_value::InternedString;
use models::{HostId, PackageId};
use rpc_toolkit::{from_fn_async, Context, Empty, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::context::{CliContext, RpcContext};
use crate::db::model::DatabaseModel;
use crate::net::forward::AvailablePorts;
use crate::net::host::address::HostAddress;
@@ -134,3 +137,163 @@ impl Model<Host> {
})
}
}
#[derive(Deserialize, Serialize, Parser)]
pub struct HostParams {
package: PackageId,
}
pub fn host<C: Context>() -> ParentHandler<C, HostParams> {
ParentHandler::<C, HostParams>::new()
.subcommand(
"list",
from_fn_async(list_hosts)
.with_inherited(|HostParams { package }, _| package)
.with_custom_display_fn(|_, ids| {
for id in ids {
println!("{id}")
}
Ok(())
})
.with_about("List host IDs available for this service"),
)
.subcommand(
"address",
address::<C>().with_inherited(|HostParams { package }, _| package),
)
}
pub async fn list_hosts(
ctx: RpcContext,
_: Empty,
package: PackageId,
) -> Result<Vec<HostId>, Error> {
ctx.db
.peek()
.await
.into_public()
.into_package_data()
.into_idx(&package)
.or_not_found(&package)?
.into_hosts()
.keys()
}
#[derive(Deserialize, Serialize, Parser)]
pub struct AddressApiParams {
host: HostId,
}
pub fn address<C: Context>() -> ParentHandler<C, AddressApiParams, PackageId> {
ParentHandler::<C, AddressApiParams, PackageId>::new()
.subcommand(
"add",
from_fn_async(add_address)
.with_inherited(|AddressApiParams { host }, package| (package, host))
.no_display()
.with_about("Add an address to this host")
.with_call_remote::<CliContext>(),
)
.subcommand(
"remove",
from_fn_async(remove_address)
.with_inherited(|AddressApiParams { host }, package| (package, host))
.no_display()
.with_about("Remove an address from this host")
.with_call_remote::<CliContext>(),
)
.subcommand(
"list",
from_fn_async(list_addresses)
.with_inherited(|AddressApiParams { host }, package| (package, host))
.with_custom_display_fn(|_, res| {
for address in res {
println!("{address}")
}
Ok(())
})
.with_about("List addresses for this host")
.with_call_remote::<CliContext>(),
)
}
#[derive(Deserialize, Serialize, Parser)]
pub struct AddressParams {
pub address: HostAddress,
}
pub async fn add_address(
ctx: RpcContext,
AddressParams { address }: AddressParams,
(package, host): (PackageId, HostId),
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
if let HostAddress::Onion { address } = address {
db.as_private()
.as_key_store()
.as_onion()
.get_key(&address)?;
}
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package)
.or_not_found(&package)?
.as_hosts_mut()
.as_idx_mut(&host)
.or_not_found(&host)?
.as_addresses_mut()
.mutate(|a| Ok(a.insert(address)))
})
.await?;
let service = ctx.services.get(&package).await;
let service_ref = service.as_ref().or_not_found(&package)?;
service_ref.update_host(host).await?;
Ok(())
}
pub async fn remove_address(
ctx: RpcContext,
AddressParams { address }: AddressParams,
(package, host): (PackageId, HostId),
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
db.as_public_mut()
.as_package_data_mut()
.as_idx_mut(&package)
.or_not_found(&package)?
.as_hosts_mut()
.as_idx_mut(&host)
.or_not_found(&host)?
.as_addresses_mut()
.mutate(|a| Ok(a.remove(&address)))
})
.await?;
let service = ctx.services.get(&package).await;
let service_ref = service.as_ref().or_not_found(&package)?;
service_ref.update_host(host).await?;
Ok(())
}
pub async fn list_addresses(
ctx: RpcContext,
_: Empty,
(package, host): (PackageId, HostId),
) -> Result<BTreeSet<HostAddress>, Error> {
ctx.db
.peek()
.await
.into_public()
.into_package_data()
.into_idx(&package)
.or_not_found(&package)?
.into_hosts()
.into_idx(&host)
.or_not_found(&host)?
.into_addresses()
.de()
}

View File

@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::account::AccountInfo;
use crate::net::acme::AcmeCertStore;
use crate::net::ssl::CertStore;
use crate::net::tor::OnionStore;
use crate::prelude::*;
@@ -10,13 +11,15 @@ use crate::prelude::*;
pub struct KeyStore {
pub onion: OnionStore,
pub local_certs: CertStore,
// pub letsencrypt_certs: BTreeMap<BTreeSet<InternedString>, CertData>
#[serde(default)]
pub acme: AcmeCertStore,
}
impl KeyStore {
pub fn new(account: &AccountInfo) -> Result<Self, Error> {
let mut res = Self {
onion: OnionStore::new(),
local_certs: CertStore::new(account)?,
acme: AcmeCertStore::new(),
};
res.onion.insert(account.tor_key.clone());
Ok(res)

View File

@@ -1,5 +1,6 @@
use rpc_toolkit::{Context, ParentHandler};
use rpc_toolkit::{Context, HandlerExt, ParentHandler};
pub mod acme;
pub mod dhcp;
pub mod dns;
pub mod forward;
@@ -20,6 +21,16 @@ pub const PACKAGE_CERT_PATH: &str = "/var/lib/embassy/ssl";
pub fn net<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("tor", tor::tor::<C>())
.subcommand("dhcp", dhcp::dhcp::<C>())
.subcommand(
"tor",
tor::tor::<C>().with_about("Tor commands such as list-services, logs, and reset"),
)
.subcommand(
"dhcp",
dhcp::dhcp::<C>().with_about("Command to update IP assigned from dhcp"),
)
.subcommand(
"acme",
acme::acme::<C>().with_about("Setup automatic clearnet certificate acquisition"),
)
}

View File

@@ -15,8 +15,8 @@ use crate::hostname::Hostname;
use crate::net::dns::DnsController;
use crate::net::forward::LanPortForwardController;
use crate::net::host::address::HostAddress;
use crate::net::host::binding::{AddSslOptions, BindOptions, LanInfo};
use crate::net::host::{host_for, Host, HostKind};
use crate::net::host::binding::{AddSslOptions, BindId, BindOptions, LanInfo};
use crate::net::host::{host_for, Host, HostKind, Hosts};
use crate::net::service_interface::{HostnameInfo, IpHostname, OnionHostname};
use crate::net::tor::TorController;
use crate::net::vhost::{AlpnInfo, VHostController};
@@ -154,14 +154,16 @@ impl NetController {
) -> Result<NetService, Error> {
let dns = self.dns.add(Some(package.clone()), ip).await?;
Ok(NetService {
let mut res = NetService {
shutdown: false,
id: package,
ip,
dns,
controller: Arc::downgrade(self),
binds: BTreeMap::new(),
})
};
res.clear_bindings(Default::default()).await?;
Ok(res)
}
}
@@ -221,35 +223,45 @@ impl NetService {
self.update(id, host).await
}
pub async fn clear_bindings(&mut self) -> Result<(), Error> {
let ctrl = self.net_controller()?;
pub async fn clear_bindings(&mut self, except: BTreeSet<BindId>) -> Result<(), Error> {
let pkg_id = &self.id;
let hosts = self
.net_controller()?
.db
.mutate(|db| {
let mut res = Hosts::default();
for (host_id, host) in db
.as_public_mut()
.as_package_data_mut()
.as_idx_mut(pkg_id)
.or_not_found(pkg_id)?
.as_hosts_mut()
.as_entries_mut()?
{
host.as_bindings_mut().mutate(|b| {
for (internal_port, info) in b {
if !except.contains(&BindId {
id: host_id.clone(),
internal_port: *internal_port,
}) {
info.disable();
}
}
Ok(())
})?;
res.0.insert(host_id, host.de()?);
}
Ok(res)
})
.await?;
let mut errors = ErrorCollection::new();
for (_, binds) in std::mem::take(&mut self.binds) {
for (_, (lan, _, hostnames, rc)) in binds.lan {
drop(rc);
if let Some(external) = lan.assigned_ssl_port {
for hostname in ctrl.server_hostnames.iter().cloned() {
ctrl.vhost.gc(hostname, external).await?;
}
for hostname in hostnames {
ctrl.vhost.gc(Some(hostname), external).await?;
}
}
if let Some(external) = lan.assigned_port {
ctrl.forward.gc(external).await?;
}
}
for (addr, (_, rcs)) in binds.tor {
drop(rcs);
errors.handle(ctrl.tor.gc(Some(addr), None).await);
}
for (id, host) in hosts.0 {
errors.handle(self.update(id, host).await);
}
std::mem::take(&mut self.dns);
errors.handle(ctrl.dns.gc(Some(self.id.clone()), self.ip).await);
errors.into_result()
}
async fn update(&mut self, id: HostId, host: Host) -> Result<(), Error> {
pub async fn update(&mut self, id: HostId, host: Host) -> Result<(), Error> {
let ctrl = self.net_controller()?;
let mut hostname_info = BTreeMap::new();
let binds = self.binds.entry(id.clone()).or_default();
@@ -261,6 +273,9 @@ impl NetService {
let ip_info = server_info.as_ip_info().de()?;
let hostname = server_info.as_hostname().de()?;
for (port, bind) in &host.bindings {
if !bind.enabled {
continue;
}
let old_lan_bind = binds.lan.remove(port);
let lan_bind = old_lan_bind
.as_ref()
@@ -315,16 +330,29 @@ impl NetService {
}
HostAddress::Domain { address } => {
if hostnames.insert(address.clone()) {
let address = Some(address.clone());
rcs.push(
ctrl.vhost
.add(
Some(address.clone()),
address.clone(),
external,
target,
connect_ssl.clone(),
)
.await?,
);
if ssl.preferred_external_port == 443 {
rcs.push(
ctrl.vhost
.add(
address.clone(),
5443,
target,
connect_ssl.clone(),
)
.await?,
);
}
}
}
}
@@ -348,11 +376,32 @@ impl NetService {
network_interface_id: interface.clone(),
public: false,
hostname: IpHostname::Local {
value: format!("{hostname}.local"),
value: InternedString::from_display(&{
let hostname = &hostname;
lazy_format!("{hostname}.local")
}),
port: new_lan_bind.0.assigned_port,
ssl_port: new_lan_bind.0.assigned_ssl_port,
},
});
for address in host.addresses() {
if let HostAddress::Domain { address } = address {
if let Some(ssl) = &new_lan_bind.1 {
if ssl.preferred_external_port == 443 {
bind_hostname_info.push(HostnameInfo::Ip {
network_interface_id: interface.clone(),
public: false,
hostname: IpHostname::Domain {
domain: address.clone(),
subdomain: None,
port: None,
ssl_port: Some(443),
},
});
}
}
}
}
if let Some(ipv4) = ip_info.ipv4 {
bind_hostname_info.push(HostnameInfo::Ip {
network_interface_id: interface.clone(),
@@ -395,7 +444,7 @@ impl NetService {
}
let mut removed = BTreeSet::new();
binds.lan.retain(|internal, (external, _, hostnames, _)| {
if host.bindings.contains_key(internal) {
if host.bindings.get(internal).map_or(false, |b| b.enabled) {
true
} else {
removed.insert((*external, std::mem::take(hostnames)));
@@ -424,6 +473,9 @@ impl NetService {
let mut tor_hostname_ports = BTreeMap::<u16, TorHostnamePorts>::new();
let mut tor_binds = OrdMap::<u16, SocketAddr>::new();
for (internal, info) in &host.bindings {
if !info.enabled {
continue;
}
tor_binds.insert(
info.options.preferred_external_port,
SocketAddr::from((self.ip, *internal)),
@@ -497,6 +549,7 @@ impl NetService {
ctrl.tor.gc(Some(addr.clone()), None).await?;
}
}
self.net_controller()?
.db
.mutate(|db| {
@@ -511,7 +564,7 @@ impl NetService {
pub async fn remove_all(mut self) -> Result<(), Error> {
self.shutdown = true;
if let Some(ctrl) = Weak::upgrade(&self.controller) {
self.clear_bindings().await?;
self.clear_bindings(Default::default()).await?;
drop(ctrl);
Ok(())
} else {
@@ -566,7 +619,7 @@ impl Drop for NetService {
binds: BTreeMap::new(),
},
);
tokio::spawn(async move { svc.remove_all().await.unwrap() });
tokio::spawn(async move { svc.remove_all().await.log_err() });
}
}
}

View File

@@ -47,13 +47,16 @@ pub enum IpHostname {
ssl_port: Option<u16>,
},
Local {
value: String,
#[ts(type = "string")]
value: InternedString,
port: Option<u16>,
ssl_port: Option<u16>,
},
Domain {
domain: String,
subdomain: Option<String>,
#[ts(type = "string")]
domain: InternedString,
#[ts(type = "string | null")]
subdomain: Option<InternedString>,
port: Option<u16>,
ssl_port: Option<u16>,
},

View File

@@ -84,7 +84,7 @@ pub fn rpc_router<C: Context + Clone + AsRef<RpcContinuations>>(
server: HttpServer<C>,
) -> Router {
Router::new()
.route("/rpc/*path", post(server))
.route("/rpc/*path", any(server))
.route(
"/ws/rpc/:guid",
get({

View File

@@ -26,7 +26,7 @@ use ts_rs::TS;
use crate::context::{CliContext, RpcContext};
use crate::logs::{journalctl, LogSource, LogsParams};
use crate::prelude::*;
use crate::util::serde::{display_serializable, HandlerExtSerde, WithIoFormat};
use crate::util::serde::{display_serializable, Base64, HandlerExtSerde, WithIoFormat};
use crate::util::Invoke;
pub const SYSTEMD_UNIT: &str = "tor@default";
@@ -59,7 +59,9 @@ impl Model<OnionStore> {
self.insert(&key.public().get_onion_address(), &key)
}
pub fn get_key(&self, address: &OnionAddressV3) -> Result<TorSecretKeyV3, Error> {
self.as_idx(address).or_not_found(address)?.de()
self.as_idx(address)
.or_not_found(lazy_format!("private key for {address}"))?
.de()
}
}
@@ -91,20 +93,102 @@ pub fn tor<C: Context>() -> ParentHandler<C> {
.with_custom_display_fn(|handle, result| {
Ok(display_services(handle.params, result))
})
.with_about("Display Tor V3 Onion Addresses")
.with_call_remote::<CliContext>(),
)
.subcommand("logs", logs())
.subcommand("logs", logs().with_about("Display Tor logs"))
.subcommand(
"logs",
from_fn_async(crate::logs::cli_logs::<RpcContext, Empty>).no_display(),
from_fn_async(crate::logs::cli_logs::<RpcContext, Empty>)
.no_display()
.with_about("Display Tor logs"),
)
.subcommand(
"reset",
from_fn_async(reset)
.no_display()
.with_about("Reset Tor daemon")
.with_call_remote::<CliContext>(),
)
.subcommand(
"key",
key::<C>().with_about("Manage the onion service key store"),
)
}
pub fn key<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand(
"generate",
from_fn_async(generate_key)
.with_about("Generate an onion service key and add it to the key store")
.with_call_remote::<CliContext>(),
)
.subcommand(
"add",
from_fn_async(add_key)
.with_about("Add an onion service key to the key store")
.with_call_remote::<CliContext>(),
)
.subcommand(
"list",
from_fn_async(list_keys)
.with_custom_display_fn(|_, res| {
for addr in res {
println!("{addr}");
}
Ok(())
})
.with_about("List onion services with keys in the key store")
.with_call_remote::<CliContext>(),
)
}
pub async fn generate_key(ctx: RpcContext) -> Result<OnionAddressV3, Error> {
ctx.db
.mutate(|db| {
Ok(db
.as_private_mut()
.as_key_store_mut()
.as_onion_mut()
.new_key()?
.public()
.get_onion_address())
})
.await
}
#[derive(Deserialize, Serialize, Parser)]
pub struct AddKeyParams {
pub key: Base64<[u8; 64]>,
}
pub async fn add_key(
ctx: RpcContext,
AddKeyParams { key }: AddKeyParams,
) -> Result<OnionAddressV3, Error> {
let key = TorSecretKeyV3::from(key.0);
ctx.db
.mutate(|db| {
db.as_private_mut()
.as_key_store_mut()
.as_onion_mut()
.insert_key(&key)
})
.await?;
Ok(key.public().get_onion_address())
}
pub async fn list_keys(ctx: RpcContext) -> Result<Vec<OnionAddressV3>, Error> {
ctx.db
.peek()
.await
.into_private()
.into_key_store()
.into_onion()
.keys()
}
#[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
@@ -307,7 +391,7 @@ async fn torctl(
let logs = journalctl(
LogSource::Unit(SYSTEMD_UNIT),
0,
Some(0),
None,
Some("0"),
false,

View File

@@ -4,6 +4,7 @@ use std::str::FromStr;
use std::sync::{Arc, Weak};
use std::time::Duration;
use async_acme::acme::ACME_TLS_ALPN_NAME;
use axum::body::Body;
use axum::extract::Request;
use axum::response::Response;
@@ -15,31 +16,47 @@ use models::ResultExt;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{Mutex, RwLock};
use tokio::sync::{watch, Mutex, RwLock};
use tokio_rustls::rustls::crypto::CryptoProvider;
use tokio_rustls::rustls::pki_types::{
CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName,
};
use tokio_rustls::rustls::server::Acceptor;
use tokio_rustls::rustls::server::{Acceptor, ResolvesServerCert};
use tokio_rustls::rustls::sign::CertifiedKey;
use tokio_rustls::rustls::{RootCertStore, ServerConfig};
use tokio_rustls::{LazyConfigAcceptor, TlsConnector};
use tokio_stream::wrappers::WatchStream;
use tokio_stream::StreamExt;
use tracing::instrument;
use ts_rs::TS;
use crate::db::model::Database;
use crate::net::acme::AcmeCertCache;
use crate::net::static_server::server_error;
use crate::prelude::*;
use crate::util::io::BackTrackingIO;
use crate::util::sync::SyncMutex;
use crate::util::serde::MaybeUtf8String;
#[derive(Debug)]
struct SingleCertResolver(Arc<CertifiedKey>);
impl ResolvesServerCert for SingleCertResolver {
fn resolve(&self, _: tokio_rustls::rustls::server::ClientHello) -> Option<Arc<CertifiedKey>> {
Some(self.0.clone())
}
}
// not allowed: <=1024, >=32768, 5355, 5432, 9050, 6010, 9051, 5353
pub struct VHostController {
crypto_provider: Arc<CryptoProvider>,
db: TypedPatchDb<Database>,
servers: Mutex<BTreeMap<u16, VHostServer>>,
}
impl VHostController {
pub fn new(db: TypedPatchDb<Database>) -> Self {
Self {
crypto_provider: Arc::new(tokio_rustls::rustls::crypto::ring::default_provider()),
db,
servers: Mutex::new(BTreeMap::new()),
}
@@ -56,7 +73,8 @@ impl VHostController {
let server = if let Some(server) = writable.remove(&external) {
server
} else {
VHostServer::new(external, self.db.clone()).await?
tracing::info!("Listening on {external}");
VHostServer::new(external, self.db.clone(), self.crypto_provider.clone()).await?
};
let rc = server
.add(
@@ -108,7 +126,11 @@ struct VHostServer {
}
impl VHostServer {
#[instrument(skip_all)]
async fn new(port: u16, db: TypedPatchDb<Database>) -> Result<Self, Error> {
async fn new(port: u16, db: TypedPatchDb<Database>, crypto_provider: Arc<CryptoProvider>) -> Result<Self, Error> {
let acme_tls_alpn_cache = Arc::new(SyncMutex::new(BTreeMap::<
InternedString,
watch::Receiver<Option<Arc<CertifiedKey>>>,
>::new()));
// check if port allowed
let listener = TcpListener::bind(SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), port))
.await
@@ -133,9 +155,11 @@ impl VHostServer {
let mut stream = BackTrackingIO::new(stream);
let mapping = mapping.clone();
let db = db.clone();
let acme_tls_alpn_cache = acme_tls_alpn_cache.clone();
let crypto_provider = crypto_provider.clone();
tokio::spawn(async move {
if let Err(e) = async {
let mid = match LazyConfigAcceptor::new(
let mid: tokio_rustls::StartHandshake<&mut BackTrackingIO<TcpStream>> = match LazyConfigAcceptor::new(
Acceptor::default(),
&mut stream,
)
@@ -206,38 +230,102 @@ impl VHostServer {
.map(|(target, _)| target.clone())
};
if let Some(target) = target {
let mut tcp_stream =
TcpStream::connect(target.addr).await?;
let hostnames = target_name
.into_iter()
.chain(
db.peek()
.await
.into_public()
.into_server_info()
.into_ip_info()
.into_entries()?
.into_iter()
.flat_map(|(_, ips)| [
ips.as_ipv4().de().map(|ip| ip.map(IpAddr::V4)),
ips.as_ipv6().de().map(|ip| ip.map(IpAddr::V6))
])
.filter_map(|a| a.transpose())
.map(|a| a.map(|ip| InternedString::from_display(&ip)))
.collect::<Result<Vec<_>, _>>()?,
)
.collect();
let key = db
.mutate(|v| {
v.as_private_mut()
.as_key_store_mut()
.as_local_certs_mut()
.cert_for(&hostnames)
})
.await?;
let cfg = ServerConfig::builder()
.with_no_client_auth();
let mut cfg =
let peek = db.peek().await;
let root = peek.as_private().as_key_store().as_local_certs().as_root_cert().de()?;
let mut cfg = match async {
if let Some(acme_settings) = peek.as_public().as_server_info().as_acme().de()? {
if let Some(domain) = target_name.as_ref().filter(|target_name| acme_settings.domains.contains(*target_name)) {
if mid
.client_hello()
.alpn()
.into_iter()
.flatten()
.any(|alpn| alpn == ACME_TLS_ALPN_NAME)
{
let cert = WatchStream::new(
acme_tls_alpn_cache.peek(|c| c.get(&**domain).cloned())
.ok_or_else(|| {
Error::new(
eyre!("No challenge recv available for {domain}"),
ErrorKind::OpenSsl
)
})?,
);
tracing::info!("Waiting for verification cert for {domain}");
let cert = cert
.filter(|c| c.is_some())
.next()
.await
.flatten()
.ok_or_else(|| {
Error::new(eyre!("No challenge available for {domain}"), ErrorKind::OpenSsl)
})?;
tracing::info!("Verification cert received for {domain}");
let mut cfg = ServerConfig::builder_with_provider(crypto_provider.clone())
.with_safe_default_protocol_versions()
.with_kind(crate::ErrorKind::OpenSsl)?
.with_no_client_auth()
.with_cert_resolver(Arc::new(SingleCertResolver(cert)));
cfg.alpn_protocols = vec![ACME_TLS_ALPN_NAME.to_vec()];
return Ok(Err(cfg));
} else {
let domains = [domain.to_string()];
let (send, recv) = watch::channel(None);
acme_tls_alpn_cache.mutate(|c| c.insert(domain.clone(), recv));
let cert =
async_acme::rustls_helper::order(
|_, cert| {
send.send_replace(Some(Arc::new(cert)));
Ok(())
},
acme_settings.provider.as_str(),
&domains,
Some(&AcmeCertCache(&db)),
&acme_settings.contact,
)
.await
.with_kind(ErrorKind::OpenSsl)?;
return Ok(Ok(
ServerConfig::builder_with_provider(crypto_provider.clone())
.with_safe_default_protocol_versions()
.with_kind(crate::ErrorKind::OpenSsl)?
.with_no_client_auth()
.with_cert_resolver(Arc::new(SingleCertResolver(Arc::new(cert))))
));
}
}
}
let hostnames = target_name
.into_iter()
.chain(
peek
.as_public()
.as_server_info()
.as_ip_info()
.as_entries()?
.into_iter()
.flat_map(|(_, ips)| [
ips.as_ipv4().de().map(|ip| ip.map(IpAddr::V4)),
ips.as_ipv6().de().map(|ip| ip.map(IpAddr::V6))
])
.filter_map(|a| a.transpose())
.map(|a| a.map(|ip| InternedString::from_display(&ip)))
.collect::<Result<Vec<_>, _>>()?,
)
.collect();
let key = db
.mutate(|v| {
v.as_private_mut()
.as_key_store_mut()
.as_local_certs_mut()
.cert_for(&hostnames)
})
.await?;
let cfg = ServerConfig::builder_with_provider(crypto_provider.clone())
.with_safe_default_protocol_versions()
.with_kind(crate::ErrorKind::OpenSsl)?
.with_no_client_auth();
if mid.client_hello().signature_schemes().contains(
&tokio_rustls::rustls::SignatureScheme::ED25519,
) {
@@ -275,16 +363,34 @@ impl VHostServer {
)),
)
}
.with_kind(crate::ErrorKind::OpenSsl)?;
.with_kind(crate::ErrorKind::OpenSsl)
.map(Ok)
}.await? {
Ok(a) => a,
Err(cfg) => {
tracing::info!("performing ACME auth challenge");
let mut accept = mid.into_stream(Arc::new(cfg));
let io = accept.get_mut().unwrap();
let buffered = io.stop_buffering();
io.write_all(&buffered).await?;
accept.await?;
tracing::info!("ACME auth challenge completed");
return Ok(());
}
};
let mut tcp_stream =
TcpStream::connect(target.addr).await?;
match target.connect_ssl {
Ok(()) => {
let mut client_cfg =
tokio_rustls::rustls::ClientConfig::builder()
tokio_rustls::rustls::ClientConfig::builder_with_provider(crypto_provider)
.with_safe_default_protocol_versions()
.with_kind(crate::ErrorKind::OpenSsl)?
.with_root_certificates({
let mut store = RootCertStore::empty();
store.add(
CertificateDer::from(
key.root.to_der()?,
root.to_der()?,
),
).with_kind(crate::ErrorKind::OpenSsl)?;
store

View File

@@ -7,7 +7,7 @@ use axum::extract::Request;
use axum::Router;
use axum_server::Handle;
use bytes::Bytes;
use futures::future::ready;
use futures::future::{ready, BoxFuture};
use futures::FutureExt;
use helpers::NonDetachingJoinHandle;
use tokio::sync::{oneshot, watch};
@@ -30,8 +30,39 @@ impl SwappableRouter {
}
}
#[derive(Clone)]
pub struct SwappableRouterService(watch::Receiver<Router>);
pub struct SwappableRouterService {
router: watch::Receiver<Router>,
changed: Option<BoxFuture<'static, ()>>,
}
impl SwappableRouterService {
fn router(&self) -> Router {
self.router.borrow().clone()
}
fn changed(&mut self, cx: &mut std::task::Context<'_>) -> Poll<()> {
let mut changed = if let Some(changed) = self.changed.take() {
changed
} else {
let mut router = self.router.clone();
async move {
router.changed().await;
}
.boxed()
};
if changed.poll_unpin(cx).is_ready() {
return Poll::Ready(());
}
self.changed = Some(changed);
Poll::Pending
}
}
impl Clone for SwappableRouterService {
fn clone(&self) -> Self {
Self {
router: self.router.clone(),
changed: None,
}
}
}
impl<B> tower_service::Service<Request<B>> for SwappableRouterService
where
B: axum::body::HttpBody<Data = Bytes> + Send + 'static,
@@ -42,15 +73,13 @@ where
type Future = <Router as tower_service::Service<Request<B>>>::Future;
#[inline]
fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
let mut changed = self.0.changed().boxed();
if changed.poll_unpin(cx).is_ready() {
if self.changed(cx).is_ready() {
return Poll::Ready(Ok(()));
}
drop(changed);
tower_service::Service::<Request<B>>::poll_ready(&mut self.0.borrow().clone(), cx)
tower_service::Service::<Request<B>>::poll_ready(&mut self.router(), cx)
}
fn call(&mut self, req: Request<B>) -> Self::Future {
self.0.borrow().clone().call(req)
self.router().call(req)
}
}
@@ -66,7 +95,10 @@ impl<T> tower_service::Service<T> for SwappableRouter {
Poll::Ready(Ok(()))
}
fn call(&mut self, _: T) -> Self::Future {
ready(Ok(SwappableRouterService(self.0.subscribe())))
ready(Ok(SwappableRouterService {
router: self.0.subscribe(),
changed: None,
}))
}
}

View File

@@ -43,18 +43,21 @@ pub fn wifi<C: Context>() -> ParentHandler<C> {
"add",
from_fn_async(add)
.no_display()
.with_about("Add wifi ssid and password")
.with_call_remote::<CliContext>(),
)
.subcommand(
"connect",
from_fn_async(connect)
.no_display()
.with_about("Connect to wifi network")
.with_call_remote::<CliContext>(),
)
.subcommand(
"delete",
from_fn_async(delete)
.no_display()
.with_about("Remove a wifi network")
.with_call_remote::<CliContext>(),
)
.subcommand(
@@ -64,10 +67,17 @@ pub fn wifi<C: Context>() -> ParentHandler<C> {
.with_custom_display_fn(|handle, result| {
Ok(display_wifi_info(handle.params, result))
})
.with_about("List wifi info")
.with_call_remote::<CliContext>(),
)
.subcommand("country", country::<C>())
.subcommand("available", available::<C>())
.subcommand(
"country",
country::<C>().with_about("Command to set country"),
)
.subcommand(
"available",
available::<C>().with_about("Command to list available wifi networks"),
)
}
pub fn available<C: Context>() -> ParentHandler<C> {
@@ -76,6 +86,7 @@ pub fn available<C: Context>() -> ParentHandler<C> {
from_fn_async(get_available)
.with_display_serializable()
.with_custom_display_fn(|handle, result| Ok(display_wifi_list(handle.params, result)))
.with_about("List available wifi networks")
.with_call_remote::<CliContext>(),
)
}
@@ -85,6 +96,7 @@ pub fn country<C: Context>() -> ParentHandler<C> {
"set",
from_fn_async(set_country)
.no_display()
.with_about("Set Country")
.with_call_remote::<CliContext>(),
)
}

View File

@@ -7,7 +7,7 @@ use clap::builder::ValueParserFactory;
use clap::Parser;
use color_eyre::eyre::eyre;
use imbl_value::InternedString;
use models::PackageId;
use models::{FromStrParser, PackageId};
use rpc_toolkit::{from_fn_async, Context, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use tracing::instrument;
@@ -17,7 +17,6 @@ use crate::backup::BackupReport;
use crate::context::{CliContext, RpcContext};
use crate::db::model::DatabaseModel;
use crate::prelude::*;
use crate::util::clap::FromStrParser;
use crate::util::serde::HandlerExtSerde;
// #[command(subcommands(list, delete, delete_before, create))]
@@ -27,24 +26,28 @@ pub fn notification<C: Context>() -> ParentHandler<C> {
"list",
from_fn_async(list)
.with_display_serializable()
.with_about("List notifications")
.with_call_remote::<CliContext>(),
)
.subcommand(
"delete",
from_fn_async(delete)
.no_display()
.with_about("Delete notification for a given id")
.with_call_remote::<CliContext>(),
)
.subcommand(
"delete-before",
from_fn_async(delete_before)
.no_display()
.with_about("Delete notifications preceding a given id")
.with_call_remote::<CliContext>(),
)
.subcommand(
"create",
from_fn_async(create)
.no_display()
.with_about("Persist a newly created notification")
.with_call_remote::<CliContext>(),
)
}
@@ -253,13 +256,13 @@ impl Map for Notifications {
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Notification {
package_id: Option<PackageId>,
created_at: DateTime<Utc>,
code: u32,
level: NotificationLevel,
title: String,
message: String,
data: Value,
pub package_id: Option<PackageId>,
pub created_at: DateTime<Utc>,
pub code: u32,
pub level: NotificationLevel,
pub title: String,
pub message: String,
pub data: Value,
}
#[derive(Debug, Serialize, Deserialize)]

View File

@@ -21,7 +21,7 @@ use crate::disk::OsPartitionInfo;
use crate::net::utils::find_eth_iface;
use crate::prelude::*;
use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile;
use crate::util::io::{open_file, TmpDir};
use crate::util::io::{delete_file, open_file, TmpDir};
use crate::util::serde::IoFormat;
use crate::util::Invoke;
use crate::ARCH;
@@ -31,17 +31,19 @@ mod mbr;
pub fn install<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("disk", disk::<C>())
.subcommand("disk", disk::<C>().with_about("Command to list disk info"))
.subcommand(
"execute",
from_fn_async(execute::<InstallContext>)
.no_display()
.with_about("Install StartOS over existing version")
.with_call_remote::<CliContext>(),
)
.subcommand(
"reboot",
from_fn_async(reboot)
.no_display()
.with_about("Restart the server")
.with_call_remote::<CliContext>(),
)
}
@@ -51,6 +53,7 @@ pub fn disk<C: Context>() -> ParentHandler<C> {
"list",
from_fn_async(list)
.no_display()
.with_about("List disk info")
.with_call_remote::<CliContext>(),
)
}
@@ -147,23 +150,6 @@ pub async fn execute<C: Context>(
overwrite |= disk.guid.is_none() && disk.partitions.iter().all(|p| p.guid.is_none());
if !overwrite
&& (disk
.guid
.as_ref()
.map_or(false, |g| g.starts_with("EMBASSY_"))
|| disk
.partitions
.iter()
.flat_map(|p| p.guid.as_ref())
.any(|g| g.starts_with("EMBASSY_")))
{
return Err(Error::new(
eyre!("installing over versions before 0.3.6 is unsupported"),
ErrorKind::InvalidRequest,
));
}
let part_info = partition(&mut disk, overwrite).await?;
if let Some(efi) = &part_info.efi {
@@ -194,18 +180,9 @@ pub async fn execute<C: Context>(
{
if let Err(e) = async {
// cp -r ${guard}/config /tmp/config
if tokio::fs::metadata(guard.path().join("config/upgrade"))
.await
.is_ok()
{
tokio::fs::remove_file(guard.path().join("config/upgrade")).await?;
}
if tokio::fs::metadata(guard.path().join("config/disk.guid"))
.await
.is_ok()
{
tokio::fs::remove_file(guard.path().join("config/disk.guid")).await?;
}
delete_file(guard.path().join("config/upgrade")).await?;
delete_file(guard.path().join("config/overlay/etc/hostname")).await?;
delete_file(guard.path().join("config/disk.guid")).await?;
Command::new("cp")
.arg("-r")
.arg(guard.path().join("config"))

View File

@@ -1,35 +0,0 @@
use clap::Parser;
use imbl_value::{json, Value};
use models::PackageId;
use serde::{Deserialize, Serialize};
use crate::context::RpcContext;
use crate::prelude::*;
use crate::Error;
pub fn display_properties(response: Value) {
println!("{}", response);
}
#[derive(Deserialize, Serialize, Parser)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
pub struct PropertiesParam {
id: PackageId,
}
// #[command(display(display_properties))]
pub async fn properties(
ctx: RpcContext,
PropertiesParam { id }: PropertiesParam,
) -> Result<Value, Error> {
match &*ctx.services.get(&id).await {
Some(service) => Ok(json!({
"version": 2,
"data": service.properties().await?
})),
None => Err(Error::new(
eyre!("Could not find a service with id {id}"),
ErrorKind::NotFound,
)),
}
}

View File

@@ -18,14 +18,23 @@ use crate::util::serde::{display_serializable, HandlerExtSerde, WithIoFormat};
pub fn admin_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("signer", signers_api::<C>())
.subcommand(
"signer",
signers_api::<C>().with_about("Commands to add or list signers"),
)
.subcommand("add", from_fn_async(add_admin).no_cli())
.subcommand("add", from_fn_async(cli_add_admin).no_display())
.subcommand(
"add",
from_fn_async(cli_add_admin)
.no_display()
.with_about("Add admin signer"),
)
.subcommand(
"list",
from_fn_async(list_admins)
.with_display_serializable()
.with_custom_display_fn(|handle, result| Ok(display_signers(handle.params, result)))
.with_about("List admin signers")
.with_call_remote::<CliContext>(),
)
}
@@ -38,6 +47,7 @@ fn signers_api<C: Context>() -> ParentHandler<C> {
.with_metadata("admin", Value::Bool(true))
.with_display_serializable()
.with_custom_display_fn(|handle, result| Ok(display_signers(handle.params, result)))
.with_about("List signers")
.with_call_remote::<CliContext>(),
)
.subcommand(
@@ -46,7 +56,17 @@ fn signers_api<C: Context>() -> ParentHandler<C> {
.with_metadata("admin", Value::Bool(true))
.no_cli(),
)
.subcommand("add", from_fn_async(cli_add_signer))
.subcommand(
"add",
from_fn_async(cli_add_signer).with_about("Add signer"),
)
.subcommand(
"edit",
from_fn_async(edit_signer)
.with_metadata("admin", Value::Bool(true))
.no_display()
.with_call_remote::<CliContext>(),
)
}
impl Model<BTreeMap<Guid, SignerInfo>> {
@@ -130,6 +150,64 @@ pub async fn add_signer(ctx: RegistryContext, signer: SignerInfo) -> Result<Guid
.await
}
#[derive(Debug, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[command(rename_all = "kebab-case")]
#[ts(export)]
pub struct EditSignerParams {
pub id: Guid,
#[arg(short = 'n', long)]
pub set_name: Option<String>,
#[arg(short = 'c', long)]
pub add_contact: Vec<ContactInfo>,
#[arg(short = 'k', long)]
pub add_key: Vec<AnyVerifyingKey>,
#[arg(short = 'C', long)]
pub remove_contact: Vec<ContactInfo>,
#[arg(short = 'K', long)]
pub remove_key: Vec<AnyVerifyingKey>,
}
pub async fn edit_signer(
ctx: RegistryContext,
EditSignerParams {
id,
set_name,
add_contact,
add_key,
remove_contact,
remove_key,
}: EditSignerParams,
) -> Result<(), Error> {
ctx.db
.mutate(|db| {
db.as_index_mut()
.as_signers_mut()
.as_idx_mut(&id)
.or_not_found(&id)?
.mutate(|s| {
if let Some(name) = set_name {
s.name = name;
}
s.contact.extend(add_contact);
for rm in remove_contact {
let Some((idx, _)) = s.contact.iter().enumerate().find(|(_, c)| *c == &rm)
else {
continue;
};
s.contact.remove(idx);
}
s.keys.extend(add_key);
for rm in remove_key {
s.keys.remove(&rm);
}
Ok(())
})
})
.await
}
#[derive(Debug, Deserialize, Serialize, Parser)]
#[command(rename_all = "kebab-case")]
#[serde(rename_all = "camelCase")]

View File

@@ -255,7 +255,7 @@ impl CallRemote<RegistryContext, RegistryUrlParams> for RpcContext {
.header(CONTENT_TYPE, "application/json")
.header(ACCEPT, "application/json")
.header(CONTENT_LENGTH, body.len())
.header(DEVICE_INFO_HEADER, DeviceInfo::from(self).to_header_value())
// .header(DEVICE_INFO_HEADER, DeviceInfo::from(self).to_header_value())
.body(body)
.send()
.await?;

View File

@@ -18,14 +18,24 @@ use crate::util::serde::{apply_expr, HandlerExtSerde};
pub fn db_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("dump", from_fn_async(cli_dump).with_display_serializable())
.subcommand(
"dump",
from_fn_async(cli_dump)
.with_display_serializable()
.with_about("Filter/query db to display tables and records"),
)
.subcommand(
"dump",
from_fn_async(dump)
.with_metadata("admin", Value::Bool(true))
.no_cli(),
)
.subcommand("apply", from_fn_async(cli_apply).no_display())
.subcommand(
"apply",
from_fn_async(cli_apply)
.no_display()
.with_about("Update a db record"),
)
.subcommand(
"apply",
from_fn_async(apply)

View File

@@ -15,6 +15,7 @@ use url::Url;
use crate::context::RpcContext;
use crate::prelude::*;
use crate::registry::context::RegistryContext;
use crate::util::lshw::{LshwDevice, LshwDisplay, LshwProcessor};
use crate::util::VersionString;
use crate::version::VersionT;
@@ -26,12 +27,12 @@ pub struct DeviceInfo {
pub os: OsInfo,
pub hardware: HardwareInfo,
}
impl From<&RpcContext> for DeviceInfo {
fn from(value: &RpcContext) -> Self {
Self {
os: OsInfo::from(value),
hardware: HardwareInfo::from(value),
}
impl DeviceInfo {
pub async fn load(ctx: &RpcContext) -> Result<Self, Error> {
Ok(Self {
os: OsInfo::from(ctx),
hardware: HardwareInfo::load(ctx).await?,
})
}
}
impl DeviceInfo {
@@ -44,11 +45,11 @@ impl DeviceInfo {
.append_pair("hardware.arch", &*self.hardware.arch)
.append_pair("hardware.ram", &self.hardware.ram.to_string());
for (class, products) in &self.hardware.devices {
for product in products {
url.query_pairs_mut()
.append_pair(&format!("hardware.device.{}", class), product);
}
for device in &self.hardware.devices {
url.query_pairs_mut().append_pair(
&format!("hardware.device.{}", device.class()),
device.product(),
);
}
HeaderValue::from_str(url.query().unwrap_or_default()).unwrap()
@@ -80,16 +81,20 @@ impl DeviceInfo {
devices: identity(query)
.split_off("hardware.device.")
.into_iter()
.filter_map(|(k, v)| {
k.strip_prefix("hardware.device.")
.map(|k| (k.into(), v.into_owned()))
.filter_map(|(k, v)| match k.strip_prefix("hardware.device.") {
Some("processor") => Some(LshwDevice::Processor(LshwProcessor {
product: v.into_owned(),
})),
Some("display") => Some(LshwDevice::Display(LshwDisplay {
product: v.into_owned(),
})),
Some(class) => {
tracing::warn!("unknown device class: {class}");
None
}
_ => None,
})
.fold(BTreeMap::new(), |mut acc, (k, v)| {
let mut devs = acc.remove(&k).unwrap_or_default();
devs.push(v);
acc.insert(k, devs);
acc
}),
.collect(),
},
})
}
@@ -108,8 +113,8 @@ pub struct OsInfo {
impl From<&RpcContext> for OsInfo {
fn from(_: &RpcContext) -> Self {
Self {
version: crate::version::Current::new().semver(),
compat: crate::version::Current::new().compat().clone(),
version: crate::version::Current::default().semver(),
compat: crate::version::Current::default().compat().clone(),
platform: InternedString::intern(&*crate::PLATFORM),
}
}
@@ -122,26 +127,16 @@ pub struct HardwareInfo {
pub arch: InternedString,
#[ts(type = "number")]
pub ram: u64,
#[ts(as = "BTreeMap::<String, Vec<String>>")]
pub devices: BTreeMap<InternedString, Vec<String>>,
pub devices: Vec<LshwDevice>,
}
impl From<&RpcContext> for HardwareInfo {
fn from(value: &RpcContext) -> Self {
Self {
arch: InternedString::intern(crate::ARCH),
ram: value.hardware.ram,
devices: value
.hardware
.devices
.iter()
.fold(BTreeMap::new(), |mut acc, dev| {
let mut devs = acc.remove(dev.class()).unwrap_or_default();
devs.push(dev.product().to_owned());
acc.insert(dev.class().into(), devs);
acc
}),
}
impl HardwareInfo {
pub async fn load(ctx: &RpcContext) -> Result<Self, Error> {
let s = ctx.db.peek().await.into_public().into_server_info();
Ok(Self {
arch: s.as_arch().de()?,
ram: s.as_ram().de()?,
devices: s.as_devices().de()?,
})
}
}

View File

@@ -0,0 +1,126 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
use clap::Parser;
use imbl_value::InternedString;
use itertools::Itertools;
use models::DataUrl;
use rpc_toolkit::{from_fn_async, Context, Empty, HandlerArgs, HandlerExt, ParentHandler};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::context::CliContext;
use crate::prelude::*;
use crate::registry::context::RegistryContext;
use crate::registry::package::index::Category;
use crate::util::serde::{HandlerExtSerde, WithIoFormat};
pub fn info_api<C: Context>() -> ParentHandler<C, WithIoFormat<Empty>> {
ParentHandler::<C, WithIoFormat<Empty>>::new()
.root_handler(
from_fn_async(get_info)
.with_display_serializable()
.with_about("Display registry name, icon, and package categories")
.with_call_remote::<CliContext>(),
)
.subcommand(
"set-name",
from_fn_async(set_name)
.with_metadata("admin", Value::Bool(true))
.no_display()
.with_about("Set the name for the registry")
.with_call_remote::<CliContext>(),
)
.subcommand(
"set-icon",
from_fn_async(set_icon)
.with_metadata("admin", Value::Bool(true))
.no_cli(),
)
.subcommand(
"set-icon",
from_fn_async(cli_set_icon)
.no_display()
.with_about("Set the icon for the registry"),
)
}
#[derive(Debug, Default, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct RegistryInfo {
pub name: Option<String>,
pub icon: Option<DataUrl<'static>>,
#[ts(as = "BTreeMap::<String, Category>")]
pub categories: BTreeMap<InternedString, Category>,
}
pub async fn get_info(ctx: RegistryContext) -> Result<RegistryInfo, Error> {
let peek = ctx.db.peek().await.into_index();
Ok(RegistryInfo {
name: peek.as_name().de()?,
icon: peek.as_icon().de()?,
categories: peek.as_package().as_categories().de()?,
})
}
#[derive(Debug, Deserialize, Serialize, Parser, TS)]
#[command(rename_all = "kebab-case")]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SetNameParams {
pub name: String,
}
pub async fn set_name(
ctx: RegistryContext,
SetNameParams { name }: SetNameParams,
) -> Result<(), Error> {
ctx.db
.mutate(|db| db.as_index_mut().as_name_mut().ser(&Some(name)))
.await
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SetIconParams {
pub icon: DataUrl<'static>,
}
pub async fn set_icon(
ctx: RegistryContext,
SetIconParams { icon }: SetIconParams,
) -> Result<(), Error> {
ctx.db
.mutate(|db| db.as_index_mut().as_icon_mut().ser(&Some(icon)))
.await
}
#[derive(Debug, Deserialize, Serialize, Parser, TS)]
#[command(rename_all = "kebab-case")]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct CliSetIconParams {
pub icon: PathBuf,
}
pub async fn cli_set_icon(
HandlerArgs {
context: ctx,
parent_method,
method,
params: CliSetIconParams { icon },
..
}: HandlerArgs<CliContext, CliSetIconParams>,
) -> Result<(), Error> {
let data_url = DataUrl::from_path(icon).await?;
ctx.call_remote::<RegistryContext>(
&parent_method.into_iter().chain(method).join("."),
imbl_value::json!({
"icon": data_url,
}),
)
.await?;
Ok(())
}

View File

@@ -28,6 +28,7 @@ pub mod auth;
pub mod context;
pub mod db;
pub mod device_info;
pub mod info;
pub mod os;
pub mod package;
pub mod signer;
@@ -57,52 +58,42 @@ pub async fn get_full_index(ctx: RegistryContext) -> Result<FullIndex, Error> {
ctx.db.peek().await.into_index().de()
}
#[derive(Debug, Default, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct RegistryInfo {
pub name: Option<String>,
pub icon: Option<DataUrl<'static>>,
#[ts(as = "BTreeMap::<String, Category>")]
pub categories: BTreeMap<InternedString, Category>,
}
pub async fn get_info(ctx: RegistryContext) -> Result<RegistryInfo, Error> {
let peek = ctx.db.peek().await.into_index();
Ok(RegistryInfo {
name: peek.as_name().de()?,
icon: peek.as_icon().de()?,
categories: peek.as_package().as_categories().de()?,
})
}
pub fn registry_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand(
"index",
from_fn_async(get_full_index)
.with_display_serializable()
.with_about("List info including registry name and packages")
.with_call_remote::<CliContext>(),
)
.subcommand("info", info::info_api::<C>())
// set info and categories
.subcommand(
"os",
os::os_api::<C>().with_about("Commands related to OS assets and versions"),
)
.subcommand(
"info",
from_fn_async(get_info)
.with_display_serializable()
.with_call_remote::<CliContext>(),
"package",
package::package_api::<C>().with_about("Commands to index, add, or get packages"),
)
.subcommand(
"admin",
admin::admin_api::<C>().with_about("Commands to add or list admins or signers"),
)
.subcommand(
"db",
db::db_api::<C>().with_about("Commands to interact with the db i.e. dump and apply"),
)
.subcommand("os", os::os_api::<C>())
.subcommand("package", package::package_api::<C>())
.subcommand("admin", admin::admin_api::<C>())
.subcommand("db", db::db_api::<C>())
}
pub fn registry_router(ctx: RegistryContext) -> Router {
use axum::extract as x;
use axum::routing::{any, get, post};
use axum::routing::{any, get};
Router::new()
.route("/rpc/*path", {
let ctx = ctx.clone();
post(
any(
Server::new(move || ready(Ok(ctx.clone())), registry_api())
.middleware(Cors::new())
.middleware(Auth::new())

View File

@@ -26,11 +26,26 @@ use crate::util::io::open_file;
pub fn get_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("iso", from_fn_async(get_iso).no_cli())
.subcommand("iso", from_fn_async(cli_get_os_asset).no_display())
.subcommand(
"iso",
from_fn_async(cli_get_os_asset)
.no_display()
.with_about("Download iso"),
)
.subcommand("img", from_fn_async(get_img).no_cli())
.subcommand("img", from_fn_async(cli_get_os_asset).no_display())
.subcommand(
"img",
from_fn_async(cli_get_os_asset)
.no_display()
.with_about("Download img"),
)
.subcommand("squashfs", from_fn_async(get_squashfs).no_cli())
.subcommand("squashfs", from_fn_async(cli_get_os_asset).no_display())
.subcommand(
"squashfs",
from_fn_async(cli_get_os_asset)
.no_display()
.with_about("Download squashfs"),
)
}
#[derive(Debug, Deserialize, Serialize, TS)]
@@ -94,7 +109,11 @@ pub async fn get_squashfs(
pub struct CliGetOsAssetParams {
pub version: Version,
pub platform: InternedString,
#[arg(long = "download", short = 'd')]
#[arg(
long = "download",
short = 'd',
help = "The path of the directory to download to"
)]
pub download: Option<PathBuf>,
#[arg(
long = "reverify",
@@ -119,9 +138,15 @@ async fn cli_get_os_asset(
..
}: HandlerArgs<CliContext, CliGetOsAssetParams>,
) -> Result<RegistryAsset<Blake3Commitment>, Error> {
let ext = method
.iter()
.last()
.or_else(|| parent_method.iter().last())
.unwrap_or(&"bin");
let res = from_value::<RegistryAsset<Blake3Commitment>>(
ctx.call_remote::<RegistryContext>(
&parent_method.into_iter().chain(method).join("."),
&parent_method.iter().chain(&method).join("."),
json!({
"version": version,
"platform": platform,
@@ -133,6 +158,7 @@ async fn cli_get_os_asset(
res.validate(SIG_CONTEXT, res.all_signers())?;
if let Some(download) = download {
let download = download.join(format!("startos-{version}_{platform}.{ext}"));
let mut file = AtomicFile::new(&download, None::<&Path>)
.await
.with_kind(ErrorKind::Filesystem)?;

View File

@@ -7,8 +7,21 @@ pub mod sign;
pub fn asset_api<C: Context>() -> ParentHandler<C> {
ParentHandler::new()
.subcommand("add", add::add_api::<C>())
.subcommand("add", from_fn_async(add::cli_add_asset).no_display())
.subcommand(
"add",
from_fn_async(add::cli_add_asset)
.no_display()
.with_about("Add asset to registry"),
)
.subcommand("sign", sign::sign_api::<C>())
.subcommand("sign", from_fn_async(sign::cli_sign_asset).no_display())
.subcommand("get", get::get_api::<C>())
.subcommand(
"sign",
from_fn_async(sign::cli_sign_asset)
.no_display()
.with_about("Sign file and add to registry index"),
)
.subcommand(
"get",
get::get_api::<C>().with_about("Commands to download image, iso, or squashfs files"),
)
}

View File

@@ -15,8 +15,16 @@ pub fn os_api<C: Context>() -> ParentHandler<C> {
"index",
from_fn_async(index::get_os_index)
.with_display_serializable()
.with_about("List index of OS versions")
.with_call_remote::<CliContext>(),
)
.subcommand("asset", asset::asset_api::<C>())
.subcommand("version", version::version_api::<C>())
.subcommand(
"asset",
asset::asset_api::<C>().with_about("Commands to add, sign, or get registry assets"),
)
.subcommand(
"version",
version::version_api::<C>()
.with_about("Commands to add, remove, or list versions or version signers"),
)
}

View File

@@ -26,6 +26,7 @@ pub fn version_api<C: Context>() -> ParentHandler<C> {
.with_metadata("admin", Value::Bool(true))
.with_metadata("get_signer", Value::Bool(true))
.no_display()
.with_about("Add OS version")
.with_call_remote::<CliContext>(),
)
.subcommand(
@@ -33,9 +34,13 @@ pub fn version_api<C: Context>() -> ParentHandler<C> {
from_fn_async(remove_version)
.with_metadata("admin", Value::Bool(true))
.no_display()
.with_about("Remove OS version")
.with_call_remote::<CliContext>(),
)
.subcommand("signer", signer::signer_api::<C>())
.subcommand(
"signer",
signer::signer_api::<C>().with_about("Add, remove, and list version signers"),
)
.subcommand(
"get",
from_fn_async(get_version)
@@ -43,6 +48,7 @@ pub fn version_api<C: Context>() -> ParentHandler<C> {
.with_custom_display_fn(|handle, result| {
Ok(display_version_info(handle.params, result))
})
.with_about("Get OS versions and related version info")
.with_call_remote::<CliContext>(),
)
}

View File

@@ -21,6 +21,7 @@ pub fn signer_api<C: Context>() -> ParentHandler<C> {
from_fn_async(add_version_signer)
.with_metadata("admin", Value::Bool(true))
.no_display()
.with_about("Add version signer")
.with_call_remote::<CliContext>(),
)
.subcommand(
@@ -28,6 +29,7 @@ pub fn signer_api<C: Context>() -> ParentHandler<C> {
from_fn_async(remove_version_signer)
.with_metadata("admin", Value::Bool(true))
.no_display()
.with_about("Remove version signer")
.with_call_remote::<CliContext>(),
)
.subcommand(
@@ -35,6 +37,7 @@ pub fn signer_api<C: Context>() -> ParentHandler<C> {
from_fn_async(list_version_signers)
.with_display_serializable()
.with_custom_display_fn(|handle, result| Ok(display_signers(handle.params, result)))
.with_about("List version signers and related signer info")
.with_call_remote::<CliContext>(),
)
}

Some files were not shown because too many files have changed in this diff Show More