mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
Compare commits
12 Commits
v0.4.0-alp
...
chore/sdk-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9519684185 | ||
|
|
58e0b166cb | ||
|
|
2a678bb017 | ||
|
|
5664456b77 | ||
|
|
3685b7e57e | ||
|
|
989d5f73b1 | ||
|
|
4f84073cb5 | ||
|
|
c190295c34 | ||
|
|
60875644a1 | ||
|
|
113b09ad01 | ||
|
|
2605d0e671 | ||
|
|
d232b91d31 |
81
.github/actions/setup-build/action.yml
vendored
Normal file
81
.github/actions/setup-build/action.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Setup Build Environment
|
||||
description: Common build environment setup steps
|
||||
|
||||
inputs:
|
||||
nodejs-version:
|
||||
description: Node.js version
|
||||
required: true
|
||||
setup-python:
|
||||
description: Set up Python
|
||||
required: false
|
||||
default: "false"
|
||||
setup-docker:
|
||||
description: Set up Docker QEMU and Buildx
|
||||
required: false
|
||||
default: "true"
|
||||
setup-sccache:
|
||||
description: Configure sccache for GitHub Actions
|
||||
required: false
|
||||
default: "true"
|
||||
free-space:
|
||||
description: Remove unnecessary packages to free disk space
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Free disk space
|
||||
if: inputs.free-space == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get remove --purge -y azure-cli || true
|
||||
sudo apt-get remove --purge -y firefox || true
|
||||
sudo apt-get remove --purge -y ghc-* || true
|
||||
sudo apt-get remove --purge -y google-cloud-sdk || true
|
||||
sudo apt-get remove --purge -y google-chrome-stable || true
|
||||
sudo apt-get remove --purge -y powershell || true
|
||||
sudo apt-get remove --purge -y php* || true
|
||||
sudo apt-get remove --purge -y ruby* || true
|
||||
sudo apt-get remove --purge -y mono-* || true
|
||||
sudo apt-get autoremove -y
|
||||
sudo apt-get clean
|
||||
sudo rm -rf /usr/lib/jvm
|
||||
sudo rm -rf /usr/local/.ghcup
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/share/swift
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
|
||||
# BuildJet runners lack /opt/hostedtoolcache, which setup-python and setup-qemu expect
|
||||
- name: Ensure hostedtoolcache exists
|
||||
shell: bash
|
||||
run: sudo mkdir -p /opt/hostedtoolcache && sudo chown $USER:$USER /opt/hostedtoolcache
|
||||
|
||||
- name: Set up Python
|
||||
if: inputs.setup-python == 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.nodejs-version }}
|
||||
cache: npm
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
|
||||
- name: Set up Docker QEMU
|
||||
if: inputs.setup-docker == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: inputs.setup-docker == 'true'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Configure sccache
|
||||
if: inputs.setup-sccache == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
48
.github/workflows/start-cli.yaml
vendored
48
.github/workflows/start-cli.yaml
vendored
@@ -37,6 +37,10 @@ on:
|
||||
- master
|
||||
- next/*
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODEJS_VERSION: "24.11.0"
|
||||
ENVIRONMENT: '${{ fromJson(format(''["{0}", ""]'', github.event.inputs.environment || ''dev''))[github.event.inputs.environment == ''NONE''] }}'
|
||||
@@ -44,6 +48,7 @@ env:
|
||||
jobs:
|
||||
compile:
|
||||
name: Build Debian Package
|
||||
if: github.event.pull_request.draft != true
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
@@ -60,50 +65,15 @@ jobs:
|
||||
}}
|
||||
runs-on: ${{ fromJson('["ubuntu-latest", "buildjet-32vcpu-ubuntu-2204"]')[github.event.inputs.runner == 'fast'] }}
|
||||
steps:
|
||||
- name: Cleaning up unnecessary files
|
||||
run: |
|
||||
sudo apt-get remove --purge -y mono-* \
|
||||
ghc* cabal-install* \
|
||||
dotnet* \
|
||||
php* \
|
||||
ruby* \
|
||||
mysql-* \
|
||||
postgresql-* \
|
||||
azure-cli \
|
||||
powershell \
|
||||
google-cloud-sdk \
|
||||
msodbcsql* mssql-tools* \
|
||||
imagemagick* \
|
||||
libgl1-mesa-dri \
|
||||
google-chrome-stable \
|
||||
firefox
|
||||
sudo apt-get autoremove -y
|
||||
sudo apt-get clean
|
||||
|
||||
- run: |
|
||||
sudo mount -t tmpfs tmpfs .
|
||||
- name: Mount tmpfs
|
||||
if: ${{ github.event.inputs.runner == 'fast' }}
|
||||
|
||||
run: sudo mount -t tmpfs tmpfs .
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: ./.github/actions/setup-build
|
||||
with:
|
||||
node-version: ${{ env.NODEJS_VERSION }}
|
||||
|
||||
- name: Set up docker QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Configure sccache
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
nodejs-version: ${{ env.NODEJS_VERSION }}
|
||||
|
||||
- name: Make
|
||||
run: TARGET=${{ matrix.triple }} make cli
|
||||
|
||||
50
.github/workflows/start-registry.yaml
vendored
50
.github/workflows/start-registry.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Start-Registry
|
||||
name: start-registry
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -35,6 +35,10 @@ on:
|
||||
- master
|
||||
- next/*
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODEJS_VERSION: "24.11.0"
|
||||
ENVIRONMENT: '${{ fromJson(format(''["{0}", ""]'', github.event.inputs.environment || ''dev''))[github.event.inputs.environment == ''NONE''] }}'
|
||||
@@ -42,6 +46,7 @@ env:
|
||||
jobs:
|
||||
compile:
|
||||
name: Build Debian Package
|
||||
if: github.event.pull_request.draft != true
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
@@ -56,50 +61,15 @@ jobs:
|
||||
}}
|
||||
runs-on: ${{ fromJson('["ubuntu-latest", "buildjet-32vcpu-ubuntu-2204"]')[github.event.inputs.runner == 'fast'] }}
|
||||
steps:
|
||||
- name: Cleaning up unnecessary files
|
||||
run: |
|
||||
sudo apt-get remove --purge -y mono-* \
|
||||
ghc* cabal-install* \
|
||||
dotnet* \
|
||||
php* \
|
||||
ruby* \
|
||||
mysql-* \
|
||||
postgresql-* \
|
||||
azure-cli \
|
||||
powershell \
|
||||
google-cloud-sdk \
|
||||
msodbcsql* mssql-tools* \
|
||||
imagemagick* \
|
||||
libgl1-mesa-dri \
|
||||
google-chrome-stable \
|
||||
firefox
|
||||
sudo apt-get autoremove -y
|
||||
sudo apt-get clean
|
||||
|
||||
- run: |
|
||||
sudo mount -t tmpfs tmpfs .
|
||||
- name: Mount tmpfs
|
||||
if: ${{ github.event.inputs.runner == 'fast' }}
|
||||
|
||||
run: sudo mount -t tmpfs tmpfs .
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: ./.github/actions/setup-build
|
||||
with:
|
||||
node-version: ${{ env.NODEJS_VERSION }}
|
||||
|
||||
- name: Set up docker QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Configure sccache
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
nodejs-version: ${{ env.NODEJS_VERSION }}
|
||||
|
||||
- name: Make
|
||||
run: make registry-deb
|
||||
|
||||
50
.github/workflows/start-tunnel.yaml
vendored
50
.github/workflows/start-tunnel.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Start-Tunnel
|
||||
name: start-tunnel
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -35,6 +35,10 @@ on:
|
||||
- master
|
||||
- next/*
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODEJS_VERSION: "24.11.0"
|
||||
ENVIRONMENT: '${{ fromJson(format(''["{0}", ""]'', github.event.inputs.environment || ''dev''))[github.event.inputs.environment == ''NONE''] }}'
|
||||
@@ -42,6 +46,7 @@ env:
|
||||
jobs:
|
||||
compile:
|
||||
name: Build Debian Package
|
||||
if: github.event.pull_request.draft != true
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
@@ -56,50 +61,15 @@ jobs:
|
||||
}}
|
||||
runs-on: ${{ fromJson('["ubuntu-latest", "buildjet-32vcpu-ubuntu-2204"]')[github.event.inputs.runner == 'fast'] }}
|
||||
steps:
|
||||
- name: Cleaning up unnecessary files
|
||||
run: |
|
||||
sudo apt-get remove --purge -y mono-* \
|
||||
ghc* cabal-install* \
|
||||
dotnet* \
|
||||
php* \
|
||||
ruby* \
|
||||
mysql-* \
|
||||
postgresql-* \
|
||||
azure-cli \
|
||||
powershell \
|
||||
google-cloud-sdk \
|
||||
msodbcsql* mssql-tools* \
|
||||
imagemagick* \
|
||||
libgl1-mesa-dri \
|
||||
google-chrome-stable \
|
||||
firefox
|
||||
sudo apt-get autoremove -y
|
||||
sudo apt-get clean
|
||||
|
||||
- run: |
|
||||
sudo mount -t tmpfs tmpfs .
|
||||
- name: Mount tmpfs
|
||||
if: ${{ github.event.inputs.runner == 'fast' }}
|
||||
|
||||
run: sudo mount -t tmpfs tmpfs .
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: ./.github/actions/setup-build
|
||||
with:
|
||||
node-version: ${{ env.NODEJS_VERSION }}
|
||||
|
||||
- name: Set up docker QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Configure sccache
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
nodejs-version: ${{ env.NODEJS_VERSION }}
|
||||
|
||||
- name: Make
|
||||
run: make tunnel-deb
|
||||
|
||||
62
.github/workflows/startos-iso.yaml
vendored
62
.github/workflows/startos-iso.yaml
vendored
@@ -27,7 +27,7 @@ on:
|
||||
- x86_64-nonfree
|
||||
- aarch64
|
||||
- aarch64-nonfree
|
||||
- raspberrypi
|
||||
# - raspberrypi
|
||||
- riscv64
|
||||
deploy:
|
||||
type: choice
|
||||
@@ -45,6 +45,10 @@ on:
|
||||
- master
|
||||
- next/*
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODEJS_VERSION: "24.11.0"
|
||||
ENVIRONMENT: '${{ fromJson(format(''["{0}", ""]'', github.event.inputs.environment || ''dev''))[github.event.inputs.environment == ''NONE''] }}'
|
||||
@@ -52,6 +56,7 @@ env:
|
||||
jobs:
|
||||
compile:
|
||||
name: Compile Base Binaries
|
||||
if: github.event.pull_request.draft != true
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
@@ -86,54 +91,16 @@ jobs:
|
||||
)[github.event.inputs.runner == 'fast']
|
||||
}}
|
||||
steps:
|
||||
- name: Cleaning up unnecessary files
|
||||
run: |
|
||||
sudo apt-get remove --purge -y azure-cli || true
|
||||
sudo apt-get remove --purge -y firefox || true
|
||||
sudo apt-get remove --purge -y ghc-* || true
|
||||
sudo apt-get remove --purge -y google-cloud-sdk || true
|
||||
sudo apt-get remove --purge -y google-chrome-stable || true
|
||||
sudo apt-get remove --purge -y powershell || true
|
||||
sudo apt-get remove --purge -y php* || true
|
||||
sudo apt-get remove --purge -y ruby* || true
|
||||
sudo apt-get remove --purge -y mono-* || true
|
||||
sudo apt-get autoremove -y
|
||||
sudo apt-get clean
|
||||
sudo rm -rf /usr/lib/jvm # All JDKs
|
||||
sudo rm -rf /usr/local/.ghcup # Haskell toolchain
|
||||
sudo rm -rf /usr/local/lib/android # Android SDK/NDK, emulator
|
||||
sudo rm -rf /usr/share/dotnet # .NET SDKs
|
||||
sudo rm -rf /usr/share/swift # Swift toolchain (if present)
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY" # Pre-cached tool cache (Go, Node, etc.)
|
||||
- run: |
|
||||
sudo mount -t tmpfs tmpfs .
|
||||
- name: Mount tmpfs
|
||||
if: ${{ github.event.inputs.runner == 'fast' }}
|
||||
|
||||
run: sudo mount -t tmpfs tmpfs .
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- uses: ./.github/actions/setup-build
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODEJS_VERSION }}
|
||||
|
||||
- name: Set up docker QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Configure sccache
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || '');
|
||||
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
|
||||
nodejs-version: ${{ env.NODEJS_VERSION }}
|
||||
setup-python: "true"
|
||||
|
||||
- name: Make
|
||||
run: make ARCH=${{ matrix.arch }} compiled-${{ matrix.arch }}.tar
|
||||
@@ -151,13 +118,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# TODO: re-add "raspberrypi" to the platform list below
|
||||
platform: >-
|
||||
${{
|
||||
fromJson(
|
||||
format(
|
||||
'[
|
||||
["{0}"],
|
||||
["x86_64", "x86_64-nonfree", "aarch64", "aarch64-nonfree", "riscv64", "raspberrypi"]
|
||||
["x86_64", "x86_64-nonfree", "aarch64", "aarch64-nonfree", "riscv64"]
|
||||
]',
|
||||
github.event.inputs.platform || 'ALL'
|
||||
)
|
||||
@@ -221,6 +189,10 @@ jobs:
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY" # Pre-cached tool cache (Go, Node, etc.)
|
||||
if: ${{ github.event.inputs.runner != 'fast' }}
|
||||
|
||||
# BuildJet runners lack /opt/hostedtoolcache, which setup-qemu expects
|
||||
- name: Ensure hostedtoolcache exists
|
||||
run: sudo mkdir -p /opt/hostedtoolcache && sudo chown $USER:$USER /opt/hostedtoolcache
|
||||
|
||||
- name: Set up docker QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
|
||||
13
.github/workflows/test.yaml
vendored
13
.github/workflows/test.yaml
vendored
@@ -10,6 +10,10 @@ on:
|
||||
- master
|
||||
- next/*
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODEJS_VERSION: "24.11.0"
|
||||
ENVIRONMENT: dev-unstable
|
||||
@@ -17,15 +21,18 @@ env:
|
||||
jobs:
|
||||
test:
|
||||
name: Run Automated Tests
|
||||
if: github.event.pull_request.draft != true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: ./.github/actions/setup-build
|
||||
with:
|
||||
node-version: ${{ env.NODEJS_VERSION }}
|
||||
nodejs-version: ${{ env.NODEJS_VERSION }}
|
||||
free-space: "false"
|
||||
setup-docker: "false"
|
||||
setup-sccache: "false"
|
||||
|
||||
- name: Build And Run Tests
|
||||
run: make test
|
||||
|
||||
10
Makefile
10
Makefile
@@ -324,15 +324,19 @@ web/.angular/.updated: patch-db/client/dist/index.js sdk/baseDist/package.json w
|
||||
mkdir -p web/.angular
|
||||
touch web/.angular/.updated
|
||||
|
||||
web/dist/raw/ui/index.html: $(WEB_UI_SRC) $(WEB_SHARED_SRC) web/.angular/.updated
|
||||
web/.i18n-checked: $(WEB_SHARED_SRC) $(WEB_UI_SRC) $(WEB_SETUP_WIZARD_SRC) $(WEB_START_TUNNEL_SRC)
|
||||
npm --prefix web run check:i18n
|
||||
touch web/.i18n-checked
|
||||
|
||||
web/dist/raw/ui/index.html: $(WEB_UI_SRC) $(WEB_SHARED_SRC) web/.angular/.updated web/.i18n-checked
|
||||
npm --prefix web run build:ui
|
||||
touch web/dist/raw/ui/index.html
|
||||
|
||||
web/dist/raw/setup-wizard/index.html: $(WEB_SETUP_WIZARD_SRC) $(WEB_SHARED_SRC) web/.angular/.updated
|
||||
web/dist/raw/setup-wizard/index.html: $(WEB_SETUP_WIZARD_SRC) $(WEB_SHARED_SRC) web/.angular/.updated web/.i18n-checked
|
||||
npm --prefix web run build:setup
|
||||
touch web/dist/raw/setup-wizard/index.html
|
||||
|
||||
web/dist/raw/start-tunnel/index.html: $(WEB_START_TUNNEL_SRC) $(WEB_SHARED_SRC) web/.angular/.updated
|
||||
web/dist/raw/start-tunnel/index.html: $(WEB_START_TUNNEL_SRC) $(WEB_SHARED_SRC) web/.angular/.updated web/.i18n-checked
|
||||
npm --prefix web run build:tunnel
|
||||
touch web/dist/raw/start-tunnel/index.html
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ openssh-server
|
||||
podman
|
||||
psmisc
|
||||
qemu-guest-agent
|
||||
qemu-user-static
|
||||
rfkill
|
||||
rsync
|
||||
samba-common-bin
|
||||
|
||||
@@ -15,13 +15,12 @@ if [ "$SKIP_DL" != "1" ]; then
|
||||
fi
|
||||
|
||||
if [ -n "$RUN_ID" ]; then
|
||||
for arch in aarch64 aarch64-nonfree riscv64 riscv64-nonfree x86_64 x86_64-nonfree raspberrypi; do
|
||||
for arch in aarch64 aarch64-nonfree riscv64 x86_64 x86_64-nonfree; do
|
||||
while ! gh run download -R Start9Labs/start-os $RUN_ID -n $arch.squashfs -D $(pwd); do sleep 1; done
|
||||
done
|
||||
for arch in aarch64 aarch64-nonfree riscv64 riscv64-nonfree x86_64 x86_64-nonfree; do
|
||||
for arch in aarch64 aarch64-nonfree riscv64 x86_64 x86_64-nonfree; do
|
||||
while ! gh run download -R Start9Labs/start-os $RUN_ID -n $arch.iso -D $(pwd); do sleep 1; done
|
||||
done
|
||||
while ! gh run download -R Start9Labs/start-os $RUN_ID -n raspberrypi.img -D $(pwd); do sleep 1; done
|
||||
fi
|
||||
|
||||
if [ -n "$ST_RUN_ID" ]; then
|
||||
@@ -57,31 +56,23 @@ start-cli --registry=https://alpha-registry-x.start9.com registry os version add
|
||||
if [ "$SKIP_UL" = "2" ]; then
|
||||
exit 2
|
||||
elif [ "$SKIP_UL" != "1" ]; then
|
||||
for file in *.squashfs *.iso *.deb start-cli_*; do
|
||||
for file in *.deb start-cli_*; do
|
||||
gh release upload -R Start9Labs/start-os v$VERSION $file
|
||||
done
|
||||
for file in *.img; do
|
||||
if ! [ -f $file.gz ]; then
|
||||
cat $file | pigz > $file.gz
|
||||
fi
|
||||
gh release upload -R Start9Labs/start-os v$VERSION $file.gz
|
||||
for file in *.iso *.squashfs; do
|
||||
s3cmd put -P $file s3://startos-images/v$VERSION/$file
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$SKIP_INDEX" != "1" ]; then
|
||||
for arch in aarch64 aarch64-nonfree riscv64 riscv64-nonfree x86_64 x86_64-nonfree; do
|
||||
for arch in aarch64 aarch64-nonfree riscv64 x86_64 x86_64-nonfree; do
|
||||
for file in *_$arch.squashfs *_$arch.iso; do
|
||||
start-cli --registry=https://alpha-registry-x.start9.com registry os asset add --platform=$arch --version=$VERSION $file https://github.com/Start9Labs/start-os/releases/download/v$VERSION/$(echo -n "$file" | sed 's/~/./g')
|
||||
done
|
||||
done
|
||||
for arch in raspberrypi; do
|
||||
for file in *_$arch.squashfs; do
|
||||
start-cli --registry=https://alpha-registry-x.start9.com registry os asset add --platform=$arch --version=$VERSION $file https://github.com/Start9Labs/start-os/releases/download/v$VERSION/$(echo -n "$file" | sed 's/~/./g')
|
||||
start-cli --registry=https://alpha-registry-x.start9.com registry os asset add --platform=$arch --version=$VERSION $file https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$file
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
for file in *.iso *.img *.img.gz *.squashfs *.deb start-cli_*; do
|
||||
for file in *.iso *.squashfs *.deb start-cli_*; do
|
||||
gpg -u 7CFFDA41CA66056A --detach-sign --armor -o "${file}.asc" "$file"
|
||||
done
|
||||
|
||||
@@ -90,20 +81,30 @@ tar -czvf signatures.tar.gz *.asc
|
||||
|
||||
gh release upload -R Start9Labs/start-os v$VERSION signatures.tar.gz
|
||||
|
||||
cat << EOF
|
||||
# ISO Downloads
|
||||
|
||||
- [x86_64/AMD64](https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$(ls *_x86_64-nonfree.iso))
|
||||
- [x86_64/AMD64-slim (FOSS-only)](https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$(ls *_x86_64.iso) "Without proprietary software or drivers")
|
||||
- [aarch64/ARM64](https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$(ls *_aarch64-nonfree.iso))
|
||||
- [aarch64/ARM64-slim (FOSS-Only)](https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$(ls *_aarch64.iso) "Without proprietary software or drivers")
|
||||
- [RISCV64 (RVA23)](https://startos-images.nyc3.cdn.digitaloceanspaces.com/v$VERSION/$(ls *_riscv64.iso))
|
||||
|
||||
EOF
|
||||
cat << 'EOF'
|
||||
# StartOS Checksums
|
||||
|
||||
## SHA-256
|
||||
```
|
||||
EOF
|
||||
sha256sum *.iso *.img *img.gz *.squashfs
|
||||
sha256sum *.iso *.squashfs
|
||||
cat << 'EOF'
|
||||
```
|
||||
|
||||
## BLAKE-3
|
||||
```
|
||||
EOF
|
||||
b3sum *.iso *.img *.img.gz *.squashfs
|
||||
b3sum *.iso *.squashfs
|
||||
cat << 'EOF'
|
||||
```
|
||||
|
||||
@@ -138,5 +139,4 @@ EOF
|
||||
b3sum start-cli_*
|
||||
cat << 'EOF'
|
||||
```
|
||||
EOF
|
||||
|
||||
EOF
|
||||
2
core/Cargo.lock
generated
2
core/Cargo.lock
generated
@@ -7817,7 +7817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "start-os"
|
||||
version = "0.4.0-alpha.18"
|
||||
version = "0.4.0-alpha.19"
|
||||
dependencies = [
|
||||
"aes 0.7.5",
|
||||
"arti-client",
|
||||
|
||||
@@ -15,7 +15,7 @@ license = "MIT"
|
||||
name = "start-os"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/Start9Labs/start-os"
|
||||
version = "0.4.0-alpha.18" # VERSION_BUMP
|
||||
version = "0.4.0-alpha.19" # VERSION_BUMP
|
||||
|
||||
[lib]
|
||||
name = "startos"
|
||||
@@ -176,6 +176,7 @@ mio = "1"
|
||||
new_mime_guess = "4"
|
||||
nix = { version = "0.30.1", features = [
|
||||
"fs",
|
||||
"hostname",
|
||||
"mount",
|
||||
"net",
|
||||
"process",
|
||||
|
||||
@@ -1843,18 +1843,18 @@ service.mod.failed-to-parse-package-data-entry:
|
||||
pl_PL: "Nie udało się przeanalizować PackageDataEntry, znaleziono: %{error}"
|
||||
|
||||
service.mod.no-matching-subcontainers:
|
||||
en_US: "no matching subcontainers are running for %{id}; some possible choices are:\n%{subcontainers}"
|
||||
de_DE: "keine passenden Subcontainer laufen für %{id}; einige mögliche Optionen sind:\n%{subcontainers}"
|
||||
es_ES: "no hay subcontenedores coincidentes ejecutándose para %{id}; algunas opciones posibles son:\n%{subcontainers}"
|
||||
fr_FR: "aucun sous-conteneur correspondant n'est en cours d'exécution pour %{id} ; voici quelques choix possibles :\n%{subcontainers}"
|
||||
pl_PL: "nie działają pasujące podkontenery dla %{id}; niektóre możliwe wybory to:\n%{subcontainers}"
|
||||
en_US: "no matching subcontainers are running for %{id}; some possible choices are:"
|
||||
de_DE: "keine passenden Subcontainer laufen für %{id}; einige mögliche Optionen sind:"
|
||||
es_ES: "no hay subcontenedores coincidentes ejecutándose para %{id}; algunas opciones posibles son:"
|
||||
fr_FR: "aucun sous-conteneur correspondant n'est en cours d'exécution pour %{id} ; voici quelques choix possibles :"
|
||||
pl_PL: "nie działają pasujące podkontenery dla %{id}; niektóre możliwe wybory to:"
|
||||
|
||||
service.mod.multiple-subcontainers-found:
|
||||
en_US: "multiple subcontainers found for %{id}: \n%{subcontainer_ids}"
|
||||
de_DE: "mehrere Subcontainer für %{id} gefunden: \n%{subcontainer_ids}"
|
||||
es_ES: "se encontraron múltiples subcontenedores para %{id}: \n%{subcontainer_ids}"
|
||||
fr_FR: "plusieurs sous-conteneurs trouvés pour %{id} : \n%{subcontainer_ids}"
|
||||
pl_PL: "znaleziono wiele podkontenerów dla %{id}: \n%{subcontainer_ids}"
|
||||
en_US: "multiple subcontainers found for %{id}"
|
||||
de_DE: "mehrere Subcontainer für %{id} gefunden"
|
||||
es_ES: "se encontraron múltiples subcontenedores para %{id}"
|
||||
fr_FR: "plusieurs sous-conteneurs trouvés pour %{id}"
|
||||
pl_PL: "znaleziono wiele podkontenerów dla %{id}"
|
||||
|
||||
service.mod.invalid-byte-length-for-signal:
|
||||
en_US: "invalid byte length for signal: %{length}"
|
||||
@@ -3703,6 +3703,20 @@ help.arg.wireguard-config:
|
||||
fr_FR: "Configuration WireGuard"
|
||||
pl_PL: "Konfiguracja WireGuard"
|
||||
|
||||
help.s9pk-s3base:
|
||||
en_US: "Base URL for publishing s9pks"
|
||||
de_DE: "Basis-URL für die Veröffentlichung von s9pks"
|
||||
es_ES: "URL base para publicar s9pks"
|
||||
fr_FR: "URL de base pour publier les s9pks"
|
||||
pl_PL: "Bazowy URL do publikowania s9pks"
|
||||
|
||||
help.s9pk-s3bucket:
|
||||
en_US: "S3 bucket to publish s9pks to (should correspond to s3base)"
|
||||
de_DE: "S3-Bucket zum Veröffentlichen von s9pks (sollte mit s3base übereinstimmen)"
|
||||
es_ES: "Bucket S3 para publicar s9pks (debe corresponder con s3base)"
|
||||
fr_FR: "Bucket S3 pour publier les s9pks (doit correspondre à s3base)"
|
||||
pl_PL: "Bucket S3 do publikowania s9pks (powinien odpowiadać s3base)"
|
||||
|
||||
# CLI command descriptions (about.*)
|
||||
about.add-address-to-host:
|
||||
en_US: "Add an address to this host"
|
||||
@@ -4866,6 +4880,13 @@ about.persist-new-notification:
|
||||
fr_FR: "Persister une nouvelle notification"
|
||||
pl_PL: "Utrwal nowe powiadomienie"
|
||||
|
||||
about.publish-s9pk:
|
||||
en_US: "Publish s9pk to S3 bucket and index on registry"
|
||||
de_DE: "S9pk in S3-Bucket veröffentlichen und in Registry indizieren"
|
||||
es_ES: "Publicar s9pk en bucket S3 e indexar en el registro"
|
||||
fr_FR: "Publier s9pk dans le bucket S3 et indexer dans le registre"
|
||||
pl_PL: "Opublikuj s9pk do bucketu S3 i zindeksuj w rejestrze"
|
||||
|
||||
about.rebuild-service-container:
|
||||
en_US: "Rebuild service container"
|
||||
de_DE: "Dienst-Container neu erstellen"
|
||||
|
||||
@@ -180,7 +180,13 @@ pub async fn update(
|
||||
.as_idx_mut(&id)
|
||||
.ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("backup.target.cifs.target-not-found", id = BackupTargetId::Cifs { id })),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"backup.target.cifs.target-not-found",
|
||||
id = BackupTargetId::Cifs { id }
|
||||
)
|
||||
),
|
||||
ErrorKind::NotFound,
|
||||
)
|
||||
})?
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use rust_i18n::t;
|
||||
|
||||
pub fn renamed(old: &str, new: &str) -> ! {
|
||||
eprintln!(
|
||||
"{}",
|
||||
t!("bins.deprecated.renamed", old = old, new = new)
|
||||
);
|
||||
eprintln!("{}", t!("bins.deprecated.renamed", old = old, new = new));
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use color_eyre::eyre::eyre;
|
||||
use rust_i18n::t;
|
||||
use futures::{FutureExt, TryFutureExt};
|
||||
use rust_i18n::t;
|
||||
use tokio::signal::unix::signal;
|
||||
use tracing::instrument;
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ pub struct CliContextSeed {
|
||||
pub registry_url: Option<Url>,
|
||||
pub registry_hostname: Vec<InternedString>,
|
||||
pub registry_listen: Option<SocketAddr>,
|
||||
pub s9pk_s3base: Option<Url>,
|
||||
pub s9pk_s3bucket: Option<InternedString>,
|
||||
pub tunnel_addr: Option<SocketAddr>,
|
||||
pub tunnel_listen: Option<SocketAddr>,
|
||||
pub client: Client,
|
||||
@@ -129,6 +131,8 @@ impl CliContext {
|
||||
.transpose()?,
|
||||
registry_hostname: config.registry_hostname.unwrap_or_default(),
|
||||
registry_listen: config.registry_listen,
|
||||
s9pk_s3base: config.s9pk_s3base,
|
||||
s9pk_s3bucket: config.s9pk_s3bucket,
|
||||
tunnel_addr: config.tunnel,
|
||||
tunnel_listen: config.tunnel_listen,
|
||||
client: {
|
||||
@@ -160,21 +164,23 @@ impl CliContext {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let pair = <ed25519::KeypairBytes as ed25519::pkcs8::DecodePrivateKey>::from_pkcs8_pem(
|
||||
&std::fs::read_to_string(path)?,
|
||||
)
|
||||
.with_kind(crate::ErrorKind::Pem)?;
|
||||
let secret = ed25519_dalek::SecretKey::try_from(&pair.secret_key[..]).map_err(|_| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("context.cli.pkcs8-key-incorrect-length")),
|
||||
ErrorKind::OpenSsl,
|
||||
let pair =
|
||||
<ed25519::KeypairBytes as ed25519::pkcs8::DecodePrivateKey>::from_pkcs8_pem(
|
||||
&std::fs::read_to_string(path)?,
|
||||
)
|
||||
})?;
|
||||
return Ok(secret.into())
|
||||
.with_kind(crate::ErrorKind::Pem)?;
|
||||
let secret =
|
||||
ed25519_dalek::SecretKey::try_from(&pair.secret_key[..]).map_err(|_| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("context.cli.pkcs8-key-incorrect-length")),
|
||||
ErrorKind::OpenSsl,
|
||||
)
|
||||
})?;
|
||||
return Ok(secret.into());
|
||||
}
|
||||
Err(Error::new(
|
||||
eyre!("{}", t!("context.cli.developer-key-does-not-exist")),
|
||||
crate::ErrorKind::Uninitialized
|
||||
crate::ErrorKind::Uninitialized,
|
||||
))
|
||||
})
|
||||
}
|
||||
@@ -195,8 +201,12 @@ impl CliContext {
|
||||
.into());
|
||||
}
|
||||
};
|
||||
url.set_scheme(ws_scheme)
|
||||
.map_err(|_| Error::new(eyre!("{}", t!("context.cli.cannot-set-url-scheme")), crate::ErrorKind::ParseUrl))?;
|
||||
url.set_scheme(ws_scheme).map_err(|_| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("context.cli.cannot-set-url-scheme")),
|
||||
crate::ErrorKind::ParseUrl,
|
||||
)
|
||||
})?;
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| eyre!("Url cannot be base"))
|
||||
.with_kind(crate::ErrorKind::ParseUrl)?
|
||||
|
||||
@@ -68,6 +68,10 @@ pub struct ClientConfig {
|
||||
pub registry_hostname: Option<Vec<InternedString>>,
|
||||
#[arg(skip)]
|
||||
pub registry_listen: Option<SocketAddr>,
|
||||
#[arg(long, help = "help.s9pk-s3base")]
|
||||
pub s9pk_s3base: Option<Url>,
|
||||
#[arg(long, help = "help.s9pk-s3bucket")]
|
||||
pub s9pk_s3bucket: Option<InternedString>,
|
||||
#[arg(short = 't', long, help = "help.arg.tunnel-address")]
|
||||
pub tunnel: Option<SocketAddr>,
|
||||
#[arg(skip)]
|
||||
@@ -89,8 +93,13 @@ impl ContextConfig for ClientConfig {
|
||||
self.host = self.host.take().or(other.host);
|
||||
self.registry = self.registry.take().or(other.registry);
|
||||
self.registry_hostname = self.registry_hostname.take().or(other.registry_hostname);
|
||||
self.registry_listen = self.registry_listen.take().or(other.registry_listen);
|
||||
self.s9pk_s3base = self.s9pk_s3base.take().or(other.s9pk_s3base);
|
||||
self.s9pk_s3bucket = self.s9pk_s3bucket.take().or(other.s9pk_s3bucket);
|
||||
self.tunnel = self.tunnel.take().or(other.tunnel);
|
||||
self.tunnel_listen = self.tunnel_listen.take().or(other.tunnel_listen);
|
||||
self.proxy = self.proxy.take().or(other.proxy);
|
||||
self.socks_listen = self.socks_listen.take().or(other.socks_listen);
|
||||
self.cookie_path = self.cookie_path.take().or(other.cookie_path);
|
||||
self.developer_key_path = self.developer_key_path.take().or(other.developer_key_path);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,10 @@ impl DiagnosticContext {
|
||||
disk_guid: Option<InternedString>,
|
||||
error: Error,
|
||||
) -> Result<Self, Error> {
|
||||
tracing::error!("{}", t!("context.diagnostic.starting-diagnostic-ui", error = error));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!("context.diagnostic.starting-diagnostic-ui", error = error)
|
||||
);
|
||||
tracing::debug!("{:?}", error);
|
||||
|
||||
let (shutdown, _) = tokio::sync::broadcast::channel(1);
|
||||
|
||||
@@ -463,7 +463,10 @@ impl RpcContext {
|
||||
.await
|
||||
.result
|
||||
{
|
||||
tracing::error!("{}", t!("context.rpc.error-in-session-cleanup-cron", error = e));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!("context.rpc.error-in-session-cleanup-cron", error = e)
|
||||
);
|
||||
tracing::debug!("{e:?}");
|
||||
}
|
||||
}
|
||||
@@ -576,6 +579,7 @@ impl RpcContext {
|
||||
pub async fn call_remote<RemoteContext>(
|
||||
&self,
|
||||
method: &str,
|
||||
metadata: OrdMap<&'static str, Value>,
|
||||
params: Value,
|
||||
) -> Result<Value, RpcError>
|
||||
where
|
||||
@@ -584,7 +588,7 @@ impl RpcContext {
|
||||
<Self as CallRemote<RemoteContext, Empty>>::call_remote(
|
||||
&self,
|
||||
method,
|
||||
OrdMap::new(),
|
||||
metadata,
|
||||
params,
|
||||
Empty {},
|
||||
)
|
||||
@@ -593,20 +597,15 @@ impl RpcContext {
|
||||
pub async fn call_remote_with<RemoteContext, T>(
|
||||
&self,
|
||||
method: &str,
|
||||
metadata: OrdMap<&'static str, Value>,
|
||||
params: Value,
|
||||
extra: T,
|
||||
) -> Result<Value, RpcError>
|
||||
where
|
||||
Self: CallRemote<RemoteContext, T>,
|
||||
{
|
||||
<Self as CallRemote<RemoteContext, T>>::call_remote(
|
||||
&self,
|
||||
method,
|
||||
OrdMap::new(),
|
||||
params,
|
||||
extra,
|
||||
)
|
||||
.await
|
||||
<Self as CallRemote<RemoteContext, T>>::call_remote(&self, method, metadata, params, extra)
|
||||
.await
|
||||
}
|
||||
}
|
||||
impl AsRef<Client> for RpcContext {
|
||||
|
||||
@@ -87,7 +87,11 @@ pub enum RevisionsRes {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[command(rename_all = "kebab-case")]
|
||||
pub struct CliDumpParams {
|
||||
#[arg(long = "include-private", short = 'p', help = "help.arg.include-private-data")]
|
||||
#[arg(
|
||||
long = "include-private",
|
||||
short = 'p',
|
||||
help = "help.arg.include-private-data"
|
||||
)]
|
||||
#[serde(default)]
|
||||
include_private: bool,
|
||||
#[arg(help = "help.arg.db-path")]
|
||||
|
||||
@@ -70,12 +70,20 @@ async fn e2fsck_runner(
|
||||
if code & 4 != 0 {
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!("disk.fsck.errors-not-corrected", device = logicalname.as_ref().display(), stderr = e2fsck_stderr),
|
||||
t!(
|
||||
"disk.fsck.errors-not-corrected",
|
||||
device = logicalname.as_ref().display(),
|
||||
stderr = e2fsck_stderr
|
||||
),
|
||||
);
|
||||
} else if code & 1 != 0 {
|
||||
tracing::warn!(
|
||||
"{}",
|
||||
t!("disk.fsck.errors-corrected", device = logicalname.as_ref().display(), stderr = e2fsck_stderr),
|
||||
t!(
|
||||
"disk.fsck.errors-corrected",
|
||||
device = logicalname.as_ref().display(),
|
||||
stderr = e2fsck_stderr
|
||||
),
|
||||
);
|
||||
}
|
||||
if code < 8 {
|
||||
|
||||
@@ -29,25 +29,31 @@ impl Default for FileType {
|
||||
pub struct Bind<Src: AsRef<Path>> {
|
||||
src: Src,
|
||||
filetype: FileType,
|
||||
recursive: bool,
|
||||
}
|
||||
impl<Src: AsRef<Path>> Bind<Src> {
|
||||
pub fn new(src: Src) -> Self {
|
||||
Self {
|
||||
src,
|
||||
filetype: FileType::Directory,
|
||||
recursive: false,
|
||||
}
|
||||
}
|
||||
pub fn with_type(mut self, filetype: FileType) -> Self {
|
||||
self.filetype = filetype;
|
||||
self
|
||||
}
|
||||
pub fn recursive(mut self, recursive: bool) -> Self {
|
||||
self.recursive = recursive;
|
||||
self
|
||||
}
|
||||
}
|
||||
impl<Src: AsRef<Path> + Send + Sync> FileSystem for Bind<Src> {
|
||||
async fn source(&self) -> Result<Option<impl AsRef<Path>>, Error> {
|
||||
Ok(Some(&self.src))
|
||||
}
|
||||
fn extra_args(&self) -> impl IntoIterator<Item = impl AsRef<std::ffi::OsStr>> {
|
||||
["--bind"]
|
||||
[if self.recursive { "--rbind" } else { "--bind" }]
|
||||
}
|
||||
async fn pre_mount(&self, mountpoint: &Path, mount_type: MountType) -> Result<(), Error> {
|
||||
let from_meta = tokio::fs::metadata(&self.src).await.ok();
|
||||
|
||||
@@ -24,7 +24,11 @@ pub async fn bind<P0: AsRef<Path>, P1: AsRef<Path>>(
|
||||
) -> Result<(), Error> {
|
||||
tracing::info!(
|
||||
"{}",
|
||||
t!("disk.mount.binding", src = src.as_ref().display(), dst = dst.as_ref().display())
|
||||
t!(
|
||||
"disk.mount.binding",
|
||||
src = src.as_ref().display(),
|
||||
dst = dst.as_ref().display()
|
||||
)
|
||||
);
|
||||
if is_mountpoint(&dst).await? {
|
||||
unmount(dst.as_ref(), true).await?;
|
||||
|
||||
@@ -4,7 +4,7 @@ use axum::http::StatusCode;
|
||||
use axum::http::uri::InvalidUri;
|
||||
use color_eyre::eyre::eyre;
|
||||
use num_enum::TryFromPrimitive;
|
||||
use patch_db::Revision;
|
||||
use patch_db::Value;
|
||||
use rpc_toolkit::reqwest;
|
||||
use rpc_toolkit::yajrc::{
|
||||
INVALID_PARAMS_ERROR, INVALID_REQUEST_ERROR, METHOD_NOT_FOUND_ERROR, PARSE_ERROR, RpcError,
|
||||
@@ -16,6 +16,7 @@ use tokio_rustls::rustls;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::InvalidId;
|
||||
use crate::prelude::to_value;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)]
|
||||
#[repr(i32)]
|
||||
@@ -183,7 +184,8 @@ impl ErrorKind {
|
||||
UpdateFailed => t!("error.update-failed"),
|
||||
Smtp => t!("error.smtp"),
|
||||
SetSysInfo => t!("error.set-sys-info"),
|
||||
}.to_string()
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
impl Display for ErrorKind {
|
||||
@@ -196,7 +198,7 @@ pub struct Error {
|
||||
pub source: color_eyre::eyre::Error,
|
||||
pub debug: Option<color_eyre::eyre::Error>,
|
||||
pub kind: ErrorKind,
|
||||
pub revision: Option<Revision>,
|
||||
pub info: Value,
|
||||
pub task: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
@@ -227,7 +229,7 @@ impl Error {
|
||||
source: source.into(),
|
||||
debug,
|
||||
kind,
|
||||
revision: None,
|
||||
info: Value::Null,
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
@@ -236,7 +238,7 @@ impl Error {
|
||||
source: eyre!("{}", self.source),
|
||||
debug: self.debug.as_ref().map(|e| eyre!("{e}")),
|
||||
kind: self.kind,
|
||||
revision: self.revision.clone(),
|
||||
info: self.info.clone(),
|
||||
task: None,
|
||||
}
|
||||
}
|
||||
@@ -244,6 +246,10 @@ impl Error {
|
||||
self.task = Some(task);
|
||||
self
|
||||
}
|
||||
pub fn with_info(mut self, info: Value) -> Self {
|
||||
self.info = info;
|
||||
self
|
||||
}
|
||||
pub async fn wait(mut self) -> Self {
|
||||
if let Some(task) = &mut self.task {
|
||||
task.await.log_err();
|
||||
@@ -422,6 +428,8 @@ impl From<patch_db::value::Error> for Error {
|
||||
pub struct ErrorData {
|
||||
pub details: String,
|
||||
pub debug: String,
|
||||
#[serde(default)]
|
||||
pub info: Value,
|
||||
}
|
||||
impl Display for ErrorData {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -439,6 +447,7 @@ impl From<Error> for ErrorData {
|
||||
Self {
|
||||
details: value.to_string(),
|
||||
debug: format!("{:?}", value),
|
||||
info: value.info,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,40 +478,31 @@ impl From<&RpcError> for ErrorData {
|
||||
.or_else(|| d.as_str().map(|s| s.to_owned()))
|
||||
})
|
||||
.unwrap_or_else(|| value.message.clone().into_owned()),
|
||||
info: to_value(
|
||||
&value
|
||||
.data
|
||||
.as_ref()
|
||||
.and_then(|d| d.as_object().and_then(|d| d.get("info"))),
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for RpcError {
|
||||
fn from(e: Error) -> Self {
|
||||
let mut data_object = serde_json::Map::with_capacity(3);
|
||||
data_object.insert("details".to_owned(), format!("{}", e.source).into());
|
||||
data_object.insert("debug".to_owned(), format!("{:?}", e.source).into());
|
||||
data_object.insert(
|
||||
"revision".to_owned(),
|
||||
match serde_json::to_value(&e.revision) {
|
||||
let kind = e.kind;
|
||||
let data = ErrorData::from(e);
|
||||
RpcError {
|
||||
code: kind as i32,
|
||||
message: kind.as_str().into(),
|
||||
data: Some(match serde_json::to_value(&data) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!("Error serializing revision for Error object: {}", e);
|
||||
tracing::warn!("Error serializing ErrorData object: {}", e);
|
||||
serde_json::Value::Null
|
||||
}
|
||||
},
|
||||
);
|
||||
RpcError {
|
||||
code: e.kind as i32,
|
||||
message: e.kind.as_str().into(),
|
||||
data: Some(
|
||||
match serde_json::to_value(&ErrorData {
|
||||
details: format!("{}", e.source),
|
||||
debug: format!("{:?}", e.source),
|
||||
}) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!("Error serializing revision for Error object: {}", e);
|
||||
serde_json::Value::Null
|
||||
}
|
||||
},
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -605,7 +605,7 @@ where
|
||||
kind,
|
||||
source,
|
||||
debug,
|
||||
revision: None,
|
||||
info: Value::Null,
|
||||
task: None,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -131,6 +131,9 @@ pub async fn install(
|
||||
let package: GetPackageResponse = from_value(
|
||||
ctx.call_remote_with::<RegistryContext, _>(
|
||||
"package.get",
|
||||
[("get_device_info", Value::Bool(true))]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
json!({
|
||||
"id": id,
|
||||
"targetVersion": VersionRange::exactly(version.deref().clone()),
|
||||
|
||||
@@ -540,7 +540,10 @@ pub fn package<C: Context>() -> ParentHandler<C> {
|
||||
.with_about("about.execute-commands-container")
|
||||
.no_cli(),
|
||||
)
|
||||
.subcommand("attach", from_fn_async(service::cli_attach).no_display())
|
||||
.subcommand(
|
||||
"attach",
|
||||
from_fn_async_local(service::cli_attach).no_display(),
|
||||
)
|
||||
.subcommand(
|
||||
"host",
|
||||
net::host::host_api::<C>().with_about("about.manage-network-hosts-package"),
|
||||
|
||||
@@ -6,7 +6,6 @@ use std::str::FromStr;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
use axum::extract::ws;
|
||||
use crate::util::net::WebSocket;
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::builder::ValueParserFactory;
|
||||
use clap::{Args, FromArgMatches, Parser};
|
||||
@@ -31,6 +30,7 @@ use crate::context::{CliContext, RpcContext};
|
||||
use crate::error::ResultExt;
|
||||
use crate::prelude::*;
|
||||
use crate::rpc_continuations::{Guid, RpcContinuation, RpcContinuations};
|
||||
use crate::util::net::WebSocket;
|
||||
use crate::util::serde::Reversible;
|
||||
use crate::util::{FromStrParser, Invoke};
|
||||
|
||||
@@ -330,12 +330,22 @@ pub struct LogsParams<Extra: FromArgMatches + Args = Empty> {
|
||||
extra: Extra,
|
||||
#[arg(short = 'l', long = "limit", help = "help.arg.log-limit")]
|
||||
limit: Option<usize>,
|
||||
#[arg(short = 'c', long = "cursor", conflicts_with = "follow", help = "help.arg.log-cursor")]
|
||||
#[arg(
|
||||
short = 'c',
|
||||
long = "cursor",
|
||||
conflicts_with = "follow",
|
||||
help = "help.arg.log-cursor"
|
||||
)]
|
||||
cursor: Option<String>,
|
||||
#[arg(short = 'b', long = "boot", help = "help.arg.log-boot")]
|
||||
#[serde(default)]
|
||||
boot: Option<BootIdentifier>,
|
||||
#[arg(short = 'B', long = "before", conflicts_with = "follow", help = "help.arg.log-before")]
|
||||
#[arg(
|
||||
short = 'B',
|
||||
long = "before",
|
||||
conflicts_with = "follow",
|
||||
help = "help.arg.log-before"
|
||||
)]
|
||||
#[serde(default)]
|
||||
before: bool,
|
||||
}
|
||||
@@ -553,10 +563,12 @@ pub async fn journalctl(
|
||||
follow_cmd.arg("--lines=0");
|
||||
}
|
||||
let mut child = follow_cmd.stdout(Stdio::piped()).spawn()?;
|
||||
let out =
|
||||
BufReader::new(child.stdout.take().ok_or_else(|| {
|
||||
Error::new(eyre!("{}", t!("logs.no-stdout-available")), crate::ErrorKind::Journald)
|
||||
})?);
|
||||
let out = BufReader::new(child.stdout.take().ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("logs.no-stdout-available")),
|
||||
crate::ErrorKind::Journald,
|
||||
)
|
||||
})?);
|
||||
|
||||
let journalctl_entries = LinesStream::new(out.lines());
|
||||
|
||||
@@ -701,7 +713,10 @@ pub async fn follow_logs<Context: AsRef<RpcContinuations>>(
|
||||
RpcContinuation::ws(
|
||||
move |socket| async move {
|
||||
if let Err(e) = ws_handler(first_entry, stream, socket).await {
|
||||
tracing::error!("{}", t!("logs.error-in-log-stream", error = e.to_string()));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!("logs.error-in-log-stream", error = e.to_string())
|
||||
);
|
||||
}
|
||||
},
|
||||
Duration::from_secs(30),
|
||||
|
||||
@@ -40,7 +40,10 @@ impl LocalAuthContext for RpcContext {
|
||||
}
|
||||
|
||||
fn unauthorized() -> Error {
|
||||
Error::new(eyre!("{}", t!("middleware.auth.unauthorized")), crate::ErrorKind::Authorization)
|
||||
Error::new(
|
||||
eyre!("{}", t!("middleware.auth.unauthorized")),
|
||||
crate::ErrorKind::Authorization,
|
||||
)
|
||||
}
|
||||
|
||||
async fn check_from_header<C: LocalAuthContext>(header: Option<&HeaderValue>) -> Result<(), Error> {
|
||||
|
||||
@@ -244,7 +244,10 @@ impl ValidSessionToken {
|
||||
C::access_sessions(db)
|
||||
.as_idx_mut(session_hash)
|
||||
.ok_or_else(|| {
|
||||
Error::new(eyre!("{}", t!("middleware.auth.unauthorized")), crate::ErrorKind::Authorization)
|
||||
Error::new(
|
||||
eyre!("{}", t!("middleware.auth.unauthorized")),
|
||||
crate::ErrorKind::Authorization,
|
||||
)
|
||||
})?
|
||||
.mutate(|s| {
|
||||
s.last_active = Utc::now();
|
||||
|
||||
@@ -347,6 +347,10 @@ pub async fn call_remote<Ctx: SigningContext + AsRef<Client>>(
|
||||
.with_kind(ErrorKind::Deserialization)?
|
||||
.result
|
||||
}
|
||||
_ => Err(Error::new(eyre!("{}", t!("middleware.auth.unknown-content-type")), ErrorKind::Network).into()),
|
||||
_ => Err(Error::new(
|
||||
eyre!("{}", t!("middleware.auth.unknown-content-type")),
|
||||
ErrorKind::Network,
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,13 @@ impl Middleware<RpcContext> for SyncDb {
|
||||
}
|
||||
.await
|
||||
{
|
||||
tracing::error!("{}", t!("middleware.db.error-writing-patch-sequence-header", error = e));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!(
|
||||
"middleware.db.error-writing-patch-sequence-header",
|
||||
error = e
|
||||
)
|
||||
);
|
||||
tracing::debug!("{e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +240,13 @@ impl PortForwardController {
|
||||
}
|
||||
.await
|
||||
{
|
||||
tracing::error!("{}", t!("net.forward.error-initializing-controller", error = format!("{e:#}")));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!(
|
||||
"net.forward.error-initializing-controller",
|
||||
error = format!("{e:#}")
|
||||
)
|
||||
);
|
||||
tracing::debug!("{e:?}");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
|
||||
@@ -171,16 +171,13 @@ where
|
||||
let mut tls_handler = self.tls_handler.clone();
|
||||
let mut fut = async move {
|
||||
let res = async {
|
||||
let mut acceptor = LazyConfigAcceptor::new(
|
||||
Acceptor::default(),
|
||||
BackTrackingIO::new(stream),
|
||||
);
|
||||
let mut acceptor =
|
||||
LazyConfigAcceptor::new(Acceptor::default(), BackTrackingIO::new(stream));
|
||||
let mut mid: tokio_rustls::StartHandshake<BackTrackingIO<AcceptStream>> =
|
||||
match (&mut acceptor).await {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
let mut stream =
|
||||
acceptor.take_io().or_not_found("acceptor io")?;
|
||||
let mut stream = acceptor.take_io().or_not_found("acceptor io")?;
|
||||
let (_, buf) = stream.rewind();
|
||||
if std::str::from_utf8(buf)
|
||||
.ok()
|
||||
|
||||
@@ -324,7 +324,12 @@ pub async fn list_keys(ctx: RpcContext) -> Result<BTreeSet<OnionAddress>, Error>
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[command(rename_all = "kebab-case")]
|
||||
pub struct ResetParams {
|
||||
#[arg(name = "wipe-state", short = 'w', long = "wipe-state", help = "help.arg.wipe-tor-state")]
|
||||
#[arg(
|
||||
name = "wipe-state",
|
||||
short = 'w',
|
||||
long = "wipe-state",
|
||||
help = "help.arg.wipe-tor-state"
|
||||
)]
|
||||
wipe_state: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,12 @@ pub async fn list_keys(ctx: RpcContext) -> Result<BTreeSet<OnionAddress>, Error>
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[command(rename_all = "kebab-case")]
|
||||
pub struct ResetParams {
|
||||
#[arg(name = "wipe-state", short = 'w', long = "wipe-state", help = "help.arg.wipe-tor-state")]
|
||||
#[arg(
|
||||
name = "wipe-state",
|
||||
short = 'w',
|
||||
long = "wipe-state",
|
||||
help = "help.arg.wipe-tor-state"
|
||||
)]
|
||||
wipe_state: bool,
|
||||
#[arg(help = "help.arg.reset-reason")]
|
||||
reason: String,
|
||||
|
||||
@@ -94,7 +94,12 @@ impl Model<BTreeMap<Guid, SignerInfo>> {
|
||||
.next()
|
||||
.transpose()?
|
||||
.map(|(a, _)| a)
|
||||
.ok_or_else(|| Error::new(eyre!("{}", t!("registry.admin.unknown-signer")), ErrorKind::Authorization))
|
||||
.ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("registry.admin.unknown-signer")),
|
||||
ErrorKind::Authorization,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_signer_info(&self, key: &AnyVerifyingKey) -> Result<(Guid, SignerInfo), Error> {
|
||||
@@ -104,7 +109,12 @@ impl Model<BTreeMap<Guid, SignerInfo>> {
|
||||
.filter_ok(|(_, s)| s.keys.contains(key))
|
||||
.next()
|
||||
.transpose()?
|
||||
.ok_or_else(|| Error::new(eyre!("{}", t!("registry.admin.unknown-signer")), ErrorKind::Authorization))
|
||||
.ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("registry.admin.unknown-signer")),
|
||||
ErrorKind::Authorization,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_signer(&mut self, signer: &SignerInfo) -> Result<Guid, Error> {
|
||||
@@ -119,7 +129,11 @@ impl Model<BTreeMap<Guid, SignerInfo>> {
|
||||
return Err(Error::new(
|
||||
eyre!(
|
||||
"{}",
|
||||
t!("registry.admin.signer-already-exists", guid = guid, name = s.name)
|
||||
t!(
|
||||
"registry.admin.signer-already-exists",
|
||||
guid = guid,
|
||||
name = s.name
|
||||
)
|
||||
),
|
||||
ErrorKind::InvalidRequest,
|
||||
));
|
||||
|
||||
@@ -44,7 +44,11 @@ const DEFAULT_REGISTRY_LISTEN: SocketAddr =
|
||||
pub struct RegistryConfig {
|
||||
#[arg(short = 'c', long = "config", help = "help.arg.config-file-path")]
|
||||
pub config: Option<PathBuf>,
|
||||
#[arg(short = 'l', long = "listen", help = "help.arg.registry-listen-address")]
|
||||
#[arg(
|
||||
short = 'l',
|
||||
long = "listen",
|
||||
help = "help.arg.registry-listen-address"
|
||||
)]
|
||||
pub registry_listen: Option<SocketAddr>,
|
||||
#[arg(short = 'H', long = "hostname", help = "help.arg.registry-hostname")]
|
||||
pub registry_hostname: Vec<InternedString>,
|
||||
@@ -52,7 +56,11 @@ pub struct RegistryConfig {
|
||||
pub tor_proxy: Option<Url>,
|
||||
#[arg(short = 'd', long = "datadir", help = "help.arg.data-directory")]
|
||||
pub datadir: Option<PathBuf>,
|
||||
#[arg(short = 'u', long = "pg-connection-url", help = "help.arg.postgres-connection-url")]
|
||||
#[arg(
|
||||
short = 'u',
|
||||
long = "pg-connection-url",
|
||||
help = "help.arg.postgres-connection-url"
|
||||
)]
|
||||
pub pg_connection_url: Option<String>,
|
||||
}
|
||||
impl ContextConfig for RegistryConfig {
|
||||
@@ -195,9 +203,11 @@ impl CallRemote<RegistryContext> for CliContext {
|
||||
.push("v0");
|
||||
url
|
||||
} else {
|
||||
return Err(
|
||||
Error::new(eyre!("{}", t!("registry.context.registry-required")), ErrorKind::InvalidRequest).into(),
|
||||
);
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("registry.context.registry-required")),
|
||||
ErrorKind::InvalidRequest,
|
||||
)
|
||||
.into());
|
||||
};
|
||||
|
||||
if let Ok(local) = cookie {
|
||||
@@ -331,7 +341,10 @@ impl SignatureAuthContext for RegistryContext {
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::new(eyre!("{}", t!("registry.context.unauthorized")), ErrorKind::Authorization))
|
||||
Err(Error::new(
|
||||
eyre!("{}", t!("registry.context.unauthorized")),
|
||||
ErrorKind::Authorization,
|
||||
))
|
||||
}
|
||||
async fn post_auth_hook(
|
||||
&self,
|
||||
|
||||
@@ -154,7 +154,10 @@ async fn add_asset(
|
||||
})?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(eyre!("{}", t!("registry.os.asset.unauthorized")), ErrorKind::Authorization))
|
||||
Err(Error::new(
|
||||
eyre!("{}", t!("registry.os.asset.unauthorized")),
|
||||
ErrorKind::Authorization,
|
||||
))
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -231,10 +234,12 @@ pub async fn cli_add_asset(
|
||||
|
||||
sign_phase.start();
|
||||
let blake3 = file.blake3_mmap().await?;
|
||||
let size = file
|
||||
.size()
|
||||
.await
|
||||
.ok_or_else(|| Error::new(eyre!("{}", t!("registry.os.asset.failed-read-metadata")), ErrorKind::Filesystem))?;
|
||||
let size = file.size().await.ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("registry.os.asset.failed-read-metadata")),
|
||||
ErrorKind::Filesystem,
|
||||
)
|
||||
})?;
|
||||
let commitment = Blake3Commitment {
|
||||
hash: Base64(*blake3.as_bytes()),
|
||||
size,
|
||||
@@ -336,7 +341,10 @@ async fn remove_asset(
|
||||
.remove(&platform)?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(eyre!("{}", t!("registry.os.asset.unauthorized")), ErrorKind::Authorization))
|
||||
Err(Error::new(
|
||||
eyre!("{}", t!("registry.os.asset.unauthorized")),
|
||||
ErrorKind::Authorization,
|
||||
))
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -125,17 +125,9 @@ pub struct CliGetOsAssetParams {
|
||||
pub version: Version,
|
||||
#[arg(help = "help.arg.platform")]
|
||||
pub platform: InternedString,
|
||||
#[arg(
|
||||
long = "download",
|
||||
short = 'd',
|
||||
help = "help.arg.download-directory"
|
||||
)]
|
||||
#[arg(long = "download", short = 'd', help = "help.arg.download-directory")]
|
||||
pub download: Option<PathBuf>,
|
||||
#[arg(
|
||||
long = "reverify",
|
||||
short = 'r',
|
||||
help = "help.arg.reverify-hash"
|
||||
)]
|
||||
#[arg(long = "reverify", short = 'r', help = "help.arg.reverify-hash")]
|
||||
pub reverify: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,10 @@ async fn sign_asset(
|
||||
.contains(&guid)
|
||||
{
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("registry.os.asset.signer-not-authorized", guid = guid)),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!("registry.os.asset.signer-not-authorized", guid = guid)
|
||||
),
|
||||
ErrorKind::Authorization,
|
||||
));
|
||||
}
|
||||
@@ -184,10 +187,12 @@ pub async fn cli_sign_asset(
|
||||
|
||||
sign_phase.start();
|
||||
let blake3 = file.blake3_mmap().await?;
|
||||
let size = file
|
||||
.size()
|
||||
.await
|
||||
.ok_or_else(|| Error::new(eyre!("{}", t!("registry.os.asset.failed-read-metadata")), ErrorKind::Filesystem))?;
|
||||
let size = file.size().await.ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("registry.os.asset.failed-read-metadata")),
|
||||
ErrorKind::Filesystem,
|
||||
)
|
||||
})?;
|
||||
let commitment = Blake3Commitment {
|
||||
hash: Base64(*blake3.as_bytes()),
|
||||
size,
|
||||
|
||||
@@ -26,7 +26,6 @@ pub fn os_api<C: Context>() -> ParentHandler<C> {
|
||||
)
|
||||
.subcommand(
|
||||
"version",
|
||||
version::version_api::<C>()
|
||||
.with_about("about.commands-add-remove-list-versions"),
|
||||
version::version_api::<C>().with_about("about.commands-add-remove-list-versions"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,14 @@ pub async fn remove_version_signer(
|
||||
.mutate(|s| Ok(s.remove(&signer)))?
|
||||
{
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("registry.os.version.signer-not-authorized", signer = signer, version = version)),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"registry.os.version.signer-not-authorized",
|
||||
signer = signer,
|
||||
version = version
|
||||
)
|
||||
),
|
||||
ErrorKind::NotFound,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -112,7 +112,10 @@ pub async fn add_package(
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(eyre!("{}", t!("registry.package.add.unauthorized")), ErrorKind::Authorization))
|
||||
Err(Error::new(
|
||||
eyre!("{}", t!("registry.package.add.unauthorized")),
|
||||
ErrorKind::Authorization,
|
||||
))
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -132,20 +135,24 @@ pub struct CliAddPackageParams {
|
||||
}
|
||||
|
||||
pub async fn cli_add_package(
|
||||
HandlerArgs {
|
||||
context: ctx,
|
||||
parent_method,
|
||||
method,
|
||||
params:
|
||||
CliAddPackageParams {
|
||||
file,
|
||||
url,
|
||||
no_verify,
|
||||
},
|
||||
..
|
||||
}: HandlerArgs<CliContext, CliAddPackageParams>,
|
||||
ctx: CliContext,
|
||||
CliAddPackageParams {
|
||||
file,
|
||||
url,
|
||||
no_verify,
|
||||
}: CliAddPackageParams,
|
||||
) -> Result<(), Error> {
|
||||
let s9pk = S9pk::open(&file, None).await?;
|
||||
cli_add_package_impl(ctx, s9pk, url, no_verify).await
|
||||
}
|
||||
|
||||
pub async fn cli_add_package_impl(
|
||||
ctx: CliContext,
|
||||
s9pk: S9pk,
|
||||
url: Vec<Url>,
|
||||
no_verify: bool,
|
||||
) -> Result<(), Error> {
|
||||
let manifest = s9pk.as_manifest();
|
||||
|
||||
let progress = FullProgressTracker::new();
|
||||
let mut sign_phase = progress.add_phase(InternedString::intern("Signing File"), Some(1));
|
||||
@@ -167,8 +174,16 @@ pub async fn cli_add_package(
|
||||
Some(1),
|
||||
);
|
||||
|
||||
let progress_task =
|
||||
progress.progress_bar_task(&format!("Adding {} to registry...", file.display()));
|
||||
let progress_task = progress.progress_bar_task(&format!(
|
||||
"Adding {}@{}{} to registry...",
|
||||
manifest.id,
|
||||
manifest.version,
|
||||
manifest
|
||||
.hardware_requirements
|
||||
.arch
|
||||
.as_ref()
|
||||
.map_or(String::new(), |a| format!(" ({})", a.iter().join("/")))
|
||||
));
|
||||
|
||||
sign_phase.start();
|
||||
let commitment = s9pk.as_archive().commitment().await?;
|
||||
@@ -185,7 +200,7 @@ pub async fn cli_add_package(
|
||||
|
||||
index_phase.start();
|
||||
ctx.call_remote::<RegistryContext>(
|
||||
&parent_method.into_iter().chain(method).join("."),
|
||||
"package.add",
|
||||
imbl_value::json!({
|
||||
"urls": &url,
|
||||
"signature": AnySignature::Ed25519(signature),
|
||||
@@ -228,8 +243,12 @@ pub async fn remove_package(
|
||||
}: RemovePackageParams,
|
||||
) -> Result<bool, Error> {
|
||||
let peek = ctx.db.peek().await;
|
||||
let signer =
|
||||
signer.ok_or_else(|| Error::new(eyre!("{}", t!("registry.package.missing-signer")), ErrorKind::InvalidRequest))?;
|
||||
let signer = signer.ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("registry.package.missing-signer")),
|
||||
ErrorKind::InvalidRequest,
|
||||
)
|
||||
})?;
|
||||
let signer_guid = peek.as_index().as_signers().get_signer(&signer)?;
|
||||
|
||||
let rev = ctx
|
||||
@@ -270,7 +289,10 @@ pub async fn remove_package(
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(eyre!("{}", t!("registry.package.unauthorized")), ErrorKind::Authorization))
|
||||
Err(Error::new(
|
||||
eyre!("{}", t!("registry.package.unauthorized")),
|
||||
ErrorKind::Authorization,
|
||||
))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -345,7 +367,10 @@ pub async fn add_mirror(
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(eyre!("{}", t!("registry.package.add-mirror.unauthorized")), ErrorKind::Authorization))
|
||||
Err(Error::new(
|
||||
eyre!("{}", t!("registry.package.add-mirror.unauthorized")),
|
||||
ErrorKind::Authorization,
|
||||
))
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -461,8 +486,12 @@ pub async fn remove_mirror(
|
||||
}: RemoveMirrorParams,
|
||||
) -> Result<(), Error> {
|
||||
let peek = ctx.db.peek().await;
|
||||
let signer =
|
||||
signer.ok_or_else(|| Error::new(eyre!("{}", t!("registry.package.missing-signer")), ErrorKind::InvalidRequest))?;
|
||||
let signer = signer.ok_or_else(|| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("registry.package.missing-signer")),
|
||||
ErrorKind::InvalidRequest,
|
||||
)
|
||||
})?;
|
||||
let signer_guid = peek.as_index().as_signers().get_signer(&signer)?;
|
||||
|
||||
ctx.db
|
||||
@@ -501,7 +530,10 @@ pub async fn remove_mirror(
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new(eyre!("{}", t!("registry.package.remove-mirror.unauthorized")), ErrorKind::Authorization))
|
||||
Err(Error::new(
|
||||
eyre!("{}", t!("registry.package.remove-mirror.unauthorized")),
|
||||
ErrorKind::Authorization,
|
||||
))
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -52,10 +52,14 @@ pub fn package_api<C: Context>() -> ParentHandler<C> {
|
||||
if !changed {
|
||||
tracing::warn!(
|
||||
"{}",
|
||||
t!("registry.package.remove-not-exist",
|
||||
t!(
|
||||
"registry.package.remove-not-exist",
|
||||
id = args.params.id,
|
||||
version = args.params.version,
|
||||
sighash = args.params.sighash.map_or(String::new(), |h| format!("#{h}"))
|
||||
sighash = args
|
||||
.params
|
||||
.sighash
|
||||
.map_or(String::new(), |h| format!("#{h}"))
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -96,7 +100,6 @@ pub fn package_api<C: Context>() -> ParentHandler<C> {
|
||||
)
|
||||
.subcommand(
|
||||
"category",
|
||||
category::category_api::<C>()
|
||||
.with_about("about.update-categories-registry"),
|
||||
category::category_api::<C>().with_about("about.update-categories-registry"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,7 +118,14 @@ pub async fn remove_package_signer(
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("registry.package.signer.not-authorized", signer = signer, id = id)),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"registry.package.signer.not-authorized",
|
||||
signer = signer,
|
||||
id = id
|
||||
)
|
||||
),
|
||||
ErrorKind::NotFound,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::ops::Deref;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Parser;
|
||||
use rpc_toolkit::{Empty, HandlerExt, ParentHandler, from_fn_async};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
use ts_rs::TS;
|
||||
use url::Url;
|
||||
|
||||
use crate::ImageId;
|
||||
use crate::context::CliContext;
|
||||
@@ -13,9 +16,9 @@ use crate::s9pk::manifest::Manifest;
|
||||
use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile;
|
||||
use crate::s9pk::v2::SIG_CONTEXT;
|
||||
use crate::s9pk::v2::pack::ImageConfig;
|
||||
use crate::util::Apply;
|
||||
use crate::util::io::{TmpDir, create_file, open_file};
|
||||
use crate::util::serde::{HandlerExtSerde, apply_expr};
|
||||
use crate::util::{Apply, Invoke};
|
||||
|
||||
pub const SKIP_ENV: &[&str] = &["TERM", "container", "HOME", "HOSTNAME"];
|
||||
|
||||
@@ -61,6 +64,12 @@ pub fn s9pk() -> ParentHandler<CliContext> {
|
||||
.no_display()
|
||||
.with_about("about.convert-s9pk-v1-to-v2"),
|
||||
)
|
||||
.subcommand(
|
||||
"publish",
|
||||
from_fn_async(publish)
|
||||
.no_display()
|
||||
.with_about("about.publish-s9pk"),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Parser)]
|
||||
@@ -256,3 +265,61 @@ async fn convert(ctx: CliContext, S9pkPath { s9pk: s9pk_path }: S9pkPath) -> Res
|
||||
tokio::fs::rename(tmp_path, s9pk_path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish(ctx: CliContext, S9pkPath { s9pk: s9pk_path }: S9pkPath) -> Result<(), Error> {
|
||||
let filename = s9pk_path.file_name().unwrap().to_string_lossy();
|
||||
let s9pk = super::S9pk::open(&s9pk_path, None).await?;
|
||||
let manifest = s9pk.as_manifest();
|
||||
let path = [
|
||||
manifest.id.deref(),
|
||||
manifest.version.as_str(),
|
||||
filename.deref(),
|
||||
];
|
||||
let mut s3url = ctx
|
||||
.s9pk_s3base
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::new(eyre!("--s9pk-s3base required"), ErrorKind::InvalidRequest))?
|
||||
.clone();
|
||||
s3url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| {
|
||||
Error::new(
|
||||
eyre!("s9pk-s3base is invalid (missing protocol?)"),
|
||||
ErrorKind::ParseUrl,
|
||||
)
|
||||
})?
|
||||
.pop_if_empty()
|
||||
.extend(path);
|
||||
|
||||
let mut s3dest = format!(
|
||||
"s3://{}",
|
||||
ctx.s9pk_s3bucket
|
||||
.as_deref()
|
||||
.or_else(|| s3url
|
||||
.host_str()
|
||||
.and_then(|h| h.split_once(".").map(|h| h.0)))
|
||||
.ok_or_else(|| {
|
||||
Error::new(eyre!("--s9pk-s3bucket required"), ErrorKind::InvalidRequest)
|
||||
})?,
|
||||
)
|
||||
.parse::<Url>()?;
|
||||
s3dest
|
||||
.path_segments_mut()
|
||||
.map_err(|_| {
|
||||
Error::new(
|
||||
eyre!("s9pk-s3base is invalid (missing protocol?)"),
|
||||
ErrorKind::ParseUrl,
|
||||
)
|
||||
})?
|
||||
.pop_if_empty()
|
||||
.extend(path);
|
||||
Command::new("s3cmd")
|
||||
.arg("put")
|
||||
.arg("-P")
|
||||
.arg(s9pk_path)
|
||||
.arg(s3dest.as_str())
|
||||
.capture(false)
|
||||
.invoke(ErrorKind::Network)
|
||||
.await?;
|
||||
crate::registry::package::add::cli_add_package_impl(ctx, s9pk, vec![s3url], false).await
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use clap::Parser;
|
||||
use futures::future::{BoxFuture, ready};
|
||||
use futures::{FutureExt, TryStreamExt};
|
||||
use imbl_value::InternedString;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::OnceCell;
|
||||
@@ -385,13 +386,17 @@ impl ImageSource {
|
||||
pub fn ingredients(&self) -> Vec<PathBuf> {
|
||||
match self {
|
||||
Self::Packed => Vec::new(),
|
||||
Self::DockerBuild { dockerfile, .. } => {
|
||||
vec![
|
||||
dockerfile
|
||||
Self::DockerBuild {
|
||||
dockerfile,
|
||||
workdir,
|
||||
..
|
||||
} => {
|
||||
vec![dockerfile.clone().unwrap_or_else(|| {
|
||||
workdir
|
||||
.as_deref()
|
||||
.unwrap_or(Path::new("Dockerfile"))
|
||||
.to_owned(),
|
||||
]
|
||||
.unwrap_or(Path::new("."))
|
||||
.join("Dockerfile")
|
||||
})]
|
||||
}
|
||||
Self::DockerTag(_) => Vec::new(),
|
||||
}
|
||||
@@ -682,7 +687,7 @@ pub async fn pack(ctx: CliContext, params: PackParams) -> Result<(), Error> {
|
||||
let manifest = s9pk.as_manifest_mut();
|
||||
manifest.git_hash = Some(GitHash::from_path(params.path()).await?);
|
||||
if !params.arch.is_empty() {
|
||||
let arches = match manifest.hardware_requirements.arch.take() {
|
||||
let arches: BTreeSet<InternedString> = match manifest.hardware_requirements.arch.take() {
|
||||
Some(a) => params
|
||||
.arch
|
||||
.iter()
|
||||
@@ -691,10 +696,41 @@ pub async fn pack(ctx: CliContext, params: PackParams) -> Result<(), Error> {
|
||||
.collect(),
|
||||
None => params.arch.iter().cloned().collect(),
|
||||
};
|
||||
manifest
|
||||
.images
|
||||
.values_mut()
|
||||
.for_each(|c| c.arch = c.arch.intersection(&arches).cloned().collect());
|
||||
if arches.is_empty() {
|
||||
return Err(Error::new(
|
||||
eyre!(
|
||||
"none of the requested architectures ({:?}) are supported by this package",
|
||||
params.arch
|
||||
),
|
||||
ErrorKind::InvalidRequest,
|
||||
));
|
||||
}
|
||||
manifest.images.iter_mut().for_each(|(id, c)| {
|
||||
let filtered = c
|
||||
.arch
|
||||
.intersection(&arches)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if filtered.is_empty() {
|
||||
if let Some(arch) = &c.emulate_missing_as {
|
||||
tracing::warn!(
|
||||
"ImageId {} is not available for {}, emulating as {}",
|
||||
id,
|
||||
arches.iter().join("/"),
|
||||
arch
|
||||
);
|
||||
c.arch = [arch.clone()].into_iter().collect();
|
||||
} else {
|
||||
tracing::error!(
|
||||
"ImageId {} is not available for {}",
|
||||
id,
|
||||
arches.iter().join("/"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
c.arch = filtered;
|
||||
}
|
||||
});
|
||||
manifest.hardware_requirements.arch = Some(arches);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,13 @@ pub fn update_tasks(
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("{}", t!("service.action.action-request-invalid-state", task = format!("{:?}", v.task)));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!(
|
||||
"service.action.action-request-invalid-state",
|
||||
task = format!("{:?}", v.task)
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -151,7 +157,10 @@ impl Handler<RunAction> for ServiceActor {
|
||||
.de()?;
|
||||
if matches!(&action.visibility, ActionVisibility::Disabled(_)) {
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("service.action.action-is-disabled", action_id = action_id)),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!("service.action.action-is-disabled", action_id = action_id)
|
||||
),
|
||||
ErrorKind::Action,
|
||||
));
|
||||
}
|
||||
@@ -162,7 +171,13 @@ impl Handler<RunAction> for ServiceActor {
|
||||
_ => false,
|
||||
} {
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("service.action.service-not-in-allowed-status", action_id = action_id)),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"service.action.service-not-in-allowed-status",
|
||||
action_id = action_id
|
||||
)
|
||||
),
|
||||
ErrorKind::Action,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -181,7 +181,10 @@ async fn run_action(
|
||||
|
||||
if package_id != &context.seed.id {
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("service.effects.action.calling-actions-on-other-packages-unsupported")),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!("service.effects.action.calling-actions-on-other-packages-unsupported")
|
||||
),
|
||||
ErrorKind::InvalidRequest,
|
||||
));
|
||||
context
|
||||
@@ -226,7 +229,10 @@ async fn create_task(
|
||||
TaskCondition::InputNotMatches => {
|
||||
let Some(input) = task.input.as_ref() else {
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("service.effects.action.input-not-matches-requires-input")),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!("service.effects.action.input-not-matches-requires-input")
|
||||
),
|
||||
ErrorKind::InvalidRequest,
|
||||
));
|
||||
};
|
||||
@@ -244,7 +250,12 @@ async fn create_task(
|
||||
else {
|
||||
return Err(Error::new(
|
||||
eyre!(
|
||||
"{}", t!("service.effects.action.action-has-no-input", action_id = task.action_id, package_id = task.package_id)
|
||||
"{}",
|
||||
t!(
|
||||
"service.effects.action.action-has-no-input",
|
||||
action_id = task.action_id,
|
||||
package_id = task.package_id
|
||||
)
|
||||
),
|
||||
ErrorKind::InvalidRequest,
|
||||
));
|
||||
|
||||
@@ -79,7 +79,7 @@ pub async fn mount(
|
||||
}
|
||||
|
||||
IdMapped::new(
|
||||
Bind::new(source).with_type(filetype),
|
||||
Bind::new(source).with_type(filetype).recursive(true),
|
||||
IdMap::stack(
|
||||
vec![IdMap {
|
||||
from_id: 0,
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::rpc_continuations::Guid;
|
||||
use crate::service::effects::prelude::*;
|
||||
use crate::service::persistent_container::Subcontainer;
|
||||
use crate::util::Invoke;
|
||||
use crate::util::io::write_file_owned_atomic;
|
||||
|
||||
pub const NVIDIA_OVERLAY_PATH: &str = "/var/tmp/startos/nvidia-overlay";
|
||||
pub const NVIDIA_OVERLAY_DEBIAN: &str = "/var/tmp/startos/nvidia-overlay/debian";
|
||||
@@ -94,7 +95,7 @@ pub async fn create_subcontainer_fs(
|
||||
.cloned()
|
||||
{
|
||||
let guid = Guid::new();
|
||||
let rootfs_dir = context
|
||||
let lxc_container = context
|
||||
.seed
|
||||
.persistent_container
|
||||
.lxc_container
|
||||
@@ -104,8 +105,9 @@ pub async fn create_subcontainer_fs(
|
||||
eyre!("PersistentContainer has been destroyed"),
|
||||
ErrorKind::Incoherent,
|
||||
)
|
||||
})?
|
||||
.rootfs_dir();
|
||||
})?;
|
||||
let container_guid = &lxc_container.guid;
|
||||
let rootfs_dir = lxc_container.rootfs_dir();
|
||||
let mountpoint = rootfs_dir
|
||||
.join("media/startos/subcontainers")
|
||||
.join(guid.as_ref());
|
||||
@@ -154,6 +156,20 @@ pub async fn create_subcontainer_fs(
|
||||
.arg(&mountpoint)
|
||||
.invoke(ErrorKind::Filesystem)
|
||||
.await?;
|
||||
write_file_owned_atomic(
|
||||
mountpoint.join("etc/hostname"),
|
||||
format!("{container_guid}\n"),
|
||||
100000,
|
||||
100000,
|
||||
)
|
||||
.await?;
|
||||
write_file_owned_atomic(
|
||||
mountpoint.join("etc/hosts"),
|
||||
format!("127.0.0.1\tlocalhost\n127.0.1.1\t{container_guid}\n::1\tlocalhost ip6-localhost ip6-loopback\n"),
|
||||
100000,
|
||||
100000,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("Mounted overlay {guid} for {image_id}");
|
||||
context
|
||||
.seed
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::ffi::{OsStr, OsString, c_int};
|
||||
use std::fs::File;
|
||||
use std::io::{IsTerminal, Read};
|
||||
use std::io::{BufRead, BufReader, IsTerminal, Read};
|
||||
use std::os::unix::process::{CommandExt, ExitStatusExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command as StdCommand, Stdio};
|
||||
@@ -146,95 +145,160 @@ impl ExecParams {
|
||||
|
||||
let mut cmd = StdCommand::new(command);
|
||||
|
||||
let passwd = std::fs::read_to_string(chroot.join("etc/passwd"))
|
||||
.with_ctx(|_| (ErrorKind::Filesystem, "read /etc/passwd"))
|
||||
.log_err()
|
||||
.unwrap_or_default();
|
||||
let mut home = None;
|
||||
let mut uid = Err(None);
|
||||
let mut gid = Err(None);
|
||||
let mut needs_home = true;
|
||||
|
||||
if let Some((uid, gid)) =
|
||||
if let Some(uid) = user.as_deref().and_then(|u| u.parse::<u32>().ok()) {
|
||||
Some((uid, uid))
|
||||
} else if let Some((uid, gid)) = user
|
||||
.as_deref()
|
||||
.and_then(|u| u.split_once(":"))
|
||||
.and_then(|(u, g)| Some((u.parse::<u32>().ok()?, g.parse::<u32>().ok()?)))
|
||||
{
|
||||
Some((uid, gid))
|
||||
} else if let Some(user) = user {
|
||||
Some(
|
||||
if let Some((uid, gid)) = passwd.lines().find_map(|l| {
|
||||
let l = l.trim();
|
||||
let mut split = l.split(":");
|
||||
if user != split.next()? {
|
||||
return None;
|
||||
}
|
||||
|
||||
split.next(); // throw away x
|
||||
let uid = split.next()?.parse().ok()?;
|
||||
let gid = split.next()?.parse().ok()?;
|
||||
split.next(); // throw away group name
|
||||
|
||||
home = split.next();
|
||||
|
||||
Some((uid, gid))
|
||||
// uid gid
|
||||
}) {
|
||||
(uid, gid)
|
||||
} else if user == "root" {
|
||||
(0, 0)
|
||||
} else {
|
||||
None.or_not_found(lazy_format!("{user} in /etc/passwd"))?
|
||||
},
|
||||
)
|
||||
if let Some(user) = user {
|
||||
if let Some((u, g)) = user.split_once(":") {
|
||||
uid = Err(Some(u));
|
||||
gid = Err(Some(g));
|
||||
} else {
|
||||
None
|
||||
uid = Err(Some(user));
|
||||
}
|
||||
{
|
||||
if home.is_none() {
|
||||
home = passwd.lines().find_map(|l| {
|
||||
let l = l.trim();
|
||||
let mut split = l.split(":");
|
||||
|
||||
split.next(); // throw away user name
|
||||
split.next(); // throw away x
|
||||
if split.next()?.parse::<u32>().ok()? != uid {
|
||||
return None;
|
||||
}
|
||||
split.next(); // throw away gid
|
||||
split.next(); // throw away group name
|
||||
|
||||
split.next()
|
||||
})
|
||||
};
|
||||
std::os::unix::fs::chown("/proc/self/fd/0", Some(uid), Some(gid)).ok();
|
||||
std::os::unix::fs::chown("/proc/self/fd/1", Some(uid), Some(gid)).ok();
|
||||
std::os::unix::fs::chown("/proc/self/fd/2", Some(uid), Some(gid)).ok();
|
||||
cmd.uid(uid);
|
||||
cmd.gid(gid);
|
||||
} else {
|
||||
home = Some("/root");
|
||||
}
|
||||
cmd.env("HOME", home.unwrap_or("/"));
|
||||
|
||||
let env_string = if let Some(env_file) = &env_file {
|
||||
std::fs::read_to_string(env_file)
|
||||
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("read {env:?}")))?
|
||||
} else {
|
||||
Default::default()
|
||||
if let Some(u) = uid.err().flatten().and_then(|u| u.parse::<u32>().ok()) {
|
||||
uid = Ok(u);
|
||||
}
|
||||
if let Some(g) = gid.err().flatten().and_then(|g| g.parse::<u32>().ok()) {
|
||||
gid = Ok(g);
|
||||
}
|
||||
|
||||
let mut update_env = |line: &str| {
|
||||
if let Some((k, v)) = line.split_once("=") {
|
||||
needs_home &= k != "HOME";
|
||||
cmd.env(k, v);
|
||||
} else {
|
||||
tracing::warn!("Invalid line in env: {line}");
|
||||
}
|
||||
};
|
||||
let env = env_string
|
||||
.lines()
|
||||
.chain(env.iter().map(|l| l.as_str()))
|
||||
.map(|l| l.trim())
|
||||
.filter_map(|l| l.split_once("="))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
if let Some(f) = env_file {
|
||||
let mut lines = BufReader::new(
|
||||
File::open(&f).with_ctx(|_| (ErrorKind::Filesystem, format!("open r {f:?}")))?,
|
||||
)
|
||||
.lines();
|
||||
while let Some(line) = lines.next().transpose()? {
|
||||
update_env(&line);
|
||||
}
|
||||
}
|
||||
|
||||
for line in env {
|
||||
update_env(&line);
|
||||
}
|
||||
|
||||
let needs_gid = Err(None) == gid;
|
||||
let mut username = InternedString::intern("root");
|
||||
let mut handle_passwd_line = |line: &str| -> Option<()> {
|
||||
let l = line.trim();
|
||||
let mut split = l.split(":");
|
||||
let user = split.next()?;
|
||||
match uid {
|
||||
Err(Some(u)) if u != user => return None,
|
||||
_ => (),
|
||||
}
|
||||
split.next(); // throw away x
|
||||
let u: u32 = split.next()?.parse().ok()?;
|
||||
match uid {
|
||||
Err(Some(_)) => uid = Ok(u),
|
||||
Err(None) if u == 0 => uid = Ok(u),
|
||||
Ok(uid) if uid != u => return None,
|
||||
_ => (),
|
||||
}
|
||||
|
||||
username = user.into();
|
||||
|
||||
if !needs_gid && !needs_home {
|
||||
return Some(());
|
||||
}
|
||||
let g = split.next()?;
|
||||
if needs_gid {
|
||||
gid = Ok(g.parse().ok()?);
|
||||
}
|
||||
|
||||
if needs_home {
|
||||
split.next(); // throw away group name
|
||||
|
||||
let home = split.next()?;
|
||||
|
||||
cmd.env("HOME", home);
|
||||
}
|
||||
|
||||
Some(())
|
||||
};
|
||||
|
||||
let mut lines = BufReader::new(
|
||||
File::open(chroot.join("etc/passwd"))
|
||||
.with_ctx(|_| (ErrorKind::Filesystem, format!("open r /etc/passwd")))?,
|
||||
)
|
||||
.lines();
|
||||
while let Some(line) = lines.next().transpose()? {
|
||||
if handle_passwd_line(&line).is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut groups = Vec::new();
|
||||
let mut handle_group_line = |line: &str| -> Option<()> {
|
||||
let l = line.trim();
|
||||
let mut split = l.split(":");
|
||||
let name = split.next()?;
|
||||
split.next()?; // throw away x
|
||||
let g = split.next()?.parse::<u32>().ok()?;
|
||||
match gid {
|
||||
Err(Some(n)) if n == name => gid = Ok(g),
|
||||
_ => (),
|
||||
}
|
||||
let users = split.next()?;
|
||||
if users.split(",").any(|u| u == &*username) {
|
||||
groups.push(nix::unistd::Gid::from_raw(g));
|
||||
}
|
||||
Some(())
|
||||
};
|
||||
let mut lines = BufReader::new(
|
||||
File::open(chroot.join("etc/group"))
|
||||
.with_ctx(|_| (ErrorKind::Filesystem, format!("open r /etc/group")))?,
|
||||
)
|
||||
.lines();
|
||||
while let Some(line) = lines.next().transpose()? {
|
||||
if handle_group_line(&line).is_none() {
|
||||
tracing::warn!("Invalid /etc/group line: {line}");
|
||||
}
|
||||
}
|
||||
|
||||
std::os::unix::fs::chroot(chroot)
|
||||
.with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("chroot {chroot:?}")))?;
|
||||
cmd.args(args);
|
||||
for (k, v) in env {
|
||||
cmd.env(k, v);
|
||||
if let Ok(uid) = uid {
|
||||
if uid != 0 {
|
||||
std::os::unix::fs::chown("/proc/self/fd/0", Some(uid), gid.ok()).ok();
|
||||
std::os::unix::fs::chown("/proc/self/fd/1", Some(uid), gid.ok()).ok();
|
||||
std::os::unix::fs::chown("/proc/self/fd/2", Some(uid), gid.ok()).ok();
|
||||
}
|
||||
}
|
||||
// Handle credential changes in pre_exec to control the order:
|
||||
// setgroups must happen before setgid/setuid (requires CAP_SETGID)
|
||||
{
|
||||
let set_uid = uid.ok();
|
||||
let set_gid = gid.ok();
|
||||
unsafe {
|
||||
cmd.pre_exec(move || {
|
||||
if !groups.is_empty() {
|
||||
nix::unistd::setgroups(&groups)
|
||||
.map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
|
||||
}
|
||||
if let Some(gid) = set_gid {
|
||||
nix::unistd::setgid(nix::unistd::Gid::from_raw(gid))
|
||||
.map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
|
||||
}
|
||||
if let Some(uid) = set_uid {
|
||||
nix::unistd::setuid(nix::unistd::Uid::from_raw(uid))
|
||||
.map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
cmd.args(args);
|
||||
|
||||
if let Some(workdir) = workdir {
|
||||
cmd.current_dir(workdir);
|
||||
|
||||
@@ -28,7 +28,6 @@ use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
|
||||
use ts_rs::TS;
|
||||
use url::Url;
|
||||
|
||||
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::db::model::package::{
|
||||
InstalledState, ManifestPreference, PackageState, PackageStateMatchModelRef, TaskSeverity,
|
||||
@@ -51,6 +50,7 @@ use crate::util::io::{AsyncReadStream, AtomicFile, TermSize, delete_file};
|
||||
use crate::util::net::WebSocket;
|
||||
use crate::util::serde::Pem;
|
||||
use crate::util::sync::SyncMutex;
|
||||
use crate::util::tui::choose;
|
||||
use crate::volume::data_dir;
|
||||
use crate::{ActionId, CAP_1_KiB, DATA_DIR, HostId, ImageId, PackageId};
|
||||
|
||||
@@ -184,7 +184,10 @@ impl ServiceRef {
|
||||
Arc::try_unwrap(service.seed)
|
||||
.map_err(|_| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("service.mod.service-actor-seed-held-after-shutdown")),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!("service.mod.service-actor-seed-held-after-shutdown")
|
||||
),
|
||||
ErrorKind::Unknown,
|
||||
)
|
||||
})?
|
||||
@@ -376,12 +379,16 @@ impl Service {
|
||||
{
|
||||
Ok(PackageState::Installed(InstalledState { manifest }))
|
||||
} else {
|
||||
Err(Error::new(eyre!("{}", t!("service.mod.race-condition-detected")), ErrorKind::Database))
|
||||
Err(Error::new(
|
||||
eyre!("{}", t!("service.mod.race-condition-detected")),
|
||||
ErrorKind::Database,
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.await.result?;
|
||||
.await
|
||||
.result?;
|
||||
handle_installed(s9pk).await
|
||||
}
|
||||
PackageStateMatchModelRef::Removing(_) | PackageStateMatchModelRef::Restoring(_) => {
|
||||
@@ -447,7 +454,13 @@ impl Service {
|
||||
handle_installed(S9pk::open(s9pk_path, Some(id)).await?).await
|
||||
}
|
||||
PackageStateMatchModelRef::Error(e) => Err(Error::new(
|
||||
eyre!("{}", t!("service.mod.failed-to-parse-package-data-entry", error = format!("{e:?}"))),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"service.mod.failed-to-parse-package-data-entry",
|
||||
error = format!("{e:?}")
|
||||
)
|
||||
),
|
||||
ErrorKind::Deserialization,
|
||||
)),
|
||||
}
|
||||
@@ -553,7 +566,11 @@ impl Service {
|
||||
true
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"{}", t!("service.mod.deleting-task-action-no-longer-exists", id = id)
|
||||
"{}",
|
||||
t!(
|
||||
"service.mod.deleting-task-action-no-longer-exists",
|
||||
id = id
|
||||
)
|
||||
);
|
||||
false
|
||||
}
|
||||
@@ -693,6 +710,19 @@ pub async fn rebuild(ctx: RpcContext, RebuildParams { id }: RebuildParams) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct SubcontainerInfo {
|
||||
pub id: Guid,
|
||||
pub name: InternedString,
|
||||
pub image_id: ImageId,
|
||||
}
|
||||
impl std::fmt::Display for SubcontainerInfo {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let SubcontainerInfo { id, name, image_id } = self;
|
||||
write!(f, "{id} => Name: {name}; Image: {image_id}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttachParams {
|
||||
@@ -706,7 +736,7 @@ pub struct AttachParams {
|
||||
#[serde(rename = "__Auth_session")]
|
||||
session: Option<InternedString>,
|
||||
#[ts(type = "string | null")]
|
||||
subcontainer: Option<InternedString>,
|
||||
subcontainer: Option<Guid>,
|
||||
#[ts(type = "string | null")]
|
||||
name: Option<InternedString>,
|
||||
#[ts(type = "string | null")]
|
||||
@@ -729,7 +759,7 @@ pub async fn attach(
|
||||
user,
|
||||
}: AttachParams,
|
||||
) -> Result<Guid, Error> {
|
||||
let (container_id, subcontainer_id, image_id, workdir, root_command) = {
|
||||
let (container_id, subcontainer_id, image_id, user, workdir, root_command) = {
|
||||
let id = &id;
|
||||
|
||||
let service = ctx.services.get(id).await;
|
||||
@@ -770,13 +800,6 @@ pub async fn attach(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let format_subcontainer_pair = |(guid, wrapper): (&Guid, &Subcontainer)| {
|
||||
format!(
|
||||
"{guid} imageId: {image_id} name: \"{name}\"",
|
||||
name = &wrapper.name,
|
||||
image_id = &wrapper.image_id
|
||||
)
|
||||
};
|
||||
let Some((subcontainer_id, image_id)) = subcontainer_ids
|
||||
.first()
|
||||
.map::<(Guid, ImageId), _>(|&x| (x.0.clone(), x.1.image_id.clone()))
|
||||
@@ -787,14 +810,17 @@ pub async fn attach(
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.map(format_subcontainer_pair)
|
||||
.join("\n");
|
||||
.map(|(g, s)| SubcontainerInfo {
|
||||
id: g.clone(),
|
||||
name: s.name.clone(),
|
||||
image_id: s.image_id.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
return Err(Error::new(
|
||||
eyre!(
|
||||
"{}", t!("service.mod.no-matching-subcontainers", id = id, subcontainers = subcontainers)
|
||||
),
|
||||
eyre!("{}", t!("service.mod.no-matching-subcontainers", id = id)),
|
||||
ErrorKind::NotFound,
|
||||
));
|
||||
)
|
||||
.with_info(to_value(&subcontainers)?));
|
||||
};
|
||||
|
||||
let passwd = root_dir
|
||||
@@ -814,31 +840,39 @@ pub async fn attach(
|
||||
)
|
||||
.with_kind(ErrorKind::Deserialization)?;
|
||||
|
||||
let root_command = get_passwd_command(
|
||||
passwd,
|
||||
user.as_deref()
|
||||
.or_else(|| image_meta["user"].as_str())
|
||||
.unwrap_or("root"),
|
||||
)
|
||||
.await;
|
||||
let user = user
|
||||
.clone()
|
||||
.or_else(|| image_meta["user"].as_str().map(InternedString::intern))
|
||||
.unwrap_or_else(|| InternedString::intern("root"));
|
||||
|
||||
let root_command = get_passwd_command(passwd, &*user).await;
|
||||
|
||||
let workdir = image_meta["workdir"].as_str().map(|s| s.to_owned());
|
||||
|
||||
if subcontainer_ids.len() > 1 {
|
||||
let subcontainer_ids = subcontainer_ids
|
||||
let subcontainers = subcontainer_ids
|
||||
.into_iter()
|
||||
.map(format_subcontainer_pair)
|
||||
.join("\n");
|
||||
.map(|(g, s)| SubcontainerInfo {
|
||||
id: g.clone(),
|
||||
name: s.name.clone(),
|
||||
image_id: s.image_id.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("service.mod.multiple-subcontainers-found", id = id, subcontainer_ids = subcontainer_ids)),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!("service.mod.multiple-subcontainers-found", id = id,)
|
||||
),
|
||||
ErrorKind::InvalidRequest,
|
||||
));
|
||||
)
|
||||
.with_info(to_value(&subcontainers)?));
|
||||
}
|
||||
|
||||
(
|
||||
service_ref.container_id()?,
|
||||
subcontainer_id,
|
||||
image_id,
|
||||
user.into(),
|
||||
workdir,
|
||||
root_command,
|
||||
)
|
||||
@@ -855,7 +889,7 @@ pub async fn attach(
|
||||
pty_size: Option<TermSize>,
|
||||
image_id: ImageId,
|
||||
workdir: Option<String>,
|
||||
user: Option<InternedString>,
|
||||
user: InternedString,
|
||||
root_command: &RootCommand,
|
||||
) -> Result<(), Error> {
|
||||
use axum::extract::ws::Message;
|
||||
@@ -876,11 +910,9 @@ pub async fn attach(
|
||||
Path::new("/media/startos/images")
|
||||
.join(image_id)
|
||||
.with_extension("env"),
|
||||
);
|
||||
|
||||
if let Some(user) = user {
|
||||
cmd.arg("--user").arg(&*user);
|
||||
}
|
||||
)
|
||||
.arg("--user")
|
||||
.arg(&*user);
|
||||
|
||||
if let Some(workdir) = workdir {
|
||||
cmd.arg("--workdir").arg(workdir);
|
||||
@@ -1063,45 +1095,6 @@ pub async fn attach(
|
||||
Ok(guid)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListSubcontainersParams {
|
||||
pub id: PackageId,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubcontainerInfo {
|
||||
pub name: InternedString,
|
||||
pub image_id: ImageId,
|
||||
}
|
||||
|
||||
pub async fn list_subcontainers(
|
||||
ctx: RpcContext,
|
||||
ListSubcontainersParams { id }: ListSubcontainersParams,
|
||||
) -> Result<BTreeMap<Guid, SubcontainerInfo>, Error> {
|
||||
let service = ctx.services.get(&id).await;
|
||||
let service_ref = service.as_ref().or_not_found(&id)?;
|
||||
let container = &service_ref.seed.persistent_container;
|
||||
|
||||
let subcontainers = container.subcontainers.lock().await;
|
||||
|
||||
let result: BTreeMap<Guid, SubcontainerInfo> = subcontainers
|
||||
.iter()
|
||||
.map(|(guid, subcontainer)| {
|
||||
(
|
||||
guid.clone(),
|
||||
SubcontainerInfo {
|
||||
name: subcontainer.name.clone(),
|
||||
image_id: subcontainer.image_id.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn get_passwd_command(etc_passwd_path: PathBuf, user: &str) -> RootCommand {
|
||||
async {
|
||||
let mut file = tokio::fs::File::open(etc_passwd_path).await?;
|
||||
@@ -1120,7 +1113,13 @@ async fn get_passwd_command(etc_passwd_path: PathBuf, user: &str) -> RootCommand
|
||||
}
|
||||
}
|
||||
Err(Error::new(
|
||||
eyre!("{}", t!("service.mod.could-not-parse-etc-passwd", contents = contents)),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"service.mod.could-not-parse-etc-passwd",
|
||||
contents = contents
|
||||
)
|
||||
),
|
||||
ErrorKind::Filesystem,
|
||||
))
|
||||
}
|
||||
@@ -1176,23 +1175,34 @@ pub async fn cli_attach(
|
||||
None
|
||||
};
|
||||
|
||||
let method = parent_method.into_iter().chain(method).join(".");
|
||||
let mut params = json!({
|
||||
"id": params.id,
|
||||
"command": params.command,
|
||||
"tty": tty,
|
||||
"stderrTty": stderr.is_terminal(),
|
||||
"ptySize": if tty { TermSize::get_current() } else { None },
|
||||
"subcontainer": params.subcontainer,
|
||||
"imageId": params.image_id,
|
||||
"name": params.name,
|
||||
"user": params.user,
|
||||
});
|
||||
let guid: Guid = from_value(
|
||||
context
|
||||
.call_remote::<RpcContext>(
|
||||
&parent_method.into_iter().chain(method).join("."),
|
||||
json!({
|
||||
"id": params.id,
|
||||
"command": params.command,
|
||||
"tty": tty,
|
||||
"stderrTty": stderr.is_terminal(),
|
||||
"ptySize": if tty { TermSize::get_current() } else { None },
|
||||
"subcontainer": params.subcontainer,
|
||||
"imageId": params.image_id,
|
||||
"name": params.name,
|
||||
"user": params.user,
|
||||
}),
|
||||
)
|
||||
.await?,
|
||||
match context
|
||||
.call_remote::<RpcContext>(&method, params.clone())
|
||||
.await
|
||||
{
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
let prompt = e.to_string();
|
||||
let options: Vec<SubcontainerInfo> = from_value(e.info)?;
|
||||
let choice = choose(&prompt, &options).await?;
|
||||
params["subcontainer"] = to_value(&choice.id)?;
|
||||
context
|
||||
.call_remote::<RpcContext>(&method, params.clone())
|
||||
.await?
|
||||
}
|
||||
},
|
||||
)?;
|
||||
let mut ws = context.ws_continuation(guid).await?;
|
||||
|
||||
|
||||
@@ -364,7 +364,14 @@ impl PersistentContainer {
|
||||
let handle = NonDetachingJoinHandle::from(tokio::spawn(async move {
|
||||
let chown_status = async {
|
||||
let res = server.run_unix(&path, |err| {
|
||||
tracing::error!("{}", t!("service.persistent-container.error-on-unix-socket", path = path.display(), error = err))
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!(
|
||||
"service.persistent-container.error-on-unix-socket",
|
||||
path = path.display(),
|
||||
error = err
|
||||
)
|
||||
)
|
||||
})?;
|
||||
Command::new("chown")
|
||||
.arg("100000:100000")
|
||||
@@ -386,7 +393,10 @@ impl PersistentContainer {
|
||||
}));
|
||||
let shutdown = recv.await.map_err(|_| {
|
||||
Error::new(
|
||||
eyre!("{}", t!("service.persistent-container.unix-socket-server-panicked")),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!("service.persistent-container.unix-socket-server-panicked")
|
||||
),
|
||||
ErrorKind::Unknown,
|
||||
)
|
||||
})??;
|
||||
@@ -473,7 +483,13 @@ impl PersistentContainer {
|
||||
if let Some(destroy) = self.destroy(uninit) {
|
||||
destroy.await?;
|
||||
}
|
||||
tracing::info!("{}", t!("service.persistent-container.service-exited", id = self.s9pk.as_manifest().id));
|
||||
tracing::info!(
|
||||
"{}",
|
||||
t!(
|
||||
"service.persistent-container.service-exited",
|
||||
id = self.s9pk.as_manifest().id
|
||||
)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -47,9 +47,18 @@ impl Actor for ServiceActor {
|
||||
}
|
||||
.await
|
||||
{
|
||||
tracing::error!("{}", t!("service.service-actor.error-synchronizing-state", error = e));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!("service.service-actor.error-synchronizing-state", error = e)
|
||||
);
|
||||
tracing::debug!("{e:?}");
|
||||
tracing::error!("{}", t!("service.service-actor.retrying-in-seconds", seconds = SYNC_RETRY_COOLDOWN_SECONDS));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!(
|
||||
"service.service-actor.retrying-in-seconds",
|
||||
seconds = SYNC_RETRY_COOLDOWN_SECONDS
|
||||
)
|
||||
);
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(SYNC_RETRY_COOLDOWN_SECONDS),
|
||||
async {
|
||||
|
||||
@@ -62,7 +62,13 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(),
|
||||
| PackageState::Removing(InstalledState { manifest }) => manifest,
|
||||
s => {
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("service.uninstall.invalid-package-state-for-cleanup", state = format!("{s:?}"))),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"service.uninstall.invalid-package-state-for-cleanup",
|
||||
state = format!("{s:?}")
|
||||
)
|
||||
),
|
||||
ErrorKind::InvalidRequest,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
use crate::PLATFORM;
|
||||
use crate::context::RpcContext;
|
||||
use crate::disk::main::export;
|
||||
@@ -36,18 +35,33 @@ impl Shutdown {
|
||||
.invoke(crate::ErrorKind::Journald)
|
||||
.await
|
||||
{
|
||||
tracing::error!("{}", t!("shutdown.error-stopping-journald", error = e.to_string()));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!("shutdown.error-stopping-journald", error = e.to_string())
|
||||
);
|
||||
tracing::debug!("{:?}", e);
|
||||
}
|
||||
if let Some(guid) = &self.disk_guid {
|
||||
if let Err(e) = export(guid, crate::DATA_DIR).await {
|
||||
tracing::error!("{}", t!("shutdown.error-exporting-volume-group", error = e.to_string()));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!(
|
||||
"shutdown.error-exporting-volume-group",
|
||||
error = e.to_string()
|
||||
)
|
||||
);
|
||||
tracing::debug!("{:?}", e);
|
||||
}
|
||||
}
|
||||
if &*PLATFORM != "raspberrypi" || self.restart {
|
||||
if let Err(e) = SHUTDOWN.play().await {
|
||||
tracing::error!("{}", t!("shutdown.error-playing-shutdown-song", error = e.to_string()));
|
||||
tracing::error!(
|
||||
"{}",
|
||||
t!(
|
||||
"shutdown.error-playing-shutdown-song",
|
||||
error = e.to_string()
|
||||
)
|
||||
);
|
||||
tracing::debug!("{:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ pub fn tunnel_api<C: Context>() -> ParentHandler<C> {
|
||||
.subcommand("web", super::web::web_api::<C>())
|
||||
.subcommand(
|
||||
"db",
|
||||
super::db::db_api::<C>()
|
||||
.with_about("about.commands-interact-with-db-dump-apply"),
|
||||
super::db::db_api::<C>().with_about("about.commands-interact-with-db-dump-apply"),
|
||||
)
|
||||
.subcommand(
|
||||
"auth",
|
||||
|
||||
@@ -6,6 +6,7 @@ use clap::{ArgAction, Parser};
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use exver::{Version, VersionRange};
|
||||
use futures::TryStreamExt;
|
||||
use imbl::OrdMap;
|
||||
use imbl_value::json;
|
||||
use itertools::Itertools;
|
||||
use patch_db::json_ptr::JsonPointer;
|
||||
@@ -179,7 +180,10 @@ pub async fn cli_update_system(
|
||||
Some(v) => {
|
||||
if let Some(progress) = res.progress {
|
||||
let mut ws = context.ws_continuation(progress).await?;
|
||||
let mut progress = PhasedProgressBar::new(&t!("update.updating-to-version", version = v.to_string()));
|
||||
let mut progress = PhasedProgressBar::new(&t!(
|
||||
"update.updating-to-version",
|
||||
version = v.to_string()
|
||||
));
|
||||
let mut prev = None;
|
||||
while let Some(msg) = ws.try_next().await.with_kind(ErrorKind::Network)? {
|
||||
if let tokio_tungstenite::tungstenite::Message::Text(msg) = msg {
|
||||
@@ -202,7 +206,10 @@ pub async fn cli_update_system(
|
||||
}
|
||||
println!("{}", t!("update.complete-restart-to-apply"))
|
||||
} else {
|
||||
println!("{}", t!("update.updating-to-version", version = v.to_string()))
|
||||
println!(
|
||||
"{}",
|
||||
t!("update.updating-to-version", version = v.to_string())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,6 +246,7 @@ async fn maybe_do_update(
|
||||
let mut available = from_value::<BTreeMap<Version, OsVersionInfo>>(
|
||||
ctx.call_remote_with::<RegistryContext, _>(
|
||||
"os.version.get",
|
||||
OrdMap::new(),
|
||||
json!({
|
||||
"source": current_version,
|
||||
"target": target,
|
||||
|
||||
@@ -248,7 +248,7 @@ impl<'a> Invoke<'a> for ExtendedCommand<'a> {
|
||||
.or(Some(&res.stdout))
|
||||
.filter(|a| !a.is_empty())
|
||||
.and_then(|a| std::str::from_utf8(a).ok())
|
||||
.unwrap_or(&format!("{} exited with code {}", cmd_str, res.status))
|
||||
.unwrap_or(&format!("{} exited with {}", cmd_str, res.status))
|
||||
);
|
||||
Ok(res.stdout)
|
||||
} else {
|
||||
@@ -309,7 +309,7 @@ impl<'a> Invoke<'a> for ExtendedCommand<'a> {
|
||||
.filter(|a| !a.is_empty())
|
||||
.and_then(|a| std::str::from_utf8(a).ok())
|
||||
.unwrap_or(&format!(
|
||||
"{} exited with code {}",
|
||||
"{} exited with {}",
|
||||
cmd.as_std().get_program().to_string_lossy(),
|
||||
res.status
|
||||
))
|
||||
|
||||
@@ -97,7 +97,11 @@ impl WebSocket {
|
||||
if self.ping_state.is_some() {
|
||||
self.fused = true;
|
||||
break Poll::Ready(Some(Err(axum::Error::new(eyre!(
|
||||
"{}", t!("util.net.websocket-ping-timeout", timeout = format!("{PING_TIMEOUT:?}"))
|
||||
"{}",
|
||||
t!(
|
||||
"util.net.websocket-ping-timeout",
|
||||
timeout = format!("{PING_TIMEOUT:?}")
|
||||
)
|
||||
)))));
|
||||
}
|
||||
self.ping_state = Some((false, rand::random()));
|
||||
|
||||
@@ -1151,7 +1151,13 @@ pub fn apply_expr(input: jaq_core::Val, expr: &str) -> Result<jaq_core::Val, Err
|
||||
|
||||
let Some(expr) = expr else {
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("util.serde.failed-to-parse-expression", errors = format!("{:?}", errs))),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"util.serde.failed-to-parse-expression",
|
||||
errors = format!("{:?}", errs)
|
||||
)
|
||||
),
|
||||
crate::ErrorKind::InvalidRequest,
|
||||
));
|
||||
};
|
||||
@@ -1167,7 +1173,13 @@ pub fn apply_expr(input: jaq_core::Val, expr: &str) -> Result<jaq_core::Val, Err
|
||||
|
||||
if !errs.is_empty() {
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("util.serde.failed-to-compile-expression", errors = format!("{:?}", errs))),
|
||||
eyre!(
|
||||
"{}",
|
||||
t!(
|
||||
"util.serde.failed-to-compile-expression",
|
||||
errors = format!("{:?}", errs)
|
||||
)
|
||||
),
|
||||
crate::ErrorKind::InvalidRequest,
|
||||
));
|
||||
};
|
||||
|
||||
@@ -50,7 +50,10 @@ pub async fn prompt<T, E: std::fmt::Display, Parse: FnMut(&str) -> Result<T, E>>
|
||||
}
|
||||
}
|
||||
ReadlineEvent::Eof | ReadlineEvent::Interrupted => {
|
||||
return Err(Error::new(eyre!("{}", t!("util.tui.aborted")), ErrorKind::Cancelled));
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("util.tui.aborted")),
|
||||
ErrorKind::Cancelled,
|
||||
));
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
@@ -83,7 +86,10 @@ pub async fn prompt_multiline<
|
||||
Err(e) => writeln!(&mut rl_ctx.shared_writer, "{e}")?,
|
||||
},
|
||||
ReadlineEvent::Eof | ReadlineEvent::Interrupted => {
|
||||
return Err(Error::new(eyre!("{}", t!("util.tui.aborted")), ErrorKind::Cancelled));
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("util.tui.aborted")),
|
||||
ErrorKind::Cancelled,
|
||||
));
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
@@ -119,7 +125,10 @@ pub async fn choose_custom_display<'t, T>(
|
||||
.await
|
||||
.map_err(map_miette)?;
|
||||
if choice.len() < 1 {
|
||||
return Err(Error::new(eyre!("{}", t!("util.tui.aborted")), ErrorKind::Cancelled));
|
||||
return Err(Error::new(
|
||||
eyre!("{}", t!("util.tui.aborted")),
|
||||
ErrorKind::Cancelled,
|
||||
));
|
||||
}
|
||||
let (idx, choice_str) = string_choices
|
||||
.iter()
|
||||
|
||||
@@ -58,8 +58,9 @@ mod v0_4_0_alpha_15;
|
||||
mod v0_4_0_alpha_16;
|
||||
mod v0_4_0_alpha_17;
|
||||
mod v0_4_0_alpha_18;
|
||||
mod v0_4_0_alpha_19;
|
||||
|
||||
pub type Current = v0_4_0_alpha_18::Version; // VERSION_BUMP
|
||||
pub type Current = v0_4_0_alpha_19::Version; // VERSION_BUMP
|
||||
|
||||
impl Current {
|
||||
#[instrument(skip(self, db))]
|
||||
@@ -179,7 +180,8 @@ enum Version {
|
||||
V0_4_0_alpha_15(Wrapper<v0_4_0_alpha_15::Version>),
|
||||
V0_4_0_alpha_16(Wrapper<v0_4_0_alpha_16::Version>),
|
||||
V0_4_0_alpha_17(Wrapper<v0_4_0_alpha_17::Version>),
|
||||
V0_4_0_alpha_18(Wrapper<v0_4_0_alpha_18::Version>), // VERSION_BUMP
|
||||
V0_4_0_alpha_18(Wrapper<v0_4_0_alpha_18::Version>),
|
||||
V0_4_0_alpha_19(Wrapper<v0_4_0_alpha_19::Version>), // VERSION_BUMP
|
||||
Other(exver::Version),
|
||||
}
|
||||
|
||||
@@ -240,7 +242,8 @@ impl Version {
|
||||
Self::V0_4_0_alpha_15(v) => DynVersion(Box::new(v.0)),
|
||||
Self::V0_4_0_alpha_16(v) => DynVersion(Box::new(v.0)),
|
||||
Self::V0_4_0_alpha_17(v) => DynVersion(Box::new(v.0)),
|
||||
Self::V0_4_0_alpha_18(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
|
||||
Self::V0_4_0_alpha_18(v) => DynVersion(Box::new(v.0)),
|
||||
Self::V0_4_0_alpha_19(v) => DynVersion(Box::new(v.0)), // VERSION_BUMP
|
||||
Self::Other(v) => {
|
||||
return Err(Error::new(
|
||||
eyre!("unknown version {v}"),
|
||||
@@ -293,7 +296,8 @@ impl Version {
|
||||
Version::V0_4_0_alpha_15(Wrapper(x)) => x.semver(),
|
||||
Version::V0_4_0_alpha_16(Wrapper(x)) => x.semver(),
|
||||
Version::V0_4_0_alpha_17(Wrapper(x)) => x.semver(),
|
||||
Version::V0_4_0_alpha_18(Wrapper(x)) => x.semver(), // VERSION_BUMP
|
||||
Version::V0_4_0_alpha_18(Wrapper(x)) => x.semver(),
|
||||
Version::V0_4_0_alpha_19(Wrapper(x)) => x.semver(), // VERSION_BUMP
|
||||
Version::Other(x) => x.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
37
core/src/version/v0_4_0_alpha_19.rs
Normal file
37
core/src/version/v0_4_0_alpha_19.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use exver::{PreReleaseSegment, VersionRange};
|
||||
|
||||
use super::v0_3_5::V0_3_0_COMPAT;
|
||||
use super::{VersionT, v0_4_0_alpha_18};
|
||||
use crate::prelude::*;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref V0_4_0_alpha_19: exver::Version = exver::Version::new(
|
||||
[0, 4, 0],
|
||||
[PreReleaseSegment::String("alpha".into()), 19.into()]
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Version;
|
||||
|
||||
impl VersionT for Version {
|
||||
type Previous = v0_4_0_alpha_18::Version;
|
||||
type PreUpRes = ();
|
||||
|
||||
async fn pre_up(self) -> Result<Self::PreUpRes, Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn semver(self) -> exver::Version {
|
||||
V0_4_0_alpha_19.clone()
|
||||
}
|
||||
fn compat(self) -> &'static VersionRange {
|
||||
&V0_3_0_COMPAT
|
||||
}
|
||||
#[instrument(skip_all)]
|
||||
fn up(self, _db: &mut Value, _: Self::PreUpRes) -> Result<Value, Error> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
fn down(self, _db: &mut Value) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -25,151 +25,560 @@ import {
|
||||
ActionResult,
|
||||
} from "./types"
|
||||
|
||||
/** Used to reach out from the pure js runtime */
|
||||
|
||||
/**
|
||||
* The Effects interface is the primary mechanism for a StartOS service to interact
|
||||
* with the host operating system. All system operations—file I/O, networking,
|
||||
* health reporting, dependency management, and more—are performed through Effects.
|
||||
*
|
||||
* Effects are passed to all lifecycle functions (main, init, uninit, actions, etc.)
|
||||
* and provide a controlled, sandboxed API for service operations.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const main = sdk.setupMain(async ({ effects }) => {
|
||||
* // Use effects to interact with the system
|
||||
* const smtp = await effects.getSystemSmtp({})
|
||||
* await effects.setHealth({ name: 'server', result: { result: 'success' } })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export type Effects = {
|
||||
/**
|
||||
* A unique identifier for the current event/request context.
|
||||
* Returns null when not executing within an event context.
|
||||
* Useful for correlating logs and tracking request flows.
|
||||
*/
|
||||
readonly eventId: string | null
|
||||
|
||||
/**
|
||||
* Creates a child Effects context with a namespaced identifier.
|
||||
* Child contexts inherit the parent's capabilities but have their own
|
||||
* event tracking namespace, useful for organizing complex operations.
|
||||
*
|
||||
* @param name - The name to append to the context namespace
|
||||
* @returns A new Effects instance scoped to the child context
|
||||
*/
|
||||
child: (name: string) => Effects
|
||||
|
||||
/**
|
||||
* Internal retry mechanism for `.const()` operations.
|
||||
* Called automatically when a const operation needs to be retried
|
||||
* due to dependency changes. Not typically used directly by service developers.
|
||||
*/
|
||||
constRetry?: () => void
|
||||
|
||||
/**
|
||||
* Indicates whether the Effects instance is currently within a valid execution context.
|
||||
* Returns false if the context has been destroyed or left.
|
||||
*/
|
||||
isInContext: boolean
|
||||
|
||||
/**
|
||||
* Registers a cleanup callback to be invoked when leaving the current context.
|
||||
* Use this to clean up resources, close connections, or perform other teardown
|
||||
* operations when the service or action completes.
|
||||
*
|
||||
* @param fn - Cleanup function to execute on context exit. Can return void, null, or undefined.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* effects.onLeaveContext(() => {
|
||||
* socket.close()
|
||||
* clearInterval(healthCheckInterval)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
onLeaveContext: (fn: () => void | null | undefined) => void
|
||||
|
||||
/**
|
||||
* Clears registered callbacks by their internal IDs.
|
||||
* Used for cleanup when callbacks are no longer needed.
|
||||
*
|
||||
* @param options - Either `{ only: number[] }` to clear specific callbacks,
|
||||
* or `{ except: number[] }` to clear all except the specified ones
|
||||
* @returns Promise resolving to null on completion
|
||||
*/
|
||||
clearCallbacks: (
|
||||
options: { only: number[] } | { except: number[] },
|
||||
) => Promise<null>
|
||||
|
||||
// action
|
||||
/**
|
||||
* Action-related methods for defining, invoking, and managing user-callable operations.
|
||||
* Actions appear in the StartOS UI and can be triggered by users or other services.
|
||||
*/
|
||||
action: {
|
||||
/** Define an action that can be invoked by a user or service */
|
||||
/**
|
||||
* Exports an action to make it available in the StartOS UI.
|
||||
* Call this during initialization to register actions that users can invoke.
|
||||
*
|
||||
* @param options.id - Unique identifier for the action
|
||||
* @param options.metadata - Action configuration including name, description, input spec, and visibility
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
export(options: { id: ActionId; metadata: ActionMetadata }): Promise<null>
|
||||
/** Remove all exported actions */
|
||||
|
||||
/**
|
||||
* Removes all exported actions except those specified.
|
||||
* Typically called during initialization before re-registering current actions.
|
||||
*
|
||||
* @param options.except - Array of action IDs to keep (not remove)
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
clear(options: { except: ActionId[] }): Promise<null>
|
||||
|
||||
/**
|
||||
* Retrieves the previously submitted input for an action.
|
||||
* Useful for pre-filling forms with the last-used values.
|
||||
*
|
||||
* @param options.packageId - Package ID (defaults to current package if omitted)
|
||||
* @param options.actionId - The action whose input to retrieve
|
||||
* @returns Promise resolving to the stored input, or null if none exists
|
||||
*/
|
||||
getInput(options: {
|
||||
packageId?: PackageId
|
||||
actionId: ActionId
|
||||
}): Promise<ActionInput | null>
|
||||
|
||||
/**
|
||||
* Programmatically invokes an action on this or another service.
|
||||
* Enables service-to-service communication and automation.
|
||||
*
|
||||
* @param options.packageId - Target package ID (defaults to current package if omitted)
|
||||
* @param options.actionId - The action to invoke
|
||||
* @param options.input - Input data matching the action's input specification
|
||||
* @returns Promise resolving to the action result, or null if the action doesn't exist
|
||||
*/
|
||||
run<Input extends Record<string, unknown>>(options: {
|
||||
packageId?: PackageId
|
||||
actionId: ActionId
|
||||
input?: Input
|
||||
}): Promise<ActionResult | null>
|
||||
|
||||
/**
|
||||
* Creates a task that appears in the StartOS UI task list.
|
||||
* Tasks are used for long-running operations or required setup steps
|
||||
* that need user attention (e.g., "Create admin user", "Configure backup").
|
||||
*
|
||||
* @param options - Task configuration including ID, name, description, and completion criteria
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
createTask(options: CreateTaskParams): Promise<null>
|
||||
|
||||
/**
|
||||
* Removes tasks from the UI task list.
|
||||
*
|
||||
* @param options - Either `{ only: string[] }` to remove specific tasks,
|
||||
* or `{ except: string[] }` to remove all except specified tasks
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
clearTasks(
|
||||
options: { only: string[] } | { except: string[] },
|
||||
): Promise<null>
|
||||
}
|
||||
|
||||
// control
|
||||
/** restart this service's main function */
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Control Methods - Manage service lifecycle
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Restarts this service's main function.
|
||||
* The current main process will be terminated and a new one started.
|
||||
* Use this after configuration changes that require a restart to take effect.
|
||||
*
|
||||
* @returns Promise resolving to null when the restart has been initiated
|
||||
*/
|
||||
restart(): Promise<null>
|
||||
/** stop this service's main function */
|
||||
|
||||
/**
|
||||
* Gracefully stops this service's main function.
|
||||
* The daemon will receive a termination signal and be given time to clean up.
|
||||
*
|
||||
* @returns Promise resolving to null when shutdown has been initiated
|
||||
*/
|
||||
shutdown(): Promise<null>
|
||||
/** ask the host os what the service's current status is */
|
||||
|
||||
/**
|
||||
* Queries the current status of a service from the host OS.
|
||||
*
|
||||
* @param options.packageId - Package to query (defaults to current package if omitted)
|
||||
* @param options.callback - Optional callback invoked when status changes (for reactive updates)
|
||||
* @returns Promise resolving to the service's current status information
|
||||
*/
|
||||
getStatus(options: {
|
||||
packageId?: PackageId
|
||||
callback?: () => void
|
||||
}): Promise<StatusInfo>
|
||||
/** DEPRECATED: indicate to the host os what runstate the service is in */
|
||||
|
||||
/**
|
||||
* @deprecated This method is deprecated and should not be used.
|
||||
* Health status is now managed through the health check system.
|
||||
*
|
||||
* Previously used to manually indicate the service's run state to the host OS.
|
||||
*/
|
||||
setMainStatus(options: SetMainStatus): Promise<null>
|
||||
|
||||
// dependency
|
||||
/** Set the dependencies of what the service needs, usually run during the inputSpec action as a best practice */
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Dependency Methods - Manage service dependencies and inter-service communication
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Declares the runtime dependencies this service requires.
|
||||
* Dependencies can be marked as "running" (must be actively running) or "exists" (must be installed).
|
||||
* Call this during initialization to ensure dependencies are satisfied before the service starts.
|
||||
*
|
||||
* @param options.dependencies - Array of dependency requirements with package IDs, version ranges, and dependency kind
|
||||
* @returns Promise resolving to null on success
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await effects.setDependencies({
|
||||
* dependencies: [
|
||||
* { packageId: 'bitcoind', versionRange: '>=25.0.0', kind: 'running' },
|
||||
* { packageId: 'lnd', versionRange: '>=0.16.0', kind: 'exists' }
|
||||
* ]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
setDependencies(options: { dependencies: Dependencies }): Promise<null>
|
||||
/** Get the list of the dependencies, both the dynamic set by the effect of setDependencies and the end result any required in the manifest */
|
||||
|
||||
/**
|
||||
* Retrieves the complete list of dependencies for this service.
|
||||
* Includes both statically declared dependencies from the manifest
|
||||
* and dynamically set dependencies from setDependencies().
|
||||
*
|
||||
* @returns Promise resolving to array of all dependency requirements
|
||||
*/
|
||||
getDependencies(): Promise<DependencyRequirement[]>
|
||||
/** Test whether current dependency requirements are satisfied */
|
||||
|
||||
/**
|
||||
* Tests whether the specified or all dependencies are currently satisfied.
|
||||
* Use this to verify dependencies are met before performing operations that require them.
|
||||
*
|
||||
* @param options.packageIds - Specific packages to check (checks all dependencies if omitted)
|
||||
* @returns Promise resolving to array of check results indicating satisfaction status
|
||||
*/
|
||||
checkDependencies(options: {
|
||||
packageIds?: PackageId[]
|
||||
}): Promise<CheckDependenciesResult[]>
|
||||
/** mount a volume of a dependency */
|
||||
|
||||
/**
|
||||
* Mounts a volume from a dependency service into this service's filesystem.
|
||||
* Enables read-only or read-write access to another service's data.
|
||||
*
|
||||
* @param options - Mount configuration including dependency ID, volume ID, mountpoint, and access mode
|
||||
* @returns Promise resolving to the mount path
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Mount bitcoind's data directory for read access
|
||||
* const mountPath = await effects.mount({
|
||||
* dependencyId: 'bitcoind',
|
||||
* volumeId: 'main',
|
||||
* mountpoint: '/mnt/bitcoin',
|
||||
* readonly: true
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
mount(options: MountParams): Promise<string>
|
||||
/** Returns a list of the ids of all installed packages */
|
||||
|
||||
/**
|
||||
* Returns the package IDs of all services currently installed on the system.
|
||||
* Useful for discovering available services for optional integrations.
|
||||
*
|
||||
* @returns Promise resolving to array of installed package IDs
|
||||
*/
|
||||
getInstalledPackages(): Promise<string[]>
|
||||
/** Returns the manifest of a service */
|
||||
|
||||
/**
|
||||
* Retrieves the manifest of another installed service.
|
||||
* Use this to inspect another service's metadata, version, or capabilities.
|
||||
*
|
||||
* @param options.packageId - The package ID to retrieve the manifest for
|
||||
* @param options.callback - Optional callback invoked when the manifest changes (for reactive updates)
|
||||
* @returns Promise resolving to the service's manifest
|
||||
*/
|
||||
getServiceManifest(options: {
|
||||
packageId: PackageId
|
||||
callback?: () => void
|
||||
}): Promise<Manifest>
|
||||
|
||||
// health
|
||||
/** sets the result of a health check */
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Health Methods - Report service health status
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reports the result of a health check to the StartOS UI.
|
||||
* Health checks appear in the service's status panel and indicate operational status.
|
||||
*
|
||||
* @param o - Health check result including the check name and result status (success/failure/starting)
|
||||
* @returns Promise resolving to null on success
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await effects.setHealth({
|
||||
* name: 'web-interface',
|
||||
* result: { result: 'success', message: 'Web UI is accessible' }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
setHealth(o: SetHealth): Promise<null>
|
||||
|
||||
// subcontainer
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Subcontainer Methods - Low-level container filesystem management
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Low-level APIs for managing subcontainer filesystems.
|
||||
* These are typically used internally by the SubContainer class.
|
||||
* Service developers should use `sdk.SubContainer.of()` instead.
|
||||
*/
|
||||
subcontainer: {
|
||||
/** A low level api used by SubContainer */
|
||||
/**
|
||||
* Creates a new container filesystem from a Docker image.
|
||||
* This is a low-level API - prefer using `sdk.SubContainer.of()` for most use cases.
|
||||
*
|
||||
* @param options.imageId - The Docker image ID to create the filesystem from
|
||||
* @param options.name - Optional name for the container (null for anonymous)
|
||||
* @returns Promise resolving to a tuple of [guid, rootPath] for the created filesystem
|
||||
*/
|
||||
createFs(options: {
|
||||
imageId: string
|
||||
name: string | null
|
||||
}): Promise<[string, string]>
|
||||
/** A low level api used by SubContainer */
|
||||
|
||||
/**
|
||||
* Destroys a container filesystem and cleans up its resources.
|
||||
* This is a low-level API - SubContainer handles cleanup automatically.
|
||||
*
|
||||
* @param options.guid - The unique identifier of the filesystem to destroy
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
destroyFs(options: { guid: string }): Promise<null>
|
||||
}
|
||||
|
||||
// net
|
||||
// bind
|
||||
/** Creates a host connected to the specified port with the provided options */
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Network Methods - Port binding, host info, and network configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Binds a network port and creates a host entry for the service.
|
||||
* This makes the port accessible via the StartOS networking layer (Tor, LAN, etc.).
|
||||
*
|
||||
* @param options - Binding configuration including host ID, port, protocol, and network options
|
||||
* @returns Promise resolving to null on success
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await effects.bind({
|
||||
* id: 'webui',
|
||||
* internalPort: 8080,
|
||||
* protocol: 'http'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
bind(options: BindParams): Promise<null>
|
||||
/** Get the port address for a service */
|
||||
|
||||
/**
|
||||
* Gets the network address information for accessing a service's port.
|
||||
* Use this to discover how to connect to another service's exposed port.
|
||||
*
|
||||
* @param options.packageId - Target package (defaults to current package if omitted)
|
||||
* @param options.hostId - The host identifier for the binding
|
||||
* @param options.internalPort - The internal port number
|
||||
* @returns Promise resolving to network info including addresses and ports
|
||||
*/
|
||||
getServicePortForward(options: {
|
||||
packageId?: PackageId
|
||||
hostId: HostId
|
||||
internalPort: number
|
||||
}): Promise<NetInfo>
|
||||
/** Removes all network bindings, called in the setupInputSpec */
|
||||
|
||||
/**
|
||||
* Removes all network bindings except those specified.
|
||||
* Typically called during initialization to clean up stale bindings before re-registering.
|
||||
*
|
||||
* @param options.except - Array of bindings to preserve (by host ID and port)
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
clearBindings(options: {
|
||||
except: { id: HostId; internalPort: number }[]
|
||||
}): Promise<null>
|
||||
// host
|
||||
/** Returns information about the specified host, if it exists */
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Host Info Methods - Query network host and address information
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retrieves detailed information about a network host binding.
|
||||
*
|
||||
* @param options.packageId - Target package (defaults to current package if omitted)
|
||||
* @param options.hostId - The host identifier to query
|
||||
* @param options.callback - Optional callback invoked when host info changes (for reactive updates)
|
||||
* @returns Promise resolving to host information, or null if the host doesn't exist
|
||||
*/
|
||||
getHostInfo(options: {
|
||||
packageId?: PackageId
|
||||
hostId: HostId
|
||||
callback?: () => void
|
||||
}): Promise<Host | null>
|
||||
/** Returns the IP address of the container */
|
||||
|
||||
/**
|
||||
* Returns the internal IP address of the service's container.
|
||||
* Useful for configuring services that need to know their own network address.
|
||||
*
|
||||
* @param options.packageId - Target package (defaults to current package if omitted)
|
||||
* @param options.callback - Optional callback invoked when the IP changes
|
||||
* @returns Promise resolving to the container's IP address string
|
||||
*/
|
||||
getContainerIp(options: {
|
||||
packageId?: PackageId
|
||||
callback?: () => void
|
||||
}): Promise<string>
|
||||
/** Returns the IP address of StartOS */
|
||||
|
||||
/**
|
||||
* Returns the IP address of the StartOS host system.
|
||||
* Useful for services that need to communicate with the host or other system services.
|
||||
*
|
||||
* @returns Promise resolving to the StartOS IP address string
|
||||
*/
|
||||
getOsIp(): Promise<string>
|
||||
// interface
|
||||
/** Creates an interface bound to a specific host and port to show to the user */
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Service Interface Methods - Expose and discover service endpoints
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Exports a service interface that appears in the StartOS UI.
|
||||
* Service interfaces are the user-visible endpoints (web UIs, APIs, etc.) that users
|
||||
* can click to access the service.
|
||||
*
|
||||
* @param options - Interface configuration including ID, name, description, type, and associated host/port
|
||||
* @returns Promise resolving to null on success
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await effects.exportServiceInterface({
|
||||
* id: 'webui',
|
||||
* name: 'Web Interface',
|
||||
* description: 'Access the web dashboard',
|
||||
* type: 'ui',
|
||||
* hostId: 'main',
|
||||
* internalPort: 8080
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
exportServiceInterface(options: ExportServiceInterfaceParams): Promise<null>
|
||||
/** Returns an exported service interface */
|
||||
|
||||
/**
|
||||
* Retrieves information about an exported service interface.
|
||||
*
|
||||
* @param options.packageId - Target package (defaults to current package if omitted)
|
||||
* @param options.serviceInterfaceId - The interface identifier to query
|
||||
* @param options.callback - Optional callback invoked when the interface changes
|
||||
* @returns Promise resolving to the interface info, or null if it doesn't exist
|
||||
*/
|
||||
getServiceInterface(options: {
|
||||
packageId?: PackageId
|
||||
serviceInterfaceId: ServiceInterfaceId
|
||||
callback?: () => void
|
||||
}): Promise<ServiceInterface | null>
|
||||
/** Returns all exported service interfaces for a package */
|
||||
|
||||
/**
|
||||
* Lists all exported service interfaces for a package.
|
||||
* Useful for discovering what endpoints another service exposes.
|
||||
*
|
||||
* @param options.packageId - Target package (defaults to current package if omitted)
|
||||
* @param options.callback - Optional callback invoked when any interface changes
|
||||
* @returns Promise resolving to a record mapping interface IDs to their configurations
|
||||
*/
|
||||
listServiceInterfaces(options: {
|
||||
packageId?: PackageId
|
||||
callback?: () => void
|
||||
}): Promise<Record<ServiceInterfaceId, ServiceInterface>>
|
||||
/** Removes all service interfaces */
|
||||
|
||||
/**
|
||||
* Removes all service interfaces except those specified.
|
||||
* Typically called during initialization to clean up stale interfaces before re-registering.
|
||||
*
|
||||
* @param options.except - Array of interface IDs to preserve
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
clearServiceInterfaces(options: {
|
||||
except: ServiceInterfaceId[]
|
||||
}): Promise<null>
|
||||
// ssl
|
||||
/** Returns a PEM encoded fullchain for the hostnames specified */
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SSL Methods - Manage TLS certificates
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retrieves a PEM-encoded SSL certificate chain for the specified hostnames.
|
||||
* StartOS automatically manages certificate generation and renewal.
|
||||
*
|
||||
* @param options.hostnames - Array of hostnames the certificate should cover
|
||||
* @param options.algorithm - Signing algorithm: "ecdsa" (default) or "ed25519"
|
||||
* @param options.callback - Optional callback invoked when the certificate is renewed
|
||||
* @returns Promise resolving to a tuple of [certificate, chain, fullchain] PEM strings
|
||||
*/
|
||||
getSslCertificate: (options: {
|
||||
hostnames: string[]
|
||||
algorithm?: "ecdsa" | "ed25519"
|
||||
callback?: () => void
|
||||
}) => Promise<[string, string, string]>
|
||||
/** Returns a PEM encoded private key corresponding to the certificate for the hostnames specified */
|
||||
|
||||
/**
|
||||
* Retrieves the PEM-encoded private key corresponding to the SSL certificate.
|
||||
*
|
||||
* @param options.hostnames - Array of hostnames (must match a previous getSslCertificate call)
|
||||
* @param options.algorithm - Signing algorithm: "ecdsa" (default) or "ed25519"
|
||||
* @returns Promise resolving to the private key PEM string
|
||||
*/
|
||||
getSslKey: (options: {
|
||||
hostnames: string[]
|
||||
algorithm?: "ecdsa" | "ed25519"
|
||||
}) => Promise<string>
|
||||
|
||||
/** sets the version that this service's data has been migrated to */
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data Version Methods - Track data migration state
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sets the version that this service's data has been migrated to.
|
||||
* Used by the version migration system to track which migrations have been applied.
|
||||
* Service developers typically don't call this directly - it's managed by the version graph.
|
||||
*
|
||||
* @param options.version - The version string to record, or null to clear
|
||||
* @returns Promise resolving to null on success
|
||||
*/
|
||||
setDataVersion(options: { version: string | null }): Promise<null>
|
||||
/** returns the version that this service's data has been migrated to */
|
||||
|
||||
/**
|
||||
* Returns the version that this service's data has been migrated to.
|
||||
* Used to determine which migrations need to be applied during updates.
|
||||
*
|
||||
* @returns Promise resolving to the current data version string, or null if not set
|
||||
*/
|
||||
getDataVersion(): Promise<string | null>
|
||||
|
||||
// system
|
||||
/** Returns globally configured SMTP settings, if they exist */
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// System Methods - Access system-wide configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retrieves the globally configured SMTP settings from StartOS.
|
||||
* Users can configure SMTP in the StartOS settings for services to use for sending emails.
|
||||
*
|
||||
* @param options.callback - Optional callback invoked when SMTP settings change (for reactive updates)
|
||||
* @returns Promise resolving to SMTP configuration, or null if not configured
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const smtp = await effects.getSystemSmtp({})
|
||||
* if (smtp) {
|
||||
* console.log(`SMTP server: ${smtp.server}:${smtp.port}`)
|
||||
* console.log(`From address: ${smtp.from}`)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getSystemSmtp(options: { callback?: () => void }): Promise<SmtpValue | null>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
/**
|
||||
* @module inputSpecTypes
|
||||
*
|
||||
* This module defines the type specifications for action input form fields.
|
||||
* These types describe the shape of form fields that appear in the StartOS UI
|
||||
* when users interact with service actions.
|
||||
*
|
||||
* Developers typically don't create these types directly - instead, use the
|
||||
* `Value` class methods (e.g., `Value.text()`, `Value.select()`) which generate
|
||||
* these specifications with proper defaults and validation.
|
||||
*
|
||||
* @see {@link Value} for the builder API
|
||||
*/
|
||||
|
||||
/**
|
||||
* A complete input specification - a record mapping field names to their specifications.
|
||||
* This is the top-level type for an action's input form.
|
||||
*/
|
||||
export type InputSpec = Record<string, ValueSpec>
|
||||
|
||||
/**
|
||||
* All available input field types.
|
||||
*
|
||||
* - `text` - Single-line text input
|
||||
* - `textarea` - Multi-line text input
|
||||
* - `number` - Numeric input with optional min/max/step
|
||||
* - `color` - Color picker
|
||||
* - `datetime` - Date and/or time picker
|
||||
* - `toggle` - Boolean on/off switch
|
||||
* - `select` - Single-selection dropdown/radio
|
||||
* - `multiselect` - Multiple-selection checkboxes
|
||||
* - `list` - Dynamic list of items (text or objects)
|
||||
* - `object` - Nested group of fields (sub-form)
|
||||
* - `file` - File upload
|
||||
* - `union` - Conditional fields based on selection (discriminated union)
|
||||
* - `hidden` - Hidden field (not displayed to user)
|
||||
*/
|
||||
export type ValueType =
|
||||
| "text"
|
||||
| "textarea"
|
||||
@@ -13,188 +49,369 @@ export type ValueType =
|
||||
| "file"
|
||||
| "union"
|
||||
| "hidden"
|
||||
|
||||
/** Union type of all possible value specifications */
|
||||
export type ValueSpec = ValueSpecOf<ValueType>
|
||||
/** core spec types. These types provide the metadata for performing validations */
|
||||
|
||||
/**
|
||||
* Maps a ValueType to its corresponding specification type.
|
||||
* Core spec types that provide metadata for validation and UI rendering.
|
||||
*/
|
||||
// prettier-ignore
|
||||
export type ValueSpecOf<T extends ValueType> =
|
||||
T extends "text" ? ValueSpecText :
|
||||
T extends "textarea" ? ValueSpecTextarea :
|
||||
T extends "number" ? ValueSpecNumber :
|
||||
T extends "color" ? ValueSpecColor :
|
||||
T extends "datetime" ? ValueSpecDatetime :
|
||||
T extends "toggle" ? ValueSpecToggle :
|
||||
T extends "select" ? ValueSpecSelect :
|
||||
T extends "multiselect" ? ValueSpecMultiselect :
|
||||
T extends "list" ? ValueSpecList :
|
||||
T extends "object" ? ValueSpecObject :
|
||||
T extends "file" ? ValueSpecFile :
|
||||
T extends "union" ? ValueSpecUnion :
|
||||
export type ValueSpecOf<T extends ValueType> =
|
||||
T extends "text" ? ValueSpecText :
|
||||
T extends "textarea" ? ValueSpecTextarea :
|
||||
T extends "number" ? ValueSpecNumber :
|
||||
T extends "color" ? ValueSpecColor :
|
||||
T extends "datetime" ? ValueSpecDatetime :
|
||||
T extends "toggle" ? ValueSpecToggle :
|
||||
T extends "select" ? ValueSpecSelect :
|
||||
T extends "multiselect" ? ValueSpecMultiselect :
|
||||
T extends "list" ? ValueSpecList :
|
||||
T extends "object" ? ValueSpecObject :
|
||||
T extends "file" ? ValueSpecFile :
|
||||
T extends "union" ? ValueSpecUnion :
|
||||
T extends "hidden" ? ValueSpecHidden :
|
||||
never
|
||||
|
||||
/**
|
||||
* Specification for a single-line text input field.
|
||||
* Use `Value.text()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecText = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes (requires user confirmation) */
|
||||
warning: string | null
|
||||
|
||||
type: "text"
|
||||
/** Regex patterns the value must match, with descriptions for validation errors */
|
||||
patterns: Pattern[]
|
||||
/** Minimum character length */
|
||||
minLength: number | null
|
||||
/** Maximum character length */
|
||||
maxLength: number | null
|
||||
/** If true, displays input as dots (●●●) for sensitive data like passwords */
|
||||
masked: boolean
|
||||
|
||||
/** Browser input mode hint for mobile keyboards */
|
||||
inputmode: "text" | "email" | "tel" | "url"
|
||||
/** Placeholder text shown when the field is empty */
|
||||
placeholder: string | null
|
||||
|
||||
/** If true, the field cannot be left empty */
|
||||
required: boolean
|
||||
/** Default value (can be a string or random string generator) */
|
||||
default: DefaultString | null
|
||||
/** If string, the field is disabled with this message explaining why */
|
||||
disabled: false | string
|
||||
/** Configuration for "Generate" button that creates random strings */
|
||||
generate: null | RandomString
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a multi-line text area input field.
|
||||
* Use `Value.textarea()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecTextarea = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
|
||||
type: "textarea"
|
||||
/** Regex patterns the value must match */
|
||||
patterns: Pattern[]
|
||||
/** Placeholder text shown when the field is empty */
|
||||
placeholder: string | null
|
||||
/** Minimum character length */
|
||||
minLength: number | null
|
||||
/** Maximum character length */
|
||||
maxLength: number | null
|
||||
/** Minimum visible rows before scrolling */
|
||||
minRows: number
|
||||
/** Maximum visible rows before scrolling */
|
||||
maxRows: number
|
||||
/** If true, the field cannot be left empty */
|
||||
required: boolean
|
||||
/** Default value */
|
||||
default: string | null
|
||||
/** If string, the field is disabled with this message */
|
||||
disabled: false | string
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a numeric input field.
|
||||
* Use `Value.number()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecNumber = {
|
||||
type: "number"
|
||||
/** Minimum allowed value */
|
||||
min: number | null
|
||||
/** Maximum allowed value */
|
||||
max: number | null
|
||||
/** If true, only whole numbers are allowed */
|
||||
integer: boolean
|
||||
/** Increment/decrement step for arrow controls */
|
||||
step: number | null
|
||||
/** Unit label displayed after the input (e.g., "MB", "seconds") */
|
||||
units: string | null
|
||||
/** Placeholder text shown when the field is empty */
|
||||
placeholder: string | null
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
/** If true, the field cannot be left empty */
|
||||
required: boolean
|
||||
/** Default value */
|
||||
default: number | null
|
||||
/** If string, the field is disabled with this message */
|
||||
disabled: false | string
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a color picker field.
|
||||
* Use `Value.color()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecColor = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
|
||||
type: "color"
|
||||
/** If true, a color must be selected */
|
||||
required: boolean
|
||||
/** Default color value (hex format, e.g., "ffffff") */
|
||||
default: string | null
|
||||
/** If string, the field is disabled with this message */
|
||||
disabled: false | string
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a date/time picker field.
|
||||
* Use `Value.datetime()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecDatetime = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
type: "datetime"
|
||||
/** If true, the field cannot be left empty */
|
||||
required: boolean
|
||||
/** Type of datetime picker to display */
|
||||
inputmode: "date" | "time" | "datetime-local"
|
||||
/** Minimum allowed date/time */
|
||||
min: string | null
|
||||
/** Maximum allowed date/time */
|
||||
max: string | null
|
||||
/** Default value */
|
||||
default: string | null
|
||||
/** If string, the field is disabled with this message */
|
||||
disabled: false | string
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a single-selection dropdown or radio button group.
|
||||
* Use `Value.select()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecSelect = {
|
||||
/** Map of option values to their display labels */
|
||||
values: Record<string, string>
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
type: "select"
|
||||
/** Default selected option key */
|
||||
default: string | null
|
||||
/** Disabled state: false=enabled, string=disabled with message, string[]=specific options disabled */
|
||||
disabled: false | string | string[]
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a multiple-selection checkbox group.
|
||||
* Use `Value.multiselect()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecMultiselect = {
|
||||
/** Map of option values to their display labels */
|
||||
values: Record<string, string>
|
||||
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
|
||||
type: "multiselect"
|
||||
/** Minimum number of selections required */
|
||||
minLength: number | null
|
||||
/** Maximum number of selections allowed */
|
||||
maxLength: number | null
|
||||
/** Disabled state: false=enabled, string=disabled with message, string[]=specific options disabled */
|
||||
disabled: false | string | string[]
|
||||
/** Default selected option keys */
|
||||
default: string[]
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a boolean toggle switch.
|
||||
* Use `Value.toggle()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecToggle = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
|
||||
type: "toggle"
|
||||
/** Default value (on/off) */
|
||||
default: boolean | null
|
||||
/** If string, the field is disabled with this message */
|
||||
disabled: false | string
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
/**
|
||||
* Specification for a discriminated union field (conditional sub-forms).
|
||||
* Shows different fields based on which variant is selected.
|
||||
* Use `Value.union()` with `Variants.of()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecUnion = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
|
||||
type: "union"
|
||||
/** Map of variant keys to their display names and nested field specifications */
|
||||
variants: Record<
|
||||
string,
|
||||
{
|
||||
/** Display name for this variant option */
|
||||
name: string
|
||||
/** Fields to show when this variant is selected */
|
||||
spec: InputSpec
|
||||
}
|
||||
>
|
||||
/** Disabled state: false=enabled, string=disabled with message, string[]=specific variants disabled */
|
||||
disabled: false | string | string[]
|
||||
/** Default selected variant key */
|
||||
default: string | null
|
||||
/** If true, the value cannot be changed after initial set */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a file upload field.
|
||||
* Use `Value.file()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecFile = {
|
||||
/** Display label for the field */
|
||||
name: string
|
||||
/** Help text shown below the field */
|
||||
description: string | null
|
||||
/** Warning message shown when the value changes */
|
||||
warning: string | null
|
||||
type: "file"
|
||||
/** Allowed file extensions (e.g., [".json", ".yaml"]) */
|
||||
extensions: string[]
|
||||
/** If true, a file must be uploaded */
|
||||
required: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a nested object (sub-form / field group).
|
||||
* Use `Value.object()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecObject = {
|
||||
/** Display label for the field group */
|
||||
name: string
|
||||
/** Help text shown below the field group */
|
||||
description: string | null
|
||||
/** Warning message (not typically used for objects) */
|
||||
warning: string | null
|
||||
type: "object"
|
||||
/** Nested field specifications */
|
||||
spec: InputSpec
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for a hidden field (not displayed in the UI).
|
||||
* Use `Value.hidden()` to create this specification.
|
||||
* Useful for storing internal state that shouldn't be user-editable.
|
||||
*/
|
||||
export type ValueSpecHidden = {
|
||||
type: "hidden"
|
||||
}
|
||||
|
||||
/** Types of items that can appear in a list */
|
||||
export type ListValueSpecType = "text" | "object"
|
||||
|
||||
/** Maps a list item type to its specification */
|
||||
// prettier-ignore
|
||||
export type ListValueSpecOf<T extends ListValueSpecType> =
|
||||
export type ListValueSpecOf<T extends ListValueSpecType> =
|
||||
T extends "text" ? ListValueSpecText :
|
||||
T extends "object" ? ListValueSpecObject :
|
||||
never
|
||||
|
||||
/** Union of all list specification types */
|
||||
export type ValueSpecList = ValueSpecListOf<ListValueSpecType>
|
||||
|
||||
/**
|
||||
* Specification for a dynamic list of items.
|
||||
* Use `Value.list()` with `List.text()` or `List.obj()` to create this specification.
|
||||
*/
|
||||
export type ValueSpecListOf<T extends ListValueSpecType> = {
|
||||
/** Display label for the list field */
|
||||
name: string
|
||||
/** Help text shown below the list */
|
||||
description: string | null
|
||||
/** Warning message shown when items change */
|
||||
warning: string | null
|
||||
type: "list"
|
||||
/** Specification for individual list items */
|
||||
spec: ListValueSpecOf<T>
|
||||
/** Minimum number of items required */
|
||||
minLength: number | null
|
||||
/** Maximum number of items allowed */
|
||||
maxLength: number | null
|
||||
/** If string, the list is disabled with this message */
|
||||
disabled: false | string
|
||||
/** Default list items */
|
||||
default:
|
||||
| string[]
|
||||
| DefaultString[]
|
||||
@@ -203,28 +420,62 @@ export type ValueSpecListOf<T extends ListValueSpecType> = {
|
||||
| readonly DefaultString[]
|
||||
| readonly Record<string, unknown>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A validation pattern with a regex and human-readable description.
|
||||
* Used to validate text input and provide meaningful error messages.
|
||||
*/
|
||||
export type Pattern = {
|
||||
/** Regular expression pattern (as a string) */
|
||||
regex: string
|
||||
/** Human-readable description shown when validation fails */
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for text items within a list.
|
||||
* Created via `List.text()`.
|
||||
*/
|
||||
export type ListValueSpecText = {
|
||||
type: "text"
|
||||
/** Regex patterns each item must match */
|
||||
patterns: Pattern[]
|
||||
/** Minimum character length per item */
|
||||
minLength: number | null
|
||||
/** Maximum character length per item */
|
||||
maxLength: number | null
|
||||
/** If true, displays items as dots (●●●) */
|
||||
masked: boolean
|
||||
|
||||
/** Configuration for "Generate" button */
|
||||
generate: null | RandomString
|
||||
/** Browser input mode hint */
|
||||
inputmode: "text" | "email" | "tel" | "url"
|
||||
/** Placeholder text for each item */
|
||||
placeholder: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Specification for object items within a list.
|
||||
* Created via `List.obj()`.
|
||||
*/
|
||||
export type ListValueSpecObject = {
|
||||
type: "object"
|
||||
/** Field specification for each object in the list */
|
||||
spec: InputSpec
|
||||
/** Constraint for ensuring unique items in the list */
|
||||
uniqueBy: UniqueBy
|
||||
/** Template string for how to display each item in the list (e.g., "{name} - {email}") */
|
||||
displayAs: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines how to determine uniqueness for list items.
|
||||
* - `null` - No uniqueness constraint
|
||||
* - `string` - Field name that must be unique (e.g., "email")
|
||||
* - `{ any: UniqueBy[] }` - Any of the specified constraints must be unique
|
||||
* - `{ all: UniqueBy[] }` - All of the specified constraints combined must be unique
|
||||
*/
|
||||
export type UniqueBy =
|
||||
| null
|
||||
| string
|
||||
@@ -234,12 +485,30 @@ export type UniqueBy =
|
||||
| {
|
||||
all: readonly UniqueBy[] | UniqueBy[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Default value for a text field - either a literal string or a random string generator.
|
||||
*/
|
||||
export type DefaultString = string | RandomString
|
||||
|
||||
/**
|
||||
* Configuration for generating random strings (e.g., passwords, tokens).
|
||||
* Used with `Value.text({ generate: ... })` to show a "Generate" button.
|
||||
*/
|
||||
export type RandomString = {
|
||||
/** Characters to use when generating (e.g., "abcdefghijklmnopqrstuvwxyz0123456789") */
|
||||
charset: string
|
||||
/** Length of the generated string */
|
||||
len: number
|
||||
}
|
||||
// sometimes the type checker needs just a little bit of help
|
||||
|
||||
/**
|
||||
* Type guard to check if a ValueSpec is a list of a specific item type.
|
||||
*
|
||||
* @param t - The value specification to check
|
||||
* @param s - The expected list item type ("text" or "object")
|
||||
* @returns True if the spec is a list of the specified type
|
||||
*/
|
||||
export function isValueSpecListOf<S extends ListValueSpecType>(
|
||||
t: ValueSpec,
|
||||
s: S,
|
||||
|
||||
@@ -1,3 +1,31 @@
|
||||
/**
|
||||
* @module setupActions
|
||||
*
|
||||
* This module provides the Action and Actions classes for defining user-callable
|
||||
* operations in StartOS services. Actions appear in the StartOS UI and can be
|
||||
* triggered by users or programmatically by other services.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Action, Actions, InputSpec, Value } from '@start9labs/start-sdk'
|
||||
*
|
||||
* const resetPasswordAction = Action.withInput(
|
||||
* 'reset-password',
|
||||
* { name: 'Reset Password', description: 'Reset the admin password' },
|
||||
* InputSpec.of({
|
||||
* username: Value.text({ name: 'Username', required: true, default: null })
|
||||
* }),
|
||||
* async ({ effects }) => ({ username: 'admin' }), // Pre-fill form
|
||||
* async ({ effects, input }) => {
|
||||
* // Perform the password reset
|
||||
* return { result: { type: 'single', value: 'Password reset successfully' } }
|
||||
* }
|
||||
* )
|
||||
*
|
||||
* export const actions = Actions.of().addAction(resetPasswordAction)
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { InputSpec } from "./input/builder"
|
||||
import { ExtractInputSpecType } from "./input/builder/inputSpec"
|
||||
import * as T from "../types"
|
||||
@@ -5,16 +33,54 @@ import { once } from "../util"
|
||||
import { InitScript } from "../inits"
|
||||
import { Parser } from "ts-matches"
|
||||
|
||||
/** @internal Input spec type or null if the action has no input */
|
||||
type MaybeInputSpec<Type> = {} extends Type ? null : InputSpec<Type>
|
||||
|
||||
/**
|
||||
* Function signature for executing an action.
|
||||
*
|
||||
* @typeParam A - The type of the validated input object
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @param options.input - The validated user input
|
||||
* @param options.spec - The input specification used to generate the form
|
||||
* @returns Promise resolving to an ActionResult to display to the user, or null/void for no result
|
||||
*/
|
||||
export type Run<A extends Record<string, any>> = (options: {
|
||||
effects: T.Effects
|
||||
input: A
|
||||
spec: T.inputSpecTypes.InputSpec
|
||||
}) => Promise<(T.ActionResult & { version: "1" }) | null | void | undefined>
|
||||
|
||||
/**
|
||||
* Function signature for pre-filling action input forms.
|
||||
* Called before displaying the input form to populate default values.
|
||||
*
|
||||
* @typeParam A - The type of the input object
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @returns Promise resolving to partial input values to pre-fill, or null for no pre-fill
|
||||
*/
|
||||
export type GetInput<A extends Record<string, any>> = (options: {
|
||||
effects: T.Effects
|
||||
}) => Promise<null | void | undefined | T.DeepPartial<A>>
|
||||
|
||||
/**
|
||||
* A value that can either be static or computed dynamically from Effects.
|
||||
* Used for action metadata that may need to change based on service state.
|
||||
*
|
||||
* @typeParam T - The type of the value
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Static metadata
|
||||
* const metadata: MaybeFn<ActionMetadata> = { name: 'My Action' }
|
||||
*
|
||||
* // Dynamic metadata based on service state
|
||||
* const dynamicMetadata: MaybeFn<ActionMetadata> = async ({ effects }) => {
|
||||
* const isEnabled = await checkSomething(effects)
|
||||
* return { name: isEnabled ? 'Disable Feature' : 'Enable Feature' }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type MaybeFn<T> = T | ((options: { effects: T.Effects }) => Promise<T>)
|
||||
function callMaybeFn<T>(
|
||||
maybeFn: MaybeFn<T>,
|
||||
@@ -37,29 +103,76 @@ function mapMaybeFn<T, U>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type information interface for an Action.
|
||||
* Used for type inference in the Actions collection.
|
||||
*
|
||||
* @typeParam Id - The action's unique identifier type
|
||||
* @typeParam Type - The action's input type
|
||||
*/
|
||||
export interface ActionInfo<
|
||||
Id extends T.ActionId,
|
||||
Type extends Record<string, any>,
|
||||
> {
|
||||
/** The unique identifier for this action */
|
||||
readonly id: Id
|
||||
/** @internal Type brand for input type inference */
|
||||
readonly _INPUT: Type
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a user-callable action in a StartOS service.
|
||||
*
|
||||
* Exposed via `sdk.Action`. Actions are operations that users can trigger
|
||||
* from the StartOS UI or that can be invoked programmatically. Each action has:
|
||||
* - A unique ID
|
||||
* - Metadata (name, description, visibility, etc.)
|
||||
* - Optional input specification (form fields)
|
||||
* - A run function that executes the action
|
||||
*
|
||||
* Use `sdk.Action.withInput()` for actions that require user input, or
|
||||
* `sdk.Action.withoutInput()` for actions that run immediately.
|
||||
*
|
||||
* See the SDK documentation for detailed examples.
|
||||
*
|
||||
* @typeParam Id - The action's unique identifier type
|
||||
* @typeParam Type - The action's input type (empty object {} for no input)
|
||||
*/
|
||||
export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
implements ActionInfo<Id, Type>
|
||||
{
|
||||
/** @internal Type brand for input type inference */
|
||||
readonly _INPUT: Type = null as any as Type
|
||||
|
||||
/** @internal Cache of built input specs by event ID */
|
||||
private prevInputSpec: Record<
|
||||
string,
|
||||
{ spec: T.inputSpecTypes.InputSpec; validator: Parser<unknown, Type> }
|
||||
> = {}
|
||||
|
||||
private constructor(
|
||||
/** The unique identifier for this action */
|
||||
readonly id: Id,
|
||||
private readonly metadataFn: MaybeFn<T.ActionMetadata>,
|
||||
private readonly inputSpec: MaybeInputSpec<Type>,
|
||||
private readonly getInputFn: GetInput<Type>,
|
||||
private readonly runFn: Run<Type>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates an action that requires user input before execution.
|
||||
* The input form is defined by an InputSpec.
|
||||
*
|
||||
* @typeParam Id - The action ID type
|
||||
* @typeParam InputSpecType - The input specification type
|
||||
*
|
||||
* @param id - Unique identifier for the action (used in URLs and API calls)
|
||||
* @param metadata - Action metadata (name, description, visibility, etc.) - can be static or dynamic
|
||||
* @param inputSpec - Specification for the input form fields
|
||||
* @param getInput - Function to pre-populate the form with default/previous values
|
||||
* @param run - Function to execute when the action is submitted
|
||||
* @returns A new Action instance
|
||||
*/
|
||||
static withInput<
|
||||
Id extends T.ActionId,
|
||||
InputSpecType extends InputSpec<Record<string, any>>,
|
||||
@@ -78,6 +191,18 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
run,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action that executes immediately without requiring user input.
|
||||
* Use this for simple operations like toggles, restarts, or status checks.
|
||||
*
|
||||
* @typeParam Id - The action ID type
|
||||
*
|
||||
* @param id - Unique identifier for the action
|
||||
* @param metadata - Action metadata (name, description, visibility, etc.) - can be static or dynamic
|
||||
* @param run - Function to execute when the action is triggered
|
||||
* @returns A new Action instance with no input
|
||||
*/
|
||||
static withoutInput<Id extends T.ActionId>(
|
||||
id: Id,
|
||||
metadata: MaybeFn<Omit<T.ActionMetadata, "hasInput">>,
|
||||
@@ -91,6 +216,14 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
run,
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Exports the action's metadata to StartOS, making it visible in the UI.
|
||||
* Called automatically during initialization by the Actions collection.
|
||||
*
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @returns Promise resolving to the exported metadata
|
||||
* @internal
|
||||
*/
|
||||
async exportMetadata(options: {
|
||||
effects: T.Effects
|
||||
}): Promise<T.ActionMetadata> {
|
||||
@@ -104,6 +237,15 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
await options.effects.action.export({ id: this.id, metadata })
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns the input specification and pre-filled values for this action.
|
||||
* Called by StartOS when a user clicks on the action to display the input form.
|
||||
*
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @returns Promise resolving to the input specification and pre-filled values
|
||||
* @internal
|
||||
*/
|
||||
async getInput(options: { effects: T.Effects }): Promise<T.ActionInput> {
|
||||
let spec = {}
|
||||
if (this.inputSpec) {
|
||||
@@ -121,6 +263,16 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
| undefined) || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the action with the provided input.
|
||||
* Called by StartOS when a user submits the action form.
|
||||
*
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @param options.input - The user-provided input (validated against the input spec)
|
||||
* @returns Promise resolving to the action result to display, or null for no result
|
||||
* @internal
|
||||
*/
|
||||
async run(options: {
|
||||
effects: T.Effects
|
||||
input: Type
|
||||
@@ -146,19 +298,77 @@ export class Action<Id extends T.ActionId, Type extends Record<string, any>>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection of actions for a StartOS service.
|
||||
*
|
||||
* Exposed via `sdk.Actions`. The Actions class manages the registration and
|
||||
* lifecycle of all actions in a service. It implements InitScript so it can
|
||||
* be included in the initialization pipeline to automatically register actions
|
||||
* with StartOS.
|
||||
*
|
||||
* @typeParam AllActions - Record type mapping action IDs to Action instances
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create an actions collection
|
||||
* export const actions = sdk.Actions.of()
|
||||
* .addAction(createUserAction)
|
||||
* .addAction(resetPasswordAction)
|
||||
* .addAction(restartAction)
|
||||
*
|
||||
* // Include in init pipeline
|
||||
* export const init = sdk.setupInit(
|
||||
* versionGraph,
|
||||
* setInterfaces,
|
||||
* actions, // Actions are registered here
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export class Actions<
|
||||
AllActions extends Record<T.ActionId, Action<T.ActionId, any>>,
|
||||
> implements InitScript
|
||||
{
|
||||
private constructor(private readonly actions: AllActions) {}
|
||||
|
||||
/**
|
||||
* Creates a new empty Actions collection.
|
||||
* Use `addAction()` to add actions to the collection.
|
||||
*
|
||||
* @returns A new empty Actions instance
|
||||
*/
|
||||
static of(): Actions<{}> {
|
||||
return new Actions({})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an action to the collection.
|
||||
* Returns a new Actions instance with the action included (immutable pattern).
|
||||
*
|
||||
* @typeParam A - The action type being added
|
||||
* @param action - The action to add
|
||||
* @returns A new Actions instance containing all previous actions plus the new one
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const actions = Actions.of()
|
||||
* .addAction(action1)
|
||||
* .addAction(action2)
|
||||
* ```
|
||||
*/
|
||||
addAction<A extends Action<T.ActionId, any>>(
|
||||
action: A, // TODO: prevent duplicates
|
||||
): Actions<AllActions & { [id in A["id"]]: A }> {
|
||||
return new Actions({ ...this.actions, [action.id]: action })
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all actions by exporting their metadata to StartOS.
|
||||
* Called automatically when included in the init pipeline.
|
||||
* Also clears any previously registered actions that are no longer in the collection.
|
||||
*
|
||||
* @param effects - Effects instance for system operations
|
||||
* @internal
|
||||
*/
|
||||
async init(effects: T.Effects): Promise<void> {
|
||||
for (let action of Object.values(this.actions)) {
|
||||
const fn = async () => {
|
||||
@@ -180,6 +390,15 @@ export class Actions<
|
||||
}
|
||||
await effects.action.clear({ except: Object.keys(this.actions) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an action from the collection by its ID.
|
||||
* Useful for programmatically invoking actions or inspecting their configuration.
|
||||
*
|
||||
* @typeParam Id - The action ID type
|
||||
* @param actionId - The ID of the action to retrieve
|
||||
* @returns The action instance
|
||||
*/
|
||||
get<Id extends T.ActionId>(actionId: Id): AllActions[Id] {
|
||||
return this.actions[actionId]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
/**
|
||||
* @module dependencies
|
||||
*
|
||||
* This module provides utilities for checking whether service dependencies are satisfied.
|
||||
* Use `checkDependencies()` to get a helper object with methods for querying and
|
||||
* validating dependency status.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const deps = await checkDependencies(effects, ['bitcoind', 'lnd'])
|
||||
*
|
||||
* // Check if all dependencies are satisfied
|
||||
* if (deps.satisfied()) {
|
||||
* // All good, proceed
|
||||
* }
|
||||
*
|
||||
* // Or throw an error with details if not satisfied
|
||||
* deps.throwIfNotSatisfied()
|
||||
*
|
||||
* // Check specific aspects
|
||||
* if (deps.installedSatisfied('bitcoind') && deps.runningSatisfied('bitcoind')) {
|
||||
* // bitcoind is installed and running
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ExtendedVersion, VersionRange } from "../exver"
|
||||
import {
|
||||
PackageId,
|
||||
@@ -7,32 +33,149 @@ import {
|
||||
} from "../types"
|
||||
import { Effects } from "../Effects"
|
||||
|
||||
/**
|
||||
* Interface providing methods to check and validate dependency satisfaction.
|
||||
* Returned by `checkDependencies()`.
|
||||
*
|
||||
* @typeParam DependencyId - The type of package IDs being checked
|
||||
*/
|
||||
export type CheckDependencies<DependencyId extends PackageId = PackageId> = {
|
||||
/**
|
||||
* Gets the requirement and current result for a specific dependency.
|
||||
* @param packageId - The package ID to query
|
||||
* @returns Object containing the requirement spec and check result
|
||||
* @throws Error if the packageId is not a known dependency
|
||||
*/
|
||||
infoFor: (packageId: DependencyId) => {
|
||||
requirement: DependencyRequirement
|
||||
result: CheckDependenciesResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a dependency is installed (regardless of version).
|
||||
* @param packageId - The package ID to check
|
||||
* @returns True if the package is installed
|
||||
*/
|
||||
installedSatisfied: (packageId: DependencyId) => boolean
|
||||
|
||||
/**
|
||||
* Checks if a dependency is installed with a satisfying version.
|
||||
* @param packageId - The package ID to check
|
||||
* @returns True if the installed version satisfies the version range requirement
|
||||
*/
|
||||
installedVersionSatisfied: (packageId: DependencyId) => boolean
|
||||
|
||||
/**
|
||||
* Checks if a "running" dependency is actually running.
|
||||
* Always returns true for "exists" dependencies.
|
||||
* @param packageId - The package ID to check
|
||||
* @returns True if the dependency is running (or only needs to exist)
|
||||
*/
|
||||
runningSatisfied: (packageId: DependencyId) => boolean
|
||||
|
||||
/**
|
||||
* Checks if all critical tasks for a dependency have been completed.
|
||||
* @param packageId - The package ID to check
|
||||
* @returns True if no critical tasks are pending
|
||||
*/
|
||||
tasksSatisfied: (packageId: DependencyId) => boolean
|
||||
|
||||
/**
|
||||
* Checks if specified health checks are passing for a dependency.
|
||||
* @param packageId - The package ID to check
|
||||
* @param healthCheckId - Specific health check to verify (optional - checks all if omitted)
|
||||
* @returns True if the health check(s) are passing
|
||||
*/
|
||||
healthCheckSatisfied: (
|
||||
packageId: DependencyId,
|
||||
healthCheckId: HealthCheckId,
|
||||
) => boolean
|
||||
|
||||
/**
|
||||
* Checks if all dependencies are fully satisfied.
|
||||
* @returns True if all dependencies meet all requirements
|
||||
*/
|
||||
satisfied: () => boolean
|
||||
|
||||
/**
|
||||
* Throws an error if the dependency is not installed.
|
||||
* @param packageId - The package ID to check
|
||||
* @throws Error with message if not installed
|
||||
*/
|
||||
throwIfInstalledNotSatisfied: (packageId: DependencyId) => null
|
||||
|
||||
/**
|
||||
* Throws an error if the installed version doesn't satisfy requirements.
|
||||
* @param packageId - The package ID to check
|
||||
* @throws Error with version mismatch details if not satisfied
|
||||
*/
|
||||
throwIfInstalledVersionNotSatisfied: (packageId: DependencyId) => null
|
||||
|
||||
/**
|
||||
* Throws an error if a "running" dependency is not running.
|
||||
* @param packageId - The package ID to check
|
||||
* @throws Error if the dependency should be running but isn't
|
||||
*/
|
||||
throwIfRunningNotSatisfied: (packageId: DependencyId) => null
|
||||
|
||||
/**
|
||||
* Throws an error if critical tasks are pending for the dependency.
|
||||
* @param packageId - The package ID to check
|
||||
* @throws Error listing pending critical tasks
|
||||
*/
|
||||
throwIfTasksNotSatisfied: (packageId: DependencyId) => null
|
||||
|
||||
/**
|
||||
* Throws an error if health checks are failing for the dependency.
|
||||
* @param packageId - The package ID to check
|
||||
* @param healthCheckId - Specific health check (optional - checks all if omitted)
|
||||
* @throws Error with health check failure details
|
||||
*/
|
||||
throwIfHealthNotSatisfied: (
|
||||
packageId: DependencyId,
|
||||
healthCheckId?: HealthCheckId,
|
||||
) => null
|
||||
|
||||
/**
|
||||
* Throws an error if any requirements are not satisfied.
|
||||
* @param packageId - Specific package to check (optional - checks all if omitted)
|
||||
* @throws Error with detailed message about what's not satisfied
|
||||
*/
|
||||
throwIfNotSatisfied: (packageId?: DependencyId) => null
|
||||
}
|
||||
/**
|
||||
* Checks the satisfaction status of service dependencies.
|
||||
* Returns a helper object with methods to query and validate dependency status.
|
||||
*
|
||||
* This is useful for:
|
||||
* - Verifying dependencies before starting operations that require them
|
||||
* - Providing detailed error messages about unsatisfied dependencies
|
||||
* - Conditionally enabling features based on dependency availability
|
||||
*
|
||||
* @typeParam DependencyId - The type of package IDs (defaults to string)
|
||||
*
|
||||
* @param effects - Effects instance for system operations
|
||||
* @param packageIds - Optional array of specific dependencies to check (checks all if omitted)
|
||||
* @returns Promise resolving to a CheckDependencies helper object
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check all dependencies
|
||||
* const deps = await checkDependencies(effects)
|
||||
* deps.throwIfNotSatisfied() // Throws if any dependency isn't met
|
||||
*
|
||||
* // Check specific dependencies
|
||||
* const deps = await checkDependencies(effects, ['bitcoind'])
|
||||
* if (deps.runningSatisfied('bitcoind') && deps.healthCheckSatisfied('bitcoind', 'rpc')) {
|
||||
* // Safe to make RPC calls to bitcoind
|
||||
* }
|
||||
*
|
||||
* // Get detailed info
|
||||
* const info = deps.infoFor('bitcoind')
|
||||
* console.log(`Installed: ${info.result.installedVersion}`)
|
||||
* console.log(`Running: ${info.result.isRunning}`)
|
||||
* ```
|
||||
*/
|
||||
export async function checkDependencies<
|
||||
DependencyId extends PackageId = PackageId,
|
||||
>(
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
/**
|
||||
* @module setupDependencies
|
||||
*
|
||||
* This module provides utilities for declaring and managing service dependencies.
|
||||
* Dependencies allow services to declare that they require other services to be
|
||||
* installed and/or running before they can function properly.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In dependencies.ts
|
||||
* export const setDependencies = sdk.setupDependencies(async ({ effects }) => {
|
||||
* const config = await store.read(s => s.mediaSource).const(effects)
|
||||
*
|
||||
* return {
|
||||
* // Required dependency - must be running with passing health checks
|
||||
* bitcoind: {
|
||||
* kind: 'running',
|
||||
* versionRange: '>=25.0.0',
|
||||
* healthChecks: ['rpc']
|
||||
* },
|
||||
* // Optional dependency - only required if feature is enabled
|
||||
* ...(config === 'nextcloud' ? {
|
||||
* nextcloud: { kind: 'exists', versionRange: '>=28.0.0' }
|
||||
* } : {})
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
import * as T from "../types"
|
||||
import { once } from "../util"
|
||||
|
||||
/**
|
||||
* Extracts the package IDs of required (non-optional) dependencies from a manifest.
|
||||
* Used for type-safe dependency declarations.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
*/
|
||||
export type RequiredDependenciesOf<Manifest extends T.SDKManifest> = {
|
||||
[K in keyof Manifest["dependencies"]]: Exclude<
|
||||
Manifest["dependencies"][K],
|
||||
@@ -9,33 +44,75 @@ export type RequiredDependenciesOf<Manifest extends T.SDKManifest> = {
|
||||
? K
|
||||
: never
|
||||
}[keyof Manifest["dependencies"]]
|
||||
|
||||
/**
|
||||
* Extracts the package IDs of optional dependencies from a manifest.
|
||||
* These dependencies are declared in the manifest but marked as optional.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
*/
|
||||
export type OptionalDependenciesOf<Manifest extends T.SDKManifest> = Exclude<
|
||||
keyof Manifest["dependencies"],
|
||||
RequiredDependenciesOf<Manifest>
|
||||
>
|
||||
|
||||
/**
|
||||
* Specifies the requirements for a single dependency.
|
||||
*
|
||||
* - `kind: "running"` - The dependency must be installed AND actively running
|
||||
* with the specified health checks passing
|
||||
* - `kind: "exists"` - The dependency only needs to be installed (not necessarily running)
|
||||
*/
|
||||
type DependencyRequirement =
|
||||
| {
|
||||
/** The dependency must be running */
|
||||
kind: "running"
|
||||
/** Health check IDs that must be passing */
|
||||
healthChecks: Array<T.HealthCheckId>
|
||||
/** Semantic version range the dependency must satisfy (e.g., ">=1.0.0") */
|
||||
versionRange: string
|
||||
}
|
||||
| {
|
||||
/** The dependency only needs to be installed */
|
||||
kind: "exists"
|
||||
/** Semantic version range the dependency must satisfy */
|
||||
versionRange: string
|
||||
}
|
||||
|
||||
/** @internal Type checking helper */
|
||||
type Matches<T, U> = T extends U ? (U extends T ? null : never) : never
|
||||
const _checkType: Matches<
|
||||
DependencyRequirement & { id: T.PackageId },
|
||||
T.DependencyRequirement
|
||||
> = null
|
||||
|
||||
/**
|
||||
* The return type for dependency declarations.
|
||||
* Required dependencies must always be specified; optional dependencies may be omitted.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
*/
|
||||
export type CurrentDependenciesResult<Manifest extends T.SDKManifest> = {
|
||||
[K in RequiredDependenciesOf<Manifest>]: DependencyRequirement
|
||||
} & {
|
||||
[K in OptionalDependenciesOf<Manifest>]?: DependencyRequirement
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency setup function for use in the initialization pipeline.
|
||||
*
|
||||
* **Note:** This is exposed via `sdk.setupDependencies`. See the SDK documentation
|
||||
* for usage examples.
|
||||
*
|
||||
* The function you provide will be called during init to determine the current
|
||||
* dependency requirements, which may vary based on service configuration.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type (inferred from SDK)
|
||||
* @param fn - Async function that returns the current dependency requirements
|
||||
* @returns An init-compatible function that sets dependencies via Effects
|
||||
*
|
||||
* @see sdk.setupDependencies for usage documentation
|
||||
*/
|
||||
export function setupDependencies<Manifest extends T.SDKManifest>(
|
||||
fn: (options: {
|
||||
effects: T.Effects
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* @module Host
|
||||
*
|
||||
* This module provides the MultiHost class for binding network ports and
|
||||
* exposing service interfaces through the StartOS networking layer.
|
||||
*
|
||||
* MultiHost handles the complexity of:
|
||||
* - Port binding with protocol-specific defaults
|
||||
* - Automatic SSL/TLS setup for secure protocols
|
||||
* - Integration with Tor and LAN networking
|
||||
*/
|
||||
|
||||
import { object, string } from "ts-matches"
|
||||
import { Effects } from "../Effects"
|
||||
import { Origin } from "./Origin"
|
||||
@@ -8,37 +20,53 @@ import { AlpnInfo } from "../osBindings"
|
||||
|
||||
export { AddSslOptions, Security, BindOptions }
|
||||
|
||||
/**
|
||||
* Known protocol definitions with their default ports and SSL variants.
|
||||
*
|
||||
* Each protocol includes:
|
||||
* - `secure` - Whether the protocol is inherently secure (SSL/TLS)
|
||||
* - `defaultPort` - Standard port for this protocol
|
||||
* - `withSsl` - The SSL variant of the protocol (if applicable)
|
||||
* - `alpn` - ALPN negotiation info for TLS
|
||||
*/
|
||||
export const knownProtocols = {
|
||||
/** HTTP - plain text web traffic, auto-upgrades to HTTPS */
|
||||
http: {
|
||||
secure: null,
|
||||
defaultPort: 80,
|
||||
withSsl: "https",
|
||||
alpn: { specified: ["http/1.1"] } as AlpnInfo,
|
||||
},
|
||||
/** HTTPS - encrypted web traffic */
|
||||
https: {
|
||||
secure: { ssl: true },
|
||||
defaultPort: 443,
|
||||
},
|
||||
/** WebSocket - plain text, auto-upgrades to WSS */
|
||||
ws: {
|
||||
secure: null,
|
||||
defaultPort: 80,
|
||||
withSsl: "wss",
|
||||
alpn: { specified: ["http/1.1"] } as AlpnInfo,
|
||||
},
|
||||
/** Secure WebSocket */
|
||||
wss: {
|
||||
secure: { ssl: true },
|
||||
defaultPort: 443,
|
||||
},
|
||||
/** SSH - inherently secure (no SSL wrapper needed) */
|
||||
ssh: {
|
||||
secure: { ssl: false },
|
||||
defaultPort: 22,
|
||||
},
|
||||
/** DNS - domain name service */
|
||||
dns: {
|
||||
secure: { ssl: false },
|
||||
defaultPort: 53,
|
||||
},
|
||||
} as const
|
||||
|
||||
/** Protocol scheme string or null for no scheme */
|
||||
export type Scheme = string | null
|
||||
|
||||
type KnownProtocols = typeof knownProtocols
|
||||
@@ -69,14 +97,47 @@ export type BindOptionsByProtocol =
|
||||
| BindOptionsByKnownProtocol
|
||||
| (BindOptions & { protocol: null })
|
||||
|
||||
/** @internal Helper to detect if protocol is a known protocol string */
|
||||
const hasStringProtocol = object({
|
||||
protocol: string,
|
||||
}).test
|
||||
|
||||
/**
|
||||
* Manages network bindings for a service interface.
|
||||
*
|
||||
* MultiHost is the primary way to expose your service's ports to users.
|
||||
* It handles:
|
||||
* - Port binding with the StartOS networking layer
|
||||
* - Protocol-aware defaults (HTTP uses port 80, HTTPS uses 443, etc.)
|
||||
* - Automatic SSL certificate provisioning for secure protocols
|
||||
* - Creation of Origin objects for exporting service interfaces
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a host for the web UI
|
||||
* const webHost = sdk.MultiHost.of(effects, 'webui')
|
||||
*
|
||||
* // Bind port 3000 with HTTP (automatically adds HTTPS variant)
|
||||
* const webOrigin = await webHost.bindPort(3000, { protocol: 'http' })
|
||||
*
|
||||
* // Export the interface
|
||||
* await webOrigin.export([
|
||||
* sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Web Interface',
|
||||
* id: 'webui',
|
||||
* description: 'Access the dashboard',
|
||||
* type: 'ui',
|
||||
* })
|
||||
* ])
|
||||
* ```
|
||||
*/
|
||||
export class MultiHost {
|
||||
constructor(
|
||||
readonly options: {
|
||||
/** Effects instance for system operations */
|
||||
effects: Effects
|
||||
/** Unique identifier for this host binding */
|
||||
id: string
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -1,16 +1,66 @@
|
||||
/**
|
||||
* @module Origin
|
||||
*
|
||||
* The Origin class represents a bound network origin (protocol + host + port)
|
||||
* that can be used to export service interfaces.
|
||||
*/
|
||||
|
||||
import { AddressInfo } from "../types"
|
||||
import { AddressReceipt } from "./AddressReceipt"
|
||||
import { MultiHost, Scheme } from "./Host"
|
||||
import { ServiceInterfaceBuilder } from "./ServiceInterfaceBuilder"
|
||||
|
||||
/**
|
||||
* Represents a network origin (protocol://host:port) created from a MultiHost binding.
|
||||
*
|
||||
* Origins are created by calling `MultiHost.bindPort()` and can be used to
|
||||
* export one or more service interfaces that share the same underlying connection.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create origin from host binding
|
||||
* const origin = await webHost.bindPort(8080, { protocol: 'http' })
|
||||
*
|
||||
* // Export multiple interfaces sharing this origin
|
||||
* await origin.export([
|
||||
* sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Web UI',
|
||||
* id: 'ui',
|
||||
* type: 'ui',
|
||||
* description: 'Main web interface',
|
||||
* }),
|
||||
* sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'REST API',
|
||||
* id: 'api',
|
||||
* type: 'api',
|
||||
* description: 'JSON API endpoint',
|
||||
* path: '/api/v1',
|
||||
* }),
|
||||
* ])
|
||||
* ```
|
||||
*/
|
||||
export class Origin {
|
||||
constructor(
|
||||
/** The MultiHost this origin was created from */
|
||||
readonly host: MultiHost,
|
||||
/** The internal port this origin is bound to */
|
||||
readonly internalPort: number,
|
||||
/** The protocol scheme (e.g., "http", "ssh") or null */
|
||||
readonly scheme: string | null,
|
||||
/** The SSL variant scheme (e.g., "https", "wss") or null */
|
||||
readonly sslScheme: string | null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Builds an AddressInfo object for this origin with the specified options.
|
||||
* Used internally by `export()` but can be called directly for custom use cases.
|
||||
*
|
||||
* @param options - Build options including path, query params, username, and scheme overrides
|
||||
* @returns AddressInfo object describing the complete address
|
||||
* @internal
|
||||
*/
|
||||
build({
|
||||
username,
|
||||
path,
|
||||
@@ -36,12 +86,28 @@ export class Origin {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description A function to register a group of origins (<PROTOCOL> :// <HOSTNAME> : <PORT>) with StartOS
|
||||
* Exports one or more service interfaces for this origin.
|
||||
*
|
||||
* The returned addressReceipt serves as proof that the addresses were registered
|
||||
* Each service interface becomes a clickable link in the StartOS UI.
|
||||
* Multiple interfaces can share the same origin but have different
|
||||
* names, descriptions, types, paths, or query parameters.
|
||||
*
|
||||
* @param addressInfo
|
||||
* @returns
|
||||
* @param serviceInterfaces - Array of ServiceInterfaceBuilder objects to export
|
||||
* @returns Promise resolving to array of AddressInfo with an AddressReceipt
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await origin.export([
|
||||
* sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Admin Panel',
|
||||
* id: 'admin',
|
||||
* type: 'ui',
|
||||
* description: 'Administrator dashboard',
|
||||
* path: '/admin',
|
||||
* })
|
||||
* ])
|
||||
* ```
|
||||
*/
|
||||
async export(
|
||||
serviceInterfaces: ServiceInterfaceBuilder[],
|
||||
@@ -83,9 +149,17 @@ export class Origin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for building an address from an Origin.
|
||||
* @internal
|
||||
*/
|
||||
type BuildOptions = {
|
||||
/** Override the default schemes for SSL and non-SSL connections */
|
||||
schemeOverride: { ssl: Scheme; noSsl: Scheme } | null
|
||||
/** Optional username for basic auth URLs */
|
||||
username: string | null
|
||||
/** URL path (e.g., "/api/v1") */
|
||||
path: string
|
||||
/** Query parameters to append to the URL */
|
||||
query: Record<string, string>
|
||||
}
|
||||
|
||||
@@ -1,30 +1,86 @@
|
||||
/**
|
||||
* @module ServiceInterfaceBuilder
|
||||
*
|
||||
* Provides the ServiceInterfaceBuilder class for creating service interface
|
||||
* configurations that are exported to the StartOS UI.
|
||||
*/
|
||||
|
||||
import { ServiceInterfaceType } from "../types"
|
||||
import { Effects } from "../Effects"
|
||||
import { Scheme } from "./Host"
|
||||
|
||||
/**
|
||||
* A helper class for creating a Network Interface
|
||||
* Builder class for creating service interface configurations.
|
||||
*
|
||||
* Network Interfaces are collections of web addresses that expose the same API or other resource,
|
||||
* display to the user with under a common name and description.
|
||||
* A service interface represents a user-visible endpoint in the StartOS UI.
|
||||
* It appears as a clickable link that users can use to access your service's
|
||||
* web UI, API, or other network resources.
|
||||
*
|
||||
* All URIs on an interface inherit the same ui: bool, basic auth credentials, path, and search (query) params
|
||||
* Interfaces are created with this builder and then exported via `Origin.export()`.
|
||||
*
|
||||
* @param options
|
||||
* @returns
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a basic web UI interface
|
||||
* const webInterface = sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Web Interface',
|
||||
* id: 'webui',
|
||||
* description: 'Access the main web dashboard',
|
||||
* type: 'ui',
|
||||
* })
|
||||
*
|
||||
* // Create an API interface with a specific path
|
||||
* const apiInterface = sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'REST API',
|
||||
* id: 'api',
|
||||
* description: 'JSON API for programmatic access',
|
||||
* type: 'api',
|
||||
* path: '/api/v1',
|
||||
* })
|
||||
*
|
||||
* // Create an interface with basic auth
|
||||
* const protectedInterface = sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Admin Panel',
|
||||
* id: 'admin',
|
||||
* description: 'Protected admin area',
|
||||
* type: 'ui',
|
||||
* username: 'admin',
|
||||
* masked: true, // Hide the URL from casual viewing
|
||||
* })
|
||||
*
|
||||
* // Export all interfaces on the same origin
|
||||
* await origin.export([webInterface, apiInterface, protectedInterface])
|
||||
* ```
|
||||
*/
|
||||
export class ServiceInterfaceBuilder {
|
||||
constructor(
|
||||
readonly options: {
|
||||
/** Effects instance for system operations */
|
||||
effects: Effects
|
||||
/** Display name shown in the StartOS UI */
|
||||
name: string
|
||||
/** Unique identifier for this interface */
|
||||
id: string
|
||||
/** Description shown below the interface name */
|
||||
description: string
|
||||
/**
|
||||
* Type of interface:
|
||||
* - `"ui"` - Web interface (opens in browser)
|
||||
* - `"api"` - API endpoint (for programmatic access)
|
||||
* - `"p2p"` - Peer-to-peer endpoint (e.g., Bitcoin P2P)
|
||||
*/
|
||||
type: ServiceInterfaceType
|
||||
/** Username for basic auth URLs (null for no auth) */
|
||||
username: string | null
|
||||
/** URL path to append (e.g., "/admin", "/api/v1") */
|
||||
path: string
|
||||
/** Query parameters to append to the URL */
|
||||
query: Record<string, string>
|
||||
/** Override default protocol schemes */
|
||||
schemeOverride: { ssl: Scheme; noSsl: Scheme } | null
|
||||
/** If true, the URL is hidden/masked in the UI (for sensitive endpoints) */
|
||||
masked: boolean
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -1,20 +1,84 @@
|
||||
/**
|
||||
* @module setupInterfaces
|
||||
*
|
||||
* This module provides utilities for setting up network interfaces (service endpoints)
|
||||
* that are exposed to users through the StartOS UI.
|
||||
*
|
||||
* Service interfaces are the clickable links users see to access your service's
|
||||
* web UI, API endpoints, or peer-to-peer connections.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||
* const webHost = sdk.MultiHost.of(effects, 'webui')
|
||||
* const webOrigin = await webHost.bindPort(8080, { protocol: 'http' })
|
||||
*
|
||||
* await webOrigin.export([
|
||||
* sdk.ServiceInterfaceBuilder.of({
|
||||
* effects,
|
||||
* name: 'Web Interface',
|
||||
* id: 'webui',
|
||||
* description: 'Access the web dashboard',
|
||||
* type: 'ui',
|
||||
* })
|
||||
* ])
|
||||
*
|
||||
* return [webOrigin]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
import * as T from "../types"
|
||||
import { once } from "../util"
|
||||
import { AddressReceipt } from "./AddressReceipt"
|
||||
|
||||
/** @internal Type brand for interface update receipt */
|
||||
declare const UpdateServiceInterfacesProof: unique symbol
|
||||
|
||||
/**
|
||||
* Receipt type proving that service interfaces have been updated.
|
||||
* @internal
|
||||
*/
|
||||
export type UpdateServiceInterfacesReceipt = {
|
||||
[UpdateServiceInterfacesProof]: never
|
||||
}
|
||||
|
||||
/** Array of address info arrays with receipts, representing all exported interfaces */
|
||||
export type ServiceInterfacesReceipt = Array<T.AddressInfo[] & AddressReceipt>
|
||||
|
||||
/**
|
||||
* Function type for setting up service interfaces.
|
||||
* @typeParam Output - The specific receipt type returned
|
||||
*/
|
||||
export type SetServiceInterfaces<Output extends ServiceInterfacesReceipt> =
|
||||
(opts: { effects: T.Effects }) => Promise<Output>
|
||||
|
||||
/** Function type for the init-compatible interface updater */
|
||||
export type UpdateServiceInterfaces = (effects: T.Effects) => Promise<null>
|
||||
|
||||
/** Function type for the setupServiceInterfaces helper */
|
||||
export type SetupServiceInterfaces = <Output extends ServiceInterfacesReceipt>(
|
||||
fn: SetServiceInterfaces<Output>,
|
||||
) => UpdateServiceInterfaces
|
||||
|
||||
/**
|
||||
* Constant indicating no interface changes are needed.
|
||||
* Use this as a return value when interfaces don't need to be updated.
|
||||
*/
|
||||
export const NO_INTERFACE_CHANGES = {} as UpdateServiceInterfacesReceipt
|
||||
|
||||
/**
|
||||
* Creates an interface setup function for use in the initialization pipeline.
|
||||
*
|
||||
* **Note:** This is exposed via `sdk.setupInterfaces`. See the SDK documentation
|
||||
* for full usage examples and parameter descriptions.
|
||||
*
|
||||
* Internally, this wrapper:
|
||||
* - Tracks all bindings and interfaces created during setup
|
||||
* - Automatically cleans up stale bindings/interfaces that weren't recreated
|
||||
*
|
||||
* @see sdk.setupInterfaces for usage documentation
|
||||
*/
|
||||
export const setupServiceInterfaces: SetupServiceInterfaces = <
|
||||
Output extends ServiceInterfacesReceipt,
|
||||
>(
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Re-exports input specification types for building action forms.
|
||||
* @see {@link inputSpecTypes} for available input field types (text, number, select, etc.)
|
||||
*/
|
||||
export * as inputSpecTypes from "./actions/input/inputSpecTypes"
|
||||
|
||||
import {
|
||||
@@ -20,107 +24,339 @@ export {
|
||||
CurrentDependenciesResult,
|
||||
} from "./dependencies/setupDependencies"
|
||||
|
||||
/**
|
||||
* Represents an object that can build a daemon (long-running process).
|
||||
* Returned by the `main` export to define how the service's primary process runs.
|
||||
*
|
||||
* The `build()` method is called to start the daemon, and returns an object
|
||||
* with a `term()` method for graceful termination.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const main = sdk.setupMain(async ({ effects }) => {
|
||||
* // Return a DaemonBuildable
|
||||
* return sdk.Daemons.of(effects).addDaemon('primary', { ... })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export type DaemonBuildable = {
|
||||
/** Builds and starts the daemon, returning a handle for termination */
|
||||
build(): Promise<{
|
||||
/** Gracefully terminates the daemon */
|
||||
term(): Promise<void>
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of service interface, determining how it appears in the StartOS UI.
|
||||
* - `"ui"` - A web interface the user can visit (opens in browser)
|
||||
* - `"p2p"` - A peer-to-peer network endpoint (e.g., Bitcoin P2P port)
|
||||
* - `"api"` - An API endpoint for programmatic access (e.g., REST, RPC)
|
||||
*/
|
||||
export type ServiceInterfaceType = "ui" | "p2p" | "api"
|
||||
|
||||
/**
|
||||
* Unix process signals that can be sent to terminate or control processes.
|
||||
* Common signals include SIGTERM (graceful termination) and SIGKILL (forced termination).
|
||||
*/
|
||||
export type Signals = NodeJS.Signals
|
||||
|
||||
/** Signal for graceful termination - allows the process to clean up before exiting */
|
||||
export const SIGTERM: Signals = "SIGTERM"
|
||||
|
||||
/** Signal for forced termination - immediately kills the process without cleanup */
|
||||
export const SIGKILL: Signals = "SIGKILL"
|
||||
|
||||
/** Constant indicating no timeout should be applied (wait indefinitely) */
|
||||
export const NO_TIMEOUT = -1
|
||||
|
||||
/**
|
||||
* Function type for constructing file paths from volume and path components.
|
||||
* Used internally for path resolution across volumes.
|
||||
*/
|
||||
export type PathMaker = (options: { volume: string; path: string }) => string
|
||||
|
||||
/**
|
||||
* Utility type representing a value that may or may not be wrapped in a Promise.
|
||||
* Useful for functions that can accept both synchronous and asynchronous values.
|
||||
*/
|
||||
export type MaybePromise<A> = Promise<A> | A
|
||||
|
||||
/**
|
||||
* Namespace defining the expected exports from a StartOS service package.
|
||||
* Every service must export these functions and values to integrate with StartOS.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In your package's index.ts:
|
||||
* export { main } from './main'
|
||||
* export { init, uninit } from './init'
|
||||
* export { createBackup } from './backups'
|
||||
* export { actions } from './actions'
|
||||
* export const manifest = buildManifest(versionGraph, sdkManifest)
|
||||
* ```
|
||||
*/
|
||||
export namespace ExpectedExports {
|
||||
version: 1
|
||||
|
||||
/** For backing up service data though the startOS UI */
|
||||
/**
|
||||
* Function for backing up service data through the StartOS UI.
|
||||
* Called when the user initiates a backup or during scheduled backups.
|
||||
*
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @returns Promise that resolves when backup is complete
|
||||
*/
|
||||
export type createBackup = (options: { effects: Effects }) => Promise<unknown>
|
||||
|
||||
/**
|
||||
* This is the entrypoint for the main container. Used to start up something like the service that the
|
||||
* package represents, like running a bitcoind in a bitcoind-wrapper.
|
||||
* The main entrypoint for the service container.
|
||||
* This function starts the primary service process (e.g., a database server, web app).
|
||||
* It should return a DaemonBuildable that manages the process lifecycle.
|
||||
*
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @returns Promise resolving to a DaemonBuildable for process management
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* export const main = sdk.setupMain(async ({ effects }) => {
|
||||
* return sdk.Daemons.of(effects)
|
||||
* .addDaemon('primary', {
|
||||
* subcontainer,
|
||||
* exec: { command: sdk.useEntrypoint() },
|
||||
* ready: { display: 'Server', fn: healthCheck }
|
||||
* })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export type main = (options: { effects: Effects }) => Promise<DaemonBuildable>
|
||||
|
||||
/**
|
||||
* Every time a service launches (both on startup, and on install) this function is called before packageInit
|
||||
* Can be used to register callbacks
|
||||
* Initialization function called every time a service starts.
|
||||
* Runs before the main function during install, update, restore, and regular startup.
|
||||
* Use this to set up interfaces, register actions, apply migrations, etc.
|
||||
*
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @param options.kind - The reason for initialization:
|
||||
* - `"install"` - Fresh installation of the service
|
||||
* - `"update"` - Updating from a previous version
|
||||
* - `"restore"` - Restoring from a backup
|
||||
* - `null` - Normal startup (service was already installed)
|
||||
* @returns Promise that resolves when initialization is complete
|
||||
*/
|
||||
export type init = (options: {
|
||||
effects: Effects
|
||||
kind: "install" | "update" | "restore" | null
|
||||
}) => Promise<unknown>
|
||||
/** This will be ran during any time a package is uninstalled, for example during a update
|
||||
* this will be called.
|
||||
|
||||
/**
|
||||
* Cleanup function called when a service is being uninstalled or updated.
|
||||
* Use this to clean up resources, deregister callbacks, or perform other teardown.
|
||||
*
|
||||
* @param options.effects - Effects instance for system operations
|
||||
* @param options.target - The version being transitioned to:
|
||||
* - `ExtendedVersion` - Specific version (during update)
|
||||
* - `VersionRange` - Version range constraint
|
||||
* - `null` - Complete uninstall (no target version)
|
||||
* @returns Promise that resolves when cleanup is complete
|
||||
*/
|
||||
export type uninit = (options: {
|
||||
effects: Effects
|
||||
target: ExtendedVersion | VersionRange | null
|
||||
}) => Promise<unknown>
|
||||
|
||||
/** The service manifest containing metadata, images, volumes, and dependencies */
|
||||
export type manifest = Manifest
|
||||
|
||||
/** The actions registry containing all user-callable actions for the service */
|
||||
export type actions = Actions<Record<ActionId, Action<ActionId, any>>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The Application Binary Interface (ABI) defining all required exports from a StartOS package.
|
||||
* This type ensures type-safety for the complete service interface.
|
||||
*/
|
||||
export type ABI = {
|
||||
/** Backup creation function */
|
||||
createBackup: ExpectedExports.createBackup
|
||||
/** Main service entrypoint */
|
||||
main: ExpectedExports.main
|
||||
/** Initialization function */
|
||||
init: ExpectedExports.init
|
||||
/** Cleanup function */
|
||||
uninit: ExpectedExports.uninit
|
||||
/** Service manifest */
|
||||
manifest: ExpectedExports.manifest
|
||||
/** User-callable actions */
|
||||
actions: ExpectedExports.actions
|
||||
}
|
||||
/** Time duration in milliseconds */
|
||||
export type TimeMs = number
|
||||
|
||||
/** A semantic version string (e.g., "1.0.0", "2.3.1-beta.0") */
|
||||
export type VersionString = string
|
||||
|
||||
/** @internal Unique symbol for type branding daemon objects */
|
||||
declare const DaemonProof: unique symbol
|
||||
|
||||
/**
|
||||
* A receipt proving a daemon was properly created.
|
||||
* Used internally to ensure type safety in daemon management.
|
||||
* @internal
|
||||
*/
|
||||
export type DaemonReceipt = {
|
||||
[DaemonProof]: never
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a running daemon process with methods to wait for completion or terminate.
|
||||
*/
|
||||
export type Daemon = {
|
||||
/**
|
||||
* Waits for the daemon to exit naturally.
|
||||
* @returns Promise resolving to the exit reason/message
|
||||
*/
|
||||
wait(): Promise<string>
|
||||
|
||||
/**
|
||||
* Terminates the daemon, optionally with a specific signal.
|
||||
* @returns Promise resolving to null when termination is complete
|
||||
*/
|
||||
term(): Promise<null>
|
||||
|
||||
/** @internal Type brand */
|
||||
[DaemonProof]: never
|
||||
}
|
||||
|
||||
/**
|
||||
* The result status of a health check.
|
||||
* - `"success"` - The service is healthy
|
||||
* - `"failure"` - The service is unhealthy
|
||||
* - `"starting"` - The service is still starting up
|
||||
*/
|
||||
export type HealthStatus = NamedHealthCheckResult["result"]
|
||||
|
||||
/**
|
||||
* SMTP (email) server configuration for sending emails from services.
|
||||
* Retrieved via `effects.getSystemSmtp()` when the user has configured system-wide SMTP.
|
||||
*/
|
||||
export type SmtpValue = {
|
||||
/** SMTP server hostname or IP address */
|
||||
server: string
|
||||
/** SMTP server port (typically 25, 465, or 587) */
|
||||
port: number
|
||||
/** The "From" email address for outgoing emails */
|
||||
from: string
|
||||
/** SMTP authentication username */
|
||||
login: string
|
||||
/** SMTP authentication password (null if no auth required) */
|
||||
password: string | null | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker class indicating that the container's default entrypoint should be used.
|
||||
* Use `sdk.useEntrypoint()` to create instances of this class.
|
||||
*
|
||||
* When passed as a command, StartOS will use the Docker image's ENTRYPOINT
|
||||
* rather than a custom command.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Use the container's built-in entrypoint
|
||||
* exec: { command: sdk.useEntrypoint() }
|
||||
*
|
||||
* // Use entrypoint with additional arguments
|
||||
* exec: { command: new UseEntrypoint(['--config', '/etc/myapp.conf']) }
|
||||
* ```
|
||||
*/
|
||||
export class UseEntrypoint {
|
||||
/** @internal Marker property for type identification */
|
||||
readonly USE_ENTRYPOINT = "USE_ENTRYPOINT"
|
||||
|
||||
/**
|
||||
* @param overridCmd - Optional command arguments to append to the entrypoint
|
||||
*/
|
||||
constructor(readonly overridCmd?: string[]) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a command is a UseEntrypoint instance.
|
||||
*
|
||||
* @param command - The command to check
|
||||
* @returns True if the command indicates entrypoint usage
|
||||
*/
|
||||
export function isUseEntrypoint(
|
||||
command: CommandType,
|
||||
): command is UseEntrypoint {
|
||||
return typeof command === "object" && "USE_ENTRYPOINT" in command
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a command to execute in a container.
|
||||
* - `string` - A single command string (will be shell-parsed)
|
||||
* - `[string, ...string[]]` - Command with arguments as array (no shell parsing)
|
||||
* - `UseEntrypoint` - Use the container's default ENTRYPOINT
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // String command (shell-parsed)
|
||||
* command: 'nginx -g "daemon off;"'
|
||||
*
|
||||
* // Array command (recommended - no shell parsing issues)
|
||||
* command: ['nginx', '-g', 'daemon off;']
|
||||
*
|
||||
* // Use container entrypoint
|
||||
* command: sdk.useEntrypoint()
|
||||
* ```
|
||||
*/
|
||||
export type CommandType = string | [string, ...string[]] | UseEntrypoint
|
||||
|
||||
/**
|
||||
* Interface returned when a daemon is started.
|
||||
* Provides methods to wait for natural exit or force termination.
|
||||
*/
|
||||
export type DaemonReturned = {
|
||||
/**
|
||||
* Waits for the daemon process to exit naturally.
|
||||
* @returns Promise that resolves when the process exits
|
||||
*/
|
||||
wait(): Promise<unknown>
|
||||
|
||||
/**
|
||||
* Terminates the daemon process.
|
||||
* @param options.signal - The signal to send (default: SIGTERM)
|
||||
* @param options.timeout - Milliseconds to wait before force-killing (default: 30000)
|
||||
* @returns Promise resolving to null when termination is complete
|
||||
*/
|
||||
term(options?: { signal?: Signals; timeout?: number }): Promise<null>
|
||||
}
|
||||
|
||||
/** @internal Unique symbol for hostname type branding */
|
||||
export declare const hostName: unique symbol
|
||||
// asdflkjadsf.onion | 1.2.3.4
|
||||
|
||||
/**
|
||||
* A network hostname string (e.g., "abc123.onion", "192.168.1.1", "myservice.local").
|
||||
* Type-branded for additional type safety.
|
||||
*/
|
||||
export type Hostname = string & { [hostName]: never }
|
||||
|
||||
/**
|
||||
* Unique identifier for a service interface.
|
||||
* Used to reference specific interfaces when exporting or querying.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const interfaceId: ServiceInterfaceId = 'webui'
|
||||
* ```
|
||||
*/
|
||||
export type ServiceInterfaceId = string
|
||||
|
||||
export { ServiceInterface }
|
||||
|
||||
/**
|
||||
* Utility type that extracts all Effect method names in kebab-case format.
|
||||
* Used internally for method routing and serialization.
|
||||
* @internal
|
||||
*/
|
||||
export type EffectMethod<T extends StringObject = Effects> = {
|
||||
[K in keyof T]-?: K extends string
|
||||
? T[K] extends Function
|
||||
@@ -131,74 +367,172 @@ export type EffectMethod<T extends StringObject = Effects> = {
|
||||
: never
|
||||
}[keyof T]
|
||||
|
||||
/**
|
||||
* Options for file/directory synchronization operations (e.g., backups).
|
||||
*/
|
||||
export type SyncOptions = {
|
||||
/** delete files that exist in the target directory, but not in the source directory */
|
||||
/**
|
||||
* If true, delete files in the target directory that don't exist in the source.
|
||||
* Use with caution - this enables true mirroring but can cause data loss.
|
||||
*/
|
||||
delete: boolean
|
||||
/** do not sync files with paths that match these patterns */
|
||||
|
||||
/**
|
||||
* Glob patterns for files/directories to exclude from synchronization.
|
||||
* @example ['*.tmp', 'node_modules/', '.git/']
|
||||
*/
|
||||
exclude: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the metadata that is returned from the metadata call.
|
||||
* File or directory metadata returned from filesystem operations.
|
||||
* Contains information about file type, size, timestamps, and permissions.
|
||||
*/
|
||||
export type Metadata = {
|
||||
/** MIME type or file type description */
|
||||
fileType: string
|
||||
/** True if this is a directory */
|
||||
isDir: boolean
|
||||
/** True if this is a regular file */
|
||||
isFile: boolean
|
||||
/** True if this is a symbolic link */
|
||||
isSymlink: boolean
|
||||
/** File size in bytes (0 for directories) */
|
||||
len: number
|
||||
/** Last modification timestamp */
|
||||
modified?: Date
|
||||
/** Last access timestamp */
|
||||
accessed?: Date
|
||||
/** Creation timestamp */
|
||||
created?: Date
|
||||
/** True if the file is read-only */
|
||||
readonly: boolean
|
||||
/** Owner user ID */
|
||||
uid: number
|
||||
/** Owner group ID */
|
||||
gid: number
|
||||
/** Unix file mode (permissions) as octal number */
|
||||
mode: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for setting up process termination behavior.
|
||||
* @internal
|
||||
*/
|
||||
export type SetResult = {
|
||||
/** Map of package IDs to their dependency health check names */
|
||||
dependsOn: DependsOn
|
||||
/** Signal to send when terminating */
|
||||
signal: Signals
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique identifier for a StartOS package/service.
|
||||
* @example "bitcoind", "gitea", "jellyfin"
|
||||
*/
|
||||
export type PackageId = string
|
||||
|
||||
/** A string message, typically for user display or logging */
|
||||
export type Message = string
|
||||
|
||||
/**
|
||||
* The kind of dependency relationship.
|
||||
* - `"running"` - The dependency must be actively running
|
||||
* - `"exists"` - The dependency must be installed (but doesn't need to be running)
|
||||
*/
|
||||
export type DependencyKind = "running" | "exists"
|
||||
|
||||
/**
|
||||
* Map of package IDs to arrays of health check names that must pass.
|
||||
* Used to specify which health checks a service depends on from other packages.
|
||||
*/
|
||||
export type DependsOn = {
|
||||
[packageId: string]: string[] | readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized error types for service operations.
|
||||
* - `{ error: string }` - Human-readable error message
|
||||
* - `{ errorCode: [number, string] }` - Numeric code with message for programmatic handling
|
||||
*/
|
||||
export type KnownError =
|
||||
| { error: string }
|
||||
| {
|
||||
errorCode: [number, string] | readonly [number, string]
|
||||
}
|
||||
|
||||
/**
|
||||
* Array of dependency requirements for a service.
|
||||
* Each requirement specifies a package ID, version range, and dependency kind.
|
||||
*/
|
||||
export type Dependencies = Array<DependencyRequirement>
|
||||
|
||||
/**
|
||||
* Recursively makes all properties of a type optional.
|
||||
* Useful for partial updates or configuration overrides.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* type Config = { server: { host: string; port: number } }
|
||||
* type PartialConfig = DeepPartial<Config>
|
||||
* // { server?: { host?: string; port?: number } }
|
||||
* ```
|
||||
*/
|
||||
export type DeepPartial<T> = T extends [infer A, ...infer Rest]
|
||||
? [DeepPartial<A>, ...DeepPartial<Rest>]
|
||||
: T extends {}
|
||||
? { [P in keyof T]?: DeepPartial<T[P]> }
|
||||
: T
|
||||
|
||||
/**
|
||||
* Recursively removes readonly modifiers from all properties.
|
||||
* Useful when you need to modify a readonly object.
|
||||
*/
|
||||
export type DeepWritable<T> = {
|
||||
-readonly [K in keyof T]: T[K]
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts a value to a deeply writable version.
|
||||
* This is a type-only operation - the value is returned unchanged.
|
||||
*
|
||||
* @param value - The value to cast
|
||||
* @returns The same value with writable type
|
||||
*/
|
||||
export function writable<T>(value: T): DeepWritable<T> {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively adds readonly modifiers to all properties.
|
||||
* Useful for ensuring immutability at the type level.
|
||||
*/
|
||||
export type DeepReadonly<T> = {
|
||||
readonly [P in keyof T]: DeepReadonly<T[P]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts a value to a deeply readonly version.
|
||||
* This is a type-only operation - the value is returned unchanged.
|
||||
*
|
||||
* @param value - The value to cast
|
||||
* @returns The same value with readonly type
|
||||
*/
|
||||
export function readonly<T>(value: T): DeepReadonly<T> {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility type that accepts both mutable and readonly versions of a type.
|
||||
* Useful for function parameters that should accept either.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* function process(items: AllowReadonly<string[]>) { ... }
|
||||
* process(['a', 'b']) // Works
|
||||
* process(['a', 'b'] as const) // Also works
|
||||
* ```
|
||||
*/
|
||||
export type AllowReadonly<T> =
|
||||
| T
|
||||
| {
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
/**
|
||||
* @module ManifestTypes
|
||||
*
|
||||
* Defines the type for the service manifest, which contains all metadata about
|
||||
* a StartOS package including its name, description, images, volumes, dependencies,
|
||||
* and other configuration.
|
||||
*
|
||||
* The manifest is defined in your package and exported as the `manifest` constant.
|
||||
* It's used by the SDK for type checking and by StartOS for package management.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { sdk } from './sdk'
|
||||
*
|
||||
* export const manifest = sdk.Manifest({
|
||||
* id: 'myservice',
|
||||
* title: 'My Service',
|
||||
* license: 'MIT',
|
||||
* images: { main: { source: { dockerTag: 'myimage:latest' } } },
|
||||
* volumes: ['main'],
|
||||
* dependencies: {},
|
||||
* // ... other required fields
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
import { T } from ".."
|
||||
import { ImageId, ImageSource } from "../types"
|
||||
|
||||
/**
|
||||
* The manifest type for StartOS service packages.
|
||||
*
|
||||
* This is the primary type used to describe a service package. All fields provide
|
||||
* metadata used by StartOS for installation, marketplace display, and runtime configuration.
|
||||
*
|
||||
* Required fields include package identification (id, title), licensing info,
|
||||
* repository URLs, descriptions, and technical specifications (images, volumes).
|
||||
*/
|
||||
export type SDKManifest = {
|
||||
/**
|
||||
* The package identifier used by StartOS. This must be unique amongst all other known packages.
|
||||
@@ -132,7 +166,6 @@ export type SDKManifest = {
|
||||
* `pattern` refers to a regular expression that at least one device of the specified class must match
|
||||
* `patternDescription` is what will be displayed to the user about what kind of device is required
|
||||
* @property {number} ram - Minimum RAM requirement (in megabytes MB)
|
||||
* @property {string[]} arch - List of supported arches
|
||||
* @example
|
||||
* ```
|
||||
hardwareRequirements: {
|
||||
@@ -141,14 +174,12 @@ export type SDKManifest = {
|
||||
{ class: 'processor', pattern: 'i[3579]-10[0-9]{3}U CPU', patternDescription: 'A 10th Generation Intel i-Series processor' },
|
||||
],
|
||||
ram: 8192,
|
||||
arch: ['x86-64'],
|
||||
},
|
||||
* ```
|
||||
*/
|
||||
readonly hardwareRequirements?: {
|
||||
readonly device?: T.DeviceFilter[]
|
||||
readonly ram?: number | null
|
||||
readonly arch?: string[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +188,11 @@ export type SDKManifest = {
|
||||
readonly hardwareAcceleration?: boolean
|
||||
}
|
||||
|
||||
// this is hacky but idk a more elegant way
|
||||
/**
|
||||
* @internal
|
||||
* Helper type for generating all valid architecture combinations.
|
||||
* Allows specifying one, two, or three target architectures in any order.
|
||||
*/
|
||||
type ArchOptions = {
|
||||
0: ["x86_64", "aarch64", "riscv64"]
|
||||
1: ["aarch64", "x86_64", "riscv64"]
|
||||
@@ -175,13 +210,55 @@ type ArchOptions = {
|
||||
13: ["aarch64"]
|
||||
14: ["riscv64"]
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a Docker image used by the service.
|
||||
*
|
||||
* Specifies where to get the image (Docker Hub, local build) and
|
||||
* which CPU architectures it supports.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Using a pre-built Docker Hub image
|
||||
* {
|
||||
* source: { dockerTag: 'nginx:latest' },
|
||||
* arch: ['x86_64', 'aarch64']
|
||||
* }
|
||||
*
|
||||
* // Building from a local Dockerfile
|
||||
* {
|
||||
* source: {
|
||||
* dockerBuild: {
|
||||
* dockerFile: './Dockerfile',
|
||||
* workdir: '.'
|
||||
* }
|
||||
* },
|
||||
* arch: ['x86_64']
|
||||
* }
|
||||
*
|
||||
* // With NVIDIA GPU support
|
||||
* {
|
||||
* source: { dockerTag: 'tensorflow/tensorflow:latest-gpu' },
|
||||
* arch: ['x86_64'],
|
||||
* nvidiaContainer: true
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type SDKImageInputSpec = {
|
||||
[A in keyof ArchOptions]: {
|
||||
/** Where to get the image (Docker tag or local build) */
|
||||
source: Exclude<ImageSource, "packed">
|
||||
/** CPU architectures this image supports */
|
||||
arch?: ArchOptions[A]
|
||||
/** If architecture is missing, use this architecture with emulation */
|
||||
emulateMissingAs?: ArchOptions[A][number] | null
|
||||
/** Enable NVIDIA container runtime for GPU acceleration */
|
||||
nvidiaContainer?: boolean
|
||||
}
|
||||
}[keyof ArchOptions]
|
||||
|
||||
/**
|
||||
* Configuration for a service dependency.
|
||||
* Extracted from the main Manifest type for dependency declarations.
|
||||
*/
|
||||
export type ManifestDependency = T.Manifest["dependencies"][string]
|
||||
|
||||
@@ -1,6 +1,50 @@
|
||||
/**
|
||||
* @module Drop
|
||||
*
|
||||
* Provides RAII-style resource management for JavaScript using FinalizationRegistry.
|
||||
* Classes extending Drop get automatic cleanup when garbage collected, ensuring
|
||||
* resources are released even if explicitly dropped.
|
||||
*
|
||||
* This is used for managing long-lived resources like health checks, daemons,
|
||||
* and other objects that need cleanup when no longer referenced.
|
||||
*/
|
||||
|
||||
/** @internal Unique symbol for drop reference identification */
|
||||
const dropId: unique symbol = Symbol("id")
|
||||
|
||||
/** @internal Reference type for tracking droppable resources */
|
||||
export type DropRef = { [dropId]: number }
|
||||
|
||||
/**
|
||||
* Abstract base class for objects that need cleanup when garbage collected.
|
||||
*
|
||||
* Subclasses must implement `onDrop()` to define cleanup behavior.
|
||||
* The cleanup is automatically triggered when the object is garbage collected,
|
||||
* or can be triggered manually by calling `drop()`.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class ResourceHolder extends Drop {
|
||||
* private handle: Handle
|
||||
*
|
||||
* constructor() {
|
||||
* super()
|
||||
* this.handle = acquireResource()
|
||||
* }
|
||||
*
|
||||
* onDrop(): void {
|
||||
* releaseResource(this.handle)
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Resource is automatically released when holder is garbage collected
|
||||
* let holder = new ResourceHolder()
|
||||
* holder = null // Eventually triggers onDrop()
|
||||
*
|
||||
* // Or manually release
|
||||
* holder.drop()
|
||||
* ```
|
||||
*/
|
||||
export abstract class Drop {
|
||||
private static weak: { [id: number]: Drop } = {}
|
||||
private static registry = new FinalizationRegistry((id: number) => {
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
/**
|
||||
* Converts an unknown value to an Error instance.
|
||||
*
|
||||
* Handles various error formats commonly encountered in JavaScript:
|
||||
* - Error instances are returned as-is (re-wrapped)
|
||||
* - Strings become Error messages
|
||||
* - Other values are JSON-stringified into the Error message
|
||||
*
|
||||
* @param e - The unknown value to convert
|
||||
* @returns An Error instance representing the input
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* try {
|
||||
* await someOperation()
|
||||
* } catch (e) {
|
||||
* const error = asError(e)
|
||||
* console.error(error.message)
|
||||
* }
|
||||
*
|
||||
* // Works with any thrown value
|
||||
* asError(new Error('oops')) // Error: oops
|
||||
* asError('string error') // Error: string error
|
||||
* asError({ code: 500 }) // Error: {"code":500}
|
||||
* ```
|
||||
*/
|
||||
export const asError = (e: unknown) => {
|
||||
if (e instanceof Error) {
|
||||
return new Error(e as any)
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
/**
|
||||
* Computes a partial diff between two values.
|
||||
* Returns undefined if values are equal, otherwise returns the differences.
|
||||
*
|
||||
* For objects, recursively compares properties. For arrays, finds new items
|
||||
* not present in the previous array.
|
||||
*
|
||||
* @typeParam T - The type of values being compared
|
||||
* @param prev - The previous value
|
||||
* @param next - The next value
|
||||
* @returns Object containing diff, or undefined if equal
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* partialDiff({ a: 1, b: 2 }, { a: 1, b: 3 })
|
||||
* // Returns: { diff: { b: 3 } }
|
||||
*
|
||||
* partialDiff({ a: 1 }, { a: 1 })
|
||||
* // Returns: undefined
|
||||
* ```
|
||||
*/
|
||||
export function partialDiff<T>(
|
||||
prev: T,
|
||||
next: T,
|
||||
@@ -46,6 +67,28 @@ export function partialDiff<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deeply merges multiple objects or arrays into one.
|
||||
*
|
||||
* For objects: Recursively merges properties from all input objects.
|
||||
* For arrays: Combines unique items from all input arrays.
|
||||
* Primitives: Returns the last non-object value.
|
||||
*
|
||||
* @param args - Values to merge (objects, arrays, or primitives)
|
||||
* @returns The merged result
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* deepMerge({ a: 1 }, { b: 2 })
|
||||
* // Returns: { a: 1, b: 2 }
|
||||
*
|
||||
* deepMerge({ a: { x: 1 } }, { a: { y: 2 } })
|
||||
* // Returns: { a: { x: 1, y: 2 } }
|
||||
*
|
||||
* deepMerge([1, 2], [2, 3])
|
||||
* // Returns: [1, 2, 3]
|
||||
* ```
|
||||
*/
|
||||
export function deepMerge(...args: unknown[]): unknown {
|
||||
const lastItem = (args as any)[args.length - 1]
|
||||
if (typeof lastItem !== "object" || !lastItem) return lastItem
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
/**
|
||||
* @module inMs
|
||||
*
|
||||
* Parses human-readable time strings into milliseconds.
|
||||
*/
|
||||
|
||||
/** @internal Regex for parsing time strings */
|
||||
const matchTimeRegex = /^\s*(\d+)?(\.\d+)?\s*(ms|s|m|h|d)/
|
||||
|
||||
/**
|
||||
* Gets the millisecond multiplier for a time unit.
|
||||
* @internal
|
||||
*/
|
||||
const unitMultiplier = (unit?: string) => {
|
||||
if (!unit) return 1
|
||||
if (unit === "ms") return 1
|
||||
@@ -9,12 +20,44 @@ const unitMultiplier = (unit?: string) => {
|
||||
if (unit === "d") return 1000 * 60 * 60 * 24
|
||||
throw new Error(`Invalid unit: ${unit}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts decimal digits to milliseconds.
|
||||
* @internal
|
||||
*/
|
||||
const digitsMs = (digits: string | null, multiplier: number) => {
|
||||
if (!digits) return 0
|
||||
const value = parseInt(digits.slice(1))
|
||||
const divideBy = multiplier / Math.pow(10, digits.length - 1)
|
||||
return Math.round(value * divideBy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a time value (string or number) into milliseconds.
|
||||
*
|
||||
* Accepts human-readable time strings with units:
|
||||
* - `ms` - milliseconds
|
||||
* - `s` - seconds
|
||||
* - `m` - minutes
|
||||
* - `h` - hours
|
||||
* - `d` - days
|
||||
*
|
||||
* Numbers are returned unchanged (assumed to be in milliseconds).
|
||||
*
|
||||
* @param time - Time value as string with unit or number in milliseconds
|
||||
* @returns Time in milliseconds, or undefined if input is undefined
|
||||
* @throws Error if string format is invalid
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* inMs('5s') // 5000
|
||||
* inMs('1.5m') // 90000
|
||||
* inMs('2h') // 7200000
|
||||
* inMs('1d') // 86400000
|
||||
* inMs(3000) // 3000
|
||||
* inMs('500ms') // 500
|
||||
* ```
|
||||
*/
|
||||
export const inMs = (time?: string | number) => {
|
||||
if (typeof time === "number") return time
|
||||
if (!time) return undefined
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
/**
|
||||
* Creates a memoized version of a function that only executes once.
|
||||
* Subsequent calls return the cached result from the first invocation.
|
||||
*
|
||||
* Useful for lazy initialization where you want to defer computation
|
||||
* until first use, but then cache the result.
|
||||
*
|
||||
* @typeParam B - The return type of the function
|
||||
* @param fn - The function to execute once and cache
|
||||
* @returns A function that returns the cached result
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const getExpensiveValue = once(() => {
|
||||
* console.log('Computing...')
|
||||
* return computeExpensiveValue()
|
||||
* })
|
||||
*
|
||||
* getExpensiveValue() // Logs "Computing...", returns value
|
||||
* getExpensiveValue() // Returns cached value, no log
|
||||
* getExpensiveValue() // Returns cached value, no log
|
||||
* ```
|
||||
*/
|
||||
export function once<B>(fn: () => B): () => B {
|
||||
let result: [B] | [] = []
|
||||
return () => {
|
||||
|
||||
@@ -1,67 +1,106 @@
|
||||
/**
|
||||
* @module patterns
|
||||
*
|
||||
* Pre-built validation patterns for common input types.
|
||||
* Use these with text inputs to validate user-entered values.
|
||||
*
|
||||
* Each pattern includes a regex and a human-readable description
|
||||
* that's shown when validation fails.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Patterns } from '@start9labs/sdk'
|
||||
*
|
||||
* // Validate an email field
|
||||
* Value.text({
|
||||
* name: 'Email',
|
||||
* patterns: [Patterns.email]
|
||||
* })
|
||||
*
|
||||
* // Validate a Tor hostname
|
||||
* Value.text({
|
||||
* name: 'Onion Address',
|
||||
* patterns: [Patterns.torHostname]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
import { Pattern } from "../actions/input/inputSpecTypes"
|
||||
import * as regexes from "./regexes"
|
||||
|
||||
/** Validates IPv6 addresses */
|
||||
export const ipv6: Pattern = {
|
||||
regex: regexes.ipv6.matches(),
|
||||
description: "Must be a valid IPv6 address",
|
||||
}
|
||||
|
||||
/** Validates IPv4 addresses (e.g., "192.168.1.1") */
|
||||
export const ipv4: Pattern = {
|
||||
regex: regexes.ipv4.matches(),
|
||||
description: "Must be a valid IPv4 address",
|
||||
}
|
||||
|
||||
/** Validates general hostnames */
|
||||
export const hostname: Pattern = {
|
||||
regex: regexes.hostname.matches(),
|
||||
description: "Must be a valid hostname",
|
||||
}
|
||||
|
||||
/** Validates .local mDNS hostnames (e.g., "mydevice.local") */
|
||||
export const localHostname: Pattern = {
|
||||
regex: regexes.localHostname.matches(),
|
||||
description: 'Must be a valid ".local" hostname',
|
||||
}
|
||||
|
||||
/** Validates Tor .onion hostnames */
|
||||
export const torHostname: Pattern = {
|
||||
regex: regexes.torHostname.matches(),
|
||||
description: 'Must be a valid Tor (".onion") hostname',
|
||||
}
|
||||
|
||||
/** Validates general URLs */
|
||||
export const url: Pattern = {
|
||||
regex: regexes.url.matches(),
|
||||
description: "Must be a valid URL",
|
||||
}
|
||||
|
||||
/** Validates .local mDNS URLs */
|
||||
export const localUrl: Pattern = {
|
||||
regex: regexes.localUrl.matches(),
|
||||
description: 'Must be a valid ".local" URL',
|
||||
}
|
||||
|
||||
/** Validates Tor .onion URLs */
|
||||
export const torUrl: Pattern = {
|
||||
regex: regexes.torUrl.matches(),
|
||||
description: 'Must be a valid Tor (".onion") URL',
|
||||
}
|
||||
|
||||
/** Validates ASCII-only text (printable characters) */
|
||||
export const ascii: Pattern = {
|
||||
regex: regexes.ascii.matches(),
|
||||
description:
|
||||
"May only contain ASCII characters. See https://www.w3schools.com/charsets/ref_html_ascii.asp",
|
||||
}
|
||||
|
||||
/** Validates fully qualified domain names (FQDNs) */
|
||||
export const domain: Pattern = {
|
||||
regex: regexes.domain.matches(),
|
||||
description: "Must be a valid Fully Qualified Domain Name",
|
||||
}
|
||||
|
||||
/** Validates email addresses (e.g., "user@example.com") */
|
||||
export const email: Pattern = {
|
||||
regex: regexes.email.matches(),
|
||||
description: "Must be a valid email address",
|
||||
}
|
||||
|
||||
/** Validates email addresses with optional display name (e.g., "John Doe <john@example.com>") */
|
||||
export const emailWithName: Pattern = {
|
||||
regex: regexes.emailWithName.matches(),
|
||||
description: "Must be a valid email address, optionally with a name",
|
||||
}
|
||||
|
||||
/** Validates base64-encoded strings */
|
||||
export const base64: Pattern = {
|
||||
regex: regexes.base64.matches(),
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { arrayOf, string } from "ts-matches"
|
||||
|
||||
/**
|
||||
* Normalizes a command into an array format suitable for execution.
|
||||
*
|
||||
* If the command is already an array, it's returned as-is.
|
||||
* If it's a string, it's wrapped in `sh -c` for shell interpretation.
|
||||
*
|
||||
* @param command - Command as string or array of strings
|
||||
* @returns Command as array suitable for process execution
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* splitCommand(['nginx', '-g', 'daemon off;'])
|
||||
* // Returns: ['nginx', '-g', 'daemon off;']
|
||||
*
|
||||
* splitCommand('nginx -g "daemon off;"')
|
||||
* // Returns: ['sh', '-c', 'nginx -g "daemon off;"']
|
||||
* ```
|
||||
*/
|
||||
export const splitCommand = (
|
||||
command: string | [string, ...string[]],
|
||||
): string[] => {
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
/**
|
||||
* @module typeHelpers
|
||||
*
|
||||
* Utility types and type guards used throughout the SDK for type manipulation
|
||||
* and runtime type checking.
|
||||
*/
|
||||
import * as T from "../types"
|
||||
|
||||
/**
|
||||
* Flattens an intersection type into a single object type.
|
||||
* Makes hover information more readable by expanding intersections.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* type A = { foo: string }
|
||||
* type B = { bar: number }
|
||||
* type AB = A & B // Shows as "A & B"
|
||||
* type Flat = FlattenIntersection<A & B> // Shows as { foo: string; bar: number }
|
||||
* ```
|
||||
*/
|
||||
// prettier-ignore
|
||||
export type FlattenIntersection<T> =
|
||||
export type FlattenIntersection<T> =
|
||||
T extends ArrayLike<any> ? T :
|
||||
T extends object ? {} & {[P in keyof T]: T[P]} :
|
||||
T;
|
||||
|
||||
/**
|
||||
* Alias for FlattenIntersection for shorter usage.
|
||||
* @see FlattenIntersection
|
||||
*/
|
||||
export type _<T> = FlattenIntersection<T>
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a KnownError.
|
||||
* KnownError is the standard error format for service operations.
|
||||
*
|
||||
* @param e - The value to check
|
||||
* @returns True if the value is a KnownError object
|
||||
*/
|
||||
export const isKnownError = (e: unknown): e is T.KnownError =>
|
||||
e instanceof Object && ("error" in e || "error-code" in e)
|
||||
|
||||
/** @internal Symbol for affine type branding */
|
||||
declare const affine: unique symbol
|
||||
|
||||
/**
|
||||
* Type brand for creating affine types (types that can only be used in specific contexts).
|
||||
* Used to prevent values from being used outside their intended context.
|
||||
*
|
||||
* @typeParam A - The context identifier
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* type BackupEffects = Effects & Affine<"Backups">
|
||||
* // BackupEffects can only be created in backup context
|
||||
* ```
|
||||
*/
|
||||
export type Affine<A> = { [affine]: A }
|
||||
|
||||
type NeverPossible = { [affine]: string }
|
||||
|
||||
@@ -67,24 +67,83 @@ import {
|
||||
import { getOwnServiceInterfaces } from "../../base/lib/util/getServiceInterfaces"
|
||||
import { Volumes, createVolumes } from "./util/Volume"
|
||||
|
||||
export const OSVersion = testTypeVersion("0.4.0-alpha.18")
|
||||
/**
|
||||
* The minimum StartOS version this SDK is compatible with.
|
||||
* Used internally for version checking.
|
||||
*/
|
||||
export const OSVersion = testTypeVersion("0.4.0-alpha.19")
|
||||
|
||||
/** @internal Helper type for conditional type resolution */
|
||||
// prettier-ignore
|
||||
type AnyNeverCond<T extends any[], Then, Else> =
|
||||
type AnyNeverCond<T extends any[], Then, Else> =
|
||||
T extends [] ? Else :
|
||||
T extends [never, ...Array<any>] ? Then :
|
||||
T extends [any, ...infer U] ? AnyNeverCond<U,Then, Else> :
|
||||
never
|
||||
|
||||
/**
|
||||
* The main SDK class for building StartOS service packages.
|
||||
*
|
||||
* StartSdk provides a fluent API for creating services with type-safe access to:
|
||||
* - Service manifest and volumes
|
||||
* - Actions (user-callable operations)
|
||||
* - Health checks and daemon management
|
||||
* - Network interfaces
|
||||
* - Dependency management
|
||||
* - Backup/restore functionality
|
||||
* - Container management (SubContainer)
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type, providing type safety for volumes, images, and dependencies
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In sdk.ts - create and export the SDK instance
|
||||
* import { StartSdk } from '@start9labs/start-sdk'
|
||||
* import { manifest } from './manifest'
|
||||
*
|
||||
* export const sdk = StartSdk.of().withManifest(manifest).build(true)
|
||||
*
|
||||
* // Now use sdk throughout your package:
|
||||
* // sdk.volumes.main - type-safe access to volumes
|
||||
* // sdk.Action.withInput(...) - create actions
|
||||
* // sdk.Daemons.of(effects) - create daemon managers
|
||||
* // sdk.SubContainer.of(...) - create containers
|
||||
* ```
|
||||
*/
|
||||
export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
private constructor(readonly manifest: Manifest) {}
|
||||
|
||||
/**
|
||||
* Creates a new StartSdk builder instance.
|
||||
* Call `.withManifest()` next to attach your service manifest.
|
||||
*
|
||||
* @returns A new StartSdk instance (uninitialized)
|
||||
*/
|
||||
static of() {
|
||||
return new StartSdk<never>(null as never)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a manifest to the SDK, enabling type-safe access to
|
||||
* volumes, images, and dependencies defined in the manifest.
|
||||
*
|
||||
* @typeParam Manifest - The manifest type
|
||||
* @param manifest - The service manifest object
|
||||
* @returns A new StartSdk instance with the manifest attached
|
||||
*/
|
||||
withManifest<Manifest extends T.SDKManifest = never>(manifest: Manifest) {
|
||||
return new StartSdk<Manifest>(manifest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the final SDK instance with all utilities and helpers.
|
||||
*
|
||||
* This must be called after `.withManifest()` to get the usable SDK object.
|
||||
* The `isReady` parameter is a type-level check that ensures a manifest was provided.
|
||||
*
|
||||
* @param isReady - Pass `true` (only compiles if manifest was provided)
|
||||
* @returns The complete SDK object with all methods and utilities
|
||||
*/
|
||||
build(isReady: AnyNeverCond<[Manifest], "Build not ready", true>) {
|
||||
type NestedEffects = "subcontainer" | "store" | "action"
|
||||
type InterfaceEffects =
|
||||
@@ -741,6 +800,45 @@ export class StartSdk<Manifest extends T.SDKManifest> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a command in a temporary container and returns the output.
|
||||
*
|
||||
* This is a convenience function for one-off command execution.
|
||||
* For long-running processes or multiple commands, use `sdk.SubContainer` instead.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
*
|
||||
* @param effects - Effects instance for system operations
|
||||
* @param image - The container image to use
|
||||
* @param image.imageId - Image ID from the manifest's images
|
||||
* @param image.sharedRun - Whether to share the run directory with other containers
|
||||
* @param command - The command to execute (string, array, or UseEntrypoint)
|
||||
* @param options - Execution options including mounts and environment
|
||||
* @param options.mounts - Volume mounts for the container (or null for none)
|
||||
* @param name - Optional name for debugging/logging
|
||||
* @returns Promise resolving to stdout and stderr from the command
|
||||
* @throws ExitError if the command exits with a non-zero code or signal
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Run a simple command
|
||||
* const result = await runCommand(
|
||||
* effects,
|
||||
* { imageId: 'main' },
|
||||
* ['echo', 'Hello, World!'],
|
||||
* { mounts: null }
|
||||
* )
|
||||
* console.log(result.stdout) // "Hello, World!\n"
|
||||
*
|
||||
* // Run with volume mounts
|
||||
* const result = await runCommand(
|
||||
* effects,
|
||||
* { imageId: 'main' },
|
||||
* ['cat', '/data/config.json'],
|
||||
* { mounts: sdk.Mounts.of().mountVolume({ volumeId: 'main', mountpoint: '/data' }) }
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export async function runCommand<Manifest extends T.SDKManifest>(
|
||||
effects: Effects,
|
||||
image: { imageId: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean },
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
/**
|
||||
* @module Backups
|
||||
*
|
||||
* Provides backup and restore functionality for StartOS services.
|
||||
* The Backups class uses rsync to efficiently synchronize service data
|
||||
* to and from backup destinations.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple backup of all volumes
|
||||
* export const createBackup = Backups.ofVolumes<Manifest>('main', 'config')
|
||||
*
|
||||
* // Advanced backup with hooks
|
||||
* export const createBackup = Backups.ofVolumes<Manifest>('main')
|
||||
* .setPreBackup(async (effects) => {
|
||||
* // Stop accepting writes before backup
|
||||
* await stopService()
|
||||
* })
|
||||
* .setPostBackup(async (effects) => {
|
||||
* // Resume after backup
|
||||
* await startService()
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
import * as T from "../../../base/lib/types"
|
||||
import * as child_process from "child_process"
|
||||
import * as fs from "fs/promises"
|
||||
@@ -5,32 +29,113 @@ import { Affine, asError } from "../util"
|
||||
import { ExtendedVersion, VersionRange } from "../../../base/lib"
|
||||
import { InitKind, InitScript } from "../../../base/lib/inits"
|
||||
|
||||
/**
|
||||
* Default sync options for backup/restore operations.
|
||||
* - `delete: true` - Remove files in destination that don't exist in source
|
||||
* - `exclude: []` - No exclusions by default
|
||||
*/
|
||||
export const DEFAULT_OPTIONS: T.SyncOptions = {
|
||||
delete: true,
|
||||
exclude: [],
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a single backup synchronization operation.
|
||||
* Maps a source data path to a backup destination path.
|
||||
*
|
||||
* @typeParam Volumes - The volume ID type from the manifest
|
||||
*/
|
||||
export type BackupSync<Volumes extends string> = {
|
||||
/** Source path on the data volume (e.g., "/media/startos/volumes/main/data") */
|
||||
dataPath: `/media/startos/volumes/${Volumes}/${string}`
|
||||
/** Destination path in the backup (e.g., "/media/startos/backup/volumes/main/") */
|
||||
backupPath: `/media/startos/backup/${string}`
|
||||
/** Sync options applied to both backup and restore */
|
||||
options?: Partial<T.SyncOptions>
|
||||
/** Sync options applied only during backup (merged with options) */
|
||||
backupOptions?: Partial<T.SyncOptions>
|
||||
/** Sync options applied only during restore (merged with options) */
|
||||
restoreOptions?: Partial<T.SyncOptions>
|
||||
}
|
||||
|
||||
/**
|
||||
* Effects type with backup context marker.
|
||||
* Provides type safety to prevent backup operations in non-backup contexts.
|
||||
*/
|
||||
export type BackupEffects = T.Effects & Affine<"Backups">
|
||||
|
||||
/**
|
||||
* Manages backup and restore operations for a StartOS service.
|
||||
*
|
||||
* Exposed via `sdk.Backups`. The Backups class provides a fluent API for
|
||||
* configuring which volumes to back up and optional hooks for pre/post
|
||||
* backup/restore operations. It uses rsync for efficient incremental backups.
|
||||
*
|
||||
* Common usage patterns:
|
||||
* - Simple: `sdk.Backups.ofVolumes('main')` - Back up the main volume
|
||||
* - Multiple volumes: `sdk.Backups.ofVolumes('main', 'config', 'logs')`
|
||||
* - With hooks: Add pre/post callbacks for database dumps, service stops, etc.
|
||||
* - Custom paths: Use `addSync()` for non-standard backup mappings
|
||||
*
|
||||
* @typeParam M - The service manifest type for type-safe volume names
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In your package's exports:
|
||||
* export const createBackup = Backups.ofVolumes<Manifest>('main', 'config')
|
||||
*
|
||||
* // With database dump before backup
|
||||
* export const createBackup = Backups.ofVolumes<Manifest>('main')
|
||||
* .setPreBackup(async (effects) => {
|
||||
* // Create a database dump before backing up files
|
||||
* await subcontainer.exec(['pg_dump', '-f', '/data/backup.sql'])
|
||||
* })
|
||||
*
|
||||
* // Exclude temporary files
|
||||
* export const createBackup = Backups.withOptions({ exclude: ['*.tmp', 'cache/'] })
|
||||
* .addVolume('main')
|
||||
* ```
|
||||
*/
|
||||
export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
private constructor(
|
||||
/** @internal Default sync options */
|
||||
private options = DEFAULT_OPTIONS,
|
||||
/** @internal Options specific to restore operations */
|
||||
private restoreOptions: Partial<T.SyncOptions> = {},
|
||||
/** @internal Options specific to backup operations */
|
||||
private backupOptions: Partial<T.SyncOptions> = {},
|
||||
/** @internal Set of sync configurations */
|
||||
private backupSet = [] as BackupSync<M["volumes"][number]>[],
|
||||
/** @internal Hook called before backup starts */
|
||||
private preBackup = async (effects: BackupEffects) => {},
|
||||
/** @internal Hook called after backup completes */
|
||||
private postBackup = async (effects: BackupEffects) => {},
|
||||
/** @internal Hook called before restore starts */
|
||||
private preRestore = async (effects: BackupEffects) => {},
|
||||
/** @internal Hook called after restore completes */
|
||||
private postRestore = async (effects: BackupEffects) => {},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a Backups instance configured to back up the specified volumes.
|
||||
* This is the most common way to create a backup configuration.
|
||||
*
|
||||
* Each volume is backed up to a corresponding path in the backup destination
|
||||
* using the volume's name as the subdirectory.
|
||||
*
|
||||
* @typeParam M - The manifest type (inferred from volume names)
|
||||
* @param volumeNames - Volume IDs to include in backups (from manifest.volumes)
|
||||
* @returns A configured Backups instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Back up a single volume
|
||||
* export const createBackup = Backups.ofVolumes<Manifest>('main')
|
||||
*
|
||||
* // Back up multiple volumes
|
||||
* export const createBackup = Backups.ofVolumes<Manifest>('main', 'config', 'logs')
|
||||
* ```
|
||||
*/
|
||||
static ofVolumes<M extends T.SDKManifest = never>(
|
||||
...volumeNames: Array<M["volumes"][number]>
|
||||
): Backups<M> {
|
||||
@@ -42,18 +147,56 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Backups instance from explicit sync configurations.
|
||||
* Use this for custom source/destination path mappings.
|
||||
*
|
||||
* @typeParam M - The manifest type
|
||||
* @param syncs - Array of sync configurations
|
||||
* @returns A configured Backups instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const backups = Backups.ofSyncs<Manifest>(
|
||||
* { dataPath: '/media/startos/volumes/main/data', backupPath: '/media/startos/backup/data' },
|
||||
* { dataPath: '/media/startos/volumes/main/config', backupPath: '/media/startos/backup/config' }
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
static ofSyncs<M extends T.SDKManifest = never>(
|
||||
...syncs: BackupSync<M["volumes"][number]>[]
|
||||
) {
|
||||
return syncs.reduce((acc, x) => acc.addSync(x), new Backups<M>())
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Backups instance with custom default sync options.
|
||||
* Call `addVolume()` or `addSync()` to add volumes after setting options.
|
||||
*
|
||||
* @typeParam M - The manifest type
|
||||
* @param options - Default sync options (merged with DEFAULT_OPTIONS)
|
||||
* @returns An empty Backups instance with the specified options
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Exclude cache and temp files from all backups
|
||||
* export const createBackup = Backups.withOptions<Manifest>({
|
||||
* exclude: ['cache/', '*.tmp', '*.log']
|
||||
* }).addVolume('main')
|
||||
* ```
|
||||
*/
|
||||
static withOptions<M extends T.SDKManifest = never>(
|
||||
options?: Partial<T.SyncOptions>,
|
||||
) {
|
||||
return new Backups<M>({ ...DEFAULT_OPTIONS, ...options })
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets default sync options for both backup and restore operations.
|
||||
*
|
||||
* @param options - Sync options to merge with current defaults
|
||||
* @returns This instance for chaining
|
||||
*/
|
||||
setOptions(options?: Partial<T.SyncOptions>) {
|
||||
this.options = {
|
||||
...this.options,
|
||||
@@ -62,6 +205,13 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets sync options applied only during backup operations.
|
||||
* These are merged with the default options.
|
||||
*
|
||||
* @param options - Backup-specific sync options
|
||||
* @returns This instance for chaining
|
||||
*/
|
||||
setBackupOptions(options?: Partial<T.SyncOptions>) {
|
||||
this.backupOptions = {
|
||||
...this.backupOptions,
|
||||
@@ -70,6 +220,13 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets sync options applied only during restore operations.
|
||||
* These are merged with the default options.
|
||||
*
|
||||
* @param options - Restore-specific sync options
|
||||
* @returns This instance for chaining
|
||||
*/
|
||||
setRestoreOptions(options?: Partial<T.SyncOptions>) {
|
||||
this.restoreOptions = {
|
||||
...this.restoreOptions,
|
||||
@@ -78,26 +235,88 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a function to run before backup starts.
|
||||
* Use this to prepare the service for backup (e.g., flush caches,
|
||||
* create database dumps, pause writes).
|
||||
*
|
||||
* @param fn - Async function to run before backup
|
||||
* @returns This instance for chaining
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Backups.ofVolumes<Manifest>('main')
|
||||
* .setPreBackup(async (effects) => {
|
||||
* // Flush database to disk
|
||||
* await db.checkpoint()
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
setPreBackup(fn: (effects: BackupEffects) => Promise<void>) {
|
||||
this.preBackup = fn
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a function to run after backup completes.
|
||||
* Use this to resume normal operations after backup.
|
||||
*
|
||||
* @param fn - Async function to run after backup
|
||||
* @returns This instance for chaining
|
||||
*/
|
||||
setPostBackup(fn: (effects: BackupEffects) => Promise<void>) {
|
||||
this.postBackup = fn
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a function to run before restore starts.
|
||||
* Use this to prepare for incoming data (e.g., stop services,
|
||||
* clear existing data).
|
||||
*
|
||||
* @param fn - Async function to run before restore
|
||||
* @returns This instance for chaining
|
||||
*/
|
||||
setPreRestore(fn: (effects: BackupEffects) => Promise<void>) {
|
||||
this.preRestore = fn
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a function to run after restore completes.
|
||||
* Use this to finalize restore (e.g., run migrations, rebuild indexes).
|
||||
*
|
||||
* @param fn - Async function to run after restore
|
||||
* @returns This instance for chaining
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Backups.ofVolumes<Manifest>('main')
|
||||
* .setPostRestore(async (effects) => {
|
||||
* // Rebuild search indexes after restore
|
||||
* await rebuildIndexes()
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
setPostRestore(fn: (effects: BackupEffects) => Promise<void>) {
|
||||
this.postRestore = fn
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a volume to the backup configuration.
|
||||
*
|
||||
* @param volume - Volume ID from the manifest
|
||||
* @param options - Optional sync options for this specific volume
|
||||
* @returns This instance for chaining
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Backups.withOptions<Manifest>({ exclude: ['*.tmp'] })
|
||||
* .addVolume('main')
|
||||
* .addVolume('logs', { backupOptions: { exclude: ['*.log'] } })
|
||||
* ```
|
||||
*/
|
||||
addVolume(
|
||||
volume: M["volumes"][number],
|
||||
options?: Partial<{
|
||||
@@ -113,11 +332,30 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom sync configuration to the backup.
|
||||
* Use this for non-standard path mappings.
|
||||
*
|
||||
* @param sync - Sync configuration with source and destination paths
|
||||
* @returns This instance for chaining
|
||||
*/
|
||||
addSync(sync: BackupSync<M["volumes"][0]>) {
|
||||
this.backupSet.push(sync)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a backup by syncing all configured volumes to the backup destination.
|
||||
* Called by StartOS when the user initiates a backup.
|
||||
*
|
||||
* Execution order:
|
||||
* 1. Runs preBackup hook
|
||||
* 2. Syncs each volume using rsync
|
||||
* 3. Saves the data version to the backup
|
||||
* 4. Runs postBackup hook
|
||||
*
|
||||
* @param effects - Effects instance for system operations
|
||||
*/
|
||||
async createBackup(effects: T.Effects) {
|
||||
await this.preBackup(effects as BackupEffects)
|
||||
for (const item of this.backupSet) {
|
||||
@@ -143,12 +381,30 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* InitScript implementation - handles restore during initialization.
|
||||
* Called automatically during the init phase when kind is "restore".
|
||||
*
|
||||
* @param effects - Effects instance
|
||||
* @param kind - The initialization kind (only acts on "restore")
|
||||
*/
|
||||
async init(effects: T.Effects, kind: InitKind): Promise<void> {
|
||||
if (kind === "restore") {
|
||||
await this.restoreBackup(effects)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores data from a backup by syncing from backup destination to volumes.
|
||||
*
|
||||
* Execution order:
|
||||
* 1. Runs preRestore hook
|
||||
* 2. Syncs each volume from backup using rsync
|
||||
* 3. Restores the data version from the backup
|
||||
* 4. Runs postRestore hook
|
||||
*
|
||||
* @param effects - Effects instance for system operations
|
||||
*/
|
||||
async restoreBackup(effects: T.Effects) {
|
||||
this.preRestore(effects as BackupEffects)
|
||||
|
||||
@@ -176,6 +432,13 @@ export class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes rsync to synchronize files between source and destination.
|
||||
*
|
||||
* @param rsyncOptions - Configuration for the rsync operation
|
||||
* @returns Object with methods to get process ID, wait for completion, and check progress
|
||||
* @internal
|
||||
*/
|
||||
async function runRsync(rsyncOptions: {
|
||||
srcPath: string
|
||||
dstPath: string
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @module HealthCheck
|
||||
*
|
||||
* Provides the core health check management class that runs periodic health checks
|
||||
* and reports results to the StartOS host.
|
||||
*/
|
||||
import { Effects, HealthCheckId } from "../../../base/lib/types"
|
||||
import { HealthCheckResult } from "./checkFns/HealthCheckResult"
|
||||
import { Trigger } from "../trigger"
|
||||
@@ -6,24 +12,102 @@ import { defaultTrigger } from "../trigger/defaultTrigger"
|
||||
import { once, asError, Drop } from "../util"
|
||||
import { object, unknown } from "ts-matches"
|
||||
|
||||
/**
|
||||
* Configuration options for creating a health check.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const params: HealthCheckParams = {
|
||||
* id: 'main',
|
||||
* name: 'Main Service',
|
||||
* gracePeriod: 30000, // 30s grace period
|
||||
* trigger: cooldownTrigger(5000), // Check every 5s
|
||||
* fn: async () => {
|
||||
* const isHealthy = await checkService()
|
||||
* return {
|
||||
* result: isHealthy ? 'success' : 'failure',
|
||||
* message: isHealthy ? 'Service running' : 'Service not responding'
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type HealthCheckParams = {
|
||||
/** Unique identifier for this health check (e.g., 'main', 'rpc', 'database') */
|
||||
id: HealthCheckId
|
||||
/** Human-readable name displayed in the StartOS UI */
|
||||
name: string
|
||||
/**
|
||||
* Trigger controlling when the health check runs.
|
||||
* @default defaultTrigger (1s before first success, 30s after)
|
||||
*/
|
||||
trigger?: Trigger
|
||||
/**
|
||||
* Time in milliseconds during which failures are reported as "starting" instead.
|
||||
* This prevents false failure alerts during normal service startup.
|
||||
* @default 10000 (10 seconds)
|
||||
*/
|
||||
gracePeriod?: number
|
||||
/**
|
||||
* The health check function. Called periodically according to the trigger.
|
||||
* Should return (or resolve to) a HealthCheckResult with result and message.
|
||||
*/
|
||||
fn(): Promise<HealthCheckResult> | HealthCheckResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages periodic health check execution for a service.
|
||||
*
|
||||
* HealthCheck runs a check function according to a trigger schedule and reports
|
||||
* results to StartOS. It handles:
|
||||
* - Grace period logic (failures during startup report as "starting")
|
||||
* - Trigger-based scheduling (adjustable check frequency)
|
||||
* - Error handling (exceptions become failure results)
|
||||
* - Start/stop lifecycle management
|
||||
*
|
||||
* Usually created indirectly via `Daemons.addDaemon()` or `Daemons.addHealthCheck()`,
|
||||
* but can be created directly with `HealthCheck.of()` for advanced use cases.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Direct creation (advanced usage)
|
||||
* const check = HealthCheck.of(effects, {
|
||||
* id: 'database',
|
||||
* name: 'Database Connection',
|
||||
* gracePeriod: 20000,
|
||||
* trigger: cooldownTrigger(10000),
|
||||
* fn: async () => {
|
||||
* const connected = await db.ping()
|
||||
* return {
|
||||
* result: connected ? 'success' : 'failure',
|
||||
* message: connected ? 'Connected' : 'Cannot reach database'
|
||||
* }
|
||||
* }
|
||||
* })
|
||||
*
|
||||
* // Start checking (usually tied to daemon start)
|
||||
* check.start()
|
||||
*
|
||||
* // Stop checking (usually tied to daemon stop)
|
||||
* check.stop()
|
||||
* ```
|
||||
*/
|
||||
export class HealthCheck extends Drop {
|
||||
/** @internal Timestamp when the service was started (null if stopped) */
|
||||
private started: number | null = null
|
||||
/** @internal Callback to update started state and wake the check loop */
|
||||
private setStarted = (started: number | null) => {
|
||||
this.started = started
|
||||
}
|
||||
/** @internal Flag indicating the check loop should exit */
|
||||
private exited = false
|
||||
/** @internal Callback to signal the check loop to exit */
|
||||
private exit = () => {
|
||||
this.exited = true
|
||||
}
|
||||
/** @internal Current trigger input state */
|
||||
private currentValue: TriggerInput = {}
|
||||
/** @internal Promise representing the running check loop */
|
||||
private promise: Promise<void>
|
||||
private constructor(effects: Effects, o: HealthCheckParams) {
|
||||
super()
|
||||
@@ -92,22 +176,60 @@ export class HealthCheck extends Drop {
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Creates a new HealthCheck instance.
|
||||
*
|
||||
* @param effects - Effects instance for communicating with StartOS
|
||||
* @param options - Health check configuration
|
||||
* @returns A new HealthCheck instance (initially stopped)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const check = HealthCheck.of(effects, {
|
||||
* id: 'main',
|
||||
* name: 'Main',
|
||||
* fn: () => ({ result: 'success', message: 'OK' })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
static of(effects: Effects, options: HealthCheckParams): HealthCheck {
|
||||
return new HealthCheck(effects, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the health check loop.
|
||||
* The check function will begin executing according to the trigger schedule.
|
||||
* Has no effect if already started.
|
||||
*/
|
||||
start() {
|
||||
if (this.started) return
|
||||
this.setStarted(performance.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the health check loop.
|
||||
* The check function will stop executing until `start()` is called again.
|
||||
* Has no effect if already stopped.
|
||||
*/
|
||||
stop() {
|
||||
if (!this.started) return
|
||||
this.setStarted(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the HealthCheck is being disposed.
|
||||
* Signals the check loop to exit permanently.
|
||||
* @internal
|
||||
*/
|
||||
onDrop(): void {
|
||||
this.exit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts an error message from an unknown error value.
|
||||
* @internal
|
||||
*/
|
||||
function asMessage(e: unknown) {
|
||||
if (object({ message: unknown }).test(e)) return String(e.message)
|
||||
const value = String(e)
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
import { T } from "../../../../base/lib"
|
||||
|
||||
/**
|
||||
* The result returned by a health check function.
|
||||
*
|
||||
* Contains the status result and a message describing the current state.
|
||||
* The `name` field is added automatically by the health check system.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Success result
|
||||
* const healthy: HealthCheckResult = {
|
||||
* result: 'success',
|
||||
* message: 'Server responding on port 8080'
|
||||
* }
|
||||
*
|
||||
* // Failure result
|
||||
* const unhealthy: HealthCheckResult = {
|
||||
* result: 'failure',
|
||||
* message: 'Connection refused on port 8080'
|
||||
* }
|
||||
*
|
||||
* // Starting result (usually set automatically by grace period)
|
||||
* const starting: HealthCheckResult = {
|
||||
* result: 'starting',
|
||||
* message: 'Waiting for server to initialize...'
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type HealthCheckResult = Omit<T.NamedHealthCheckResult, "name">
|
||||
|
||||
@@ -6,6 +6,15 @@ import * as CP from "node:child_process"
|
||||
|
||||
const cpExec = promisify(CP.exec)
|
||||
|
||||
/**
|
||||
* Parses /proc/net/tcp* or /proc/net/udp* output to check if a port is listening.
|
||||
*
|
||||
* @param x - Raw content from /proc/net/tcp or similar file
|
||||
* @param port - Port number to look for
|
||||
* @param address - Optional specific address to match (undefined matches any)
|
||||
* @returns True if the port is found in the listening sockets
|
||||
* @internal
|
||||
*/
|
||||
export function containsAddress(x: string, port: number, address?: bigint) {
|
||||
const readPorts = x
|
||||
.split("\n")
|
||||
@@ -20,8 +29,42 @@ export function containsAddress(x: string, port: number, address?: bigint) {
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used to check if a port is listening on the system.
|
||||
* Used during the health check fn or the check main fn.
|
||||
* Checks if a specific port is listening on the local system.
|
||||
*
|
||||
* This is a low-level health check that reads from /proc/net/ to determine
|
||||
* if a service is listening on a port. It checks both TCP and UDP, on both
|
||||
* IPv4 and IPv6 interfaces.
|
||||
*
|
||||
* This is useful for services where you want to verify the server process
|
||||
* has started and is accepting connections, even if it's not yet responding
|
||||
* to application-level requests.
|
||||
*
|
||||
* @param effects - Effects instance (currently unused but included for API consistency)
|
||||
* @param port - The port number to check
|
||||
* @param options.successMessage - Message to include when the port is listening
|
||||
* @param options.errorMessage - Message to include when the port is not listening
|
||||
* @param options.timeoutMessage - Message when the check times out (default: auto-generated)
|
||||
* @param options.timeout - Maximum time to wait for the check in milliseconds (default: 1000)
|
||||
* @returns Promise resolving to a HealthCheckResult
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check if PostgreSQL is listening on port 5432
|
||||
* const check = () => checkPortListening(effects, 5432, {
|
||||
* successMessage: 'PostgreSQL is accepting connections',
|
||||
* errorMessage: 'PostgreSQL is not listening on port 5432'
|
||||
* })
|
||||
*
|
||||
* // Use in health check config
|
||||
* daemons.addHealthCheck({
|
||||
* id: 'database',
|
||||
* name: 'Database Port',
|
||||
* fn: () => checkPortListening(effects, 5432, {
|
||||
* successMessage: 'Database listening',
|
||||
* errorMessage: 'Database not responding'
|
||||
* })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export async function checkPortListening(
|
||||
effects: Effects,
|
||||
|
||||
@@ -5,10 +5,41 @@ import { timeoutPromise } from "./index"
|
||||
import "isomorphic-fetch"
|
||||
|
||||
/**
|
||||
* This is a helper function to check if a web url is reachable.
|
||||
* @param url
|
||||
* @param createSuccess
|
||||
* @returns
|
||||
* Checks if a web URL is reachable by making an HTTP request.
|
||||
*
|
||||
* This is useful for services that expose an HTTP health endpoint
|
||||
* or for checking if a web UI is responding.
|
||||
*
|
||||
* Note: This only checks if the request completes without network errors.
|
||||
* It does NOT check the HTTP status code - a 500 error response would
|
||||
* still be considered "success" since the server responded.
|
||||
*
|
||||
* @param effects - Effects instance (currently unused but included for API consistency)
|
||||
* @param url - The URL to fetch (e.g., "http://localhost:8080/health")
|
||||
* @param options.timeout - Maximum time to wait for a response in milliseconds (default: 1000)
|
||||
* @param options.successMessage - Message to include when check succeeds
|
||||
* @param options.errorMessage - Message to include when check fails
|
||||
* @returns Promise resolving to a HealthCheckResult
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage
|
||||
* const check = () => checkWebUrl(effects, 'http://localhost:8080/health')
|
||||
*
|
||||
* // With custom options
|
||||
* const check = () => checkWebUrl(effects, 'http://localhost:3000', {
|
||||
* timeout: 5000,
|
||||
* successMessage: 'Web UI is responding',
|
||||
* errorMessage: 'Cannot reach web UI'
|
||||
* })
|
||||
*
|
||||
* // Use in health check config
|
||||
* daemons.addHealthCheck({
|
||||
* id: 'web',
|
||||
* name: 'Web Interface',
|
||||
* fn: () => checkWebUrl(effects, 'http://localhost:8080')
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const checkWebUrl = async (
|
||||
effects: Effects,
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
/**
|
||||
* @module checkFns
|
||||
*
|
||||
* Provides pre-built health check functions for common use cases.
|
||||
* These can be used directly in health check configurations or as building blocks
|
||||
* for custom health checks.
|
||||
*
|
||||
* Available functions:
|
||||
* - `checkPortListening` - Check if a port is open and listening
|
||||
* - `checkWebUrl` - Check if a web URL is reachable
|
||||
* - `runHealthScript` - Run a shell script and check its exit code
|
||||
* - `timeoutPromise` - Helper to add timeouts to async operations
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { checkPortListening, checkWebUrl } from '@start9labs/sdk'
|
||||
*
|
||||
* // Check if port 8080 is listening
|
||||
* const portCheck = () => checkPortListening(effects, 8080, {
|
||||
* successMessage: 'Server is listening',
|
||||
* errorMessage: 'Server not responding'
|
||||
* })
|
||||
*
|
||||
* // Check if web UI is reachable
|
||||
* const webCheck = () => checkWebUrl(effects, 'http://localhost:8080/health', {
|
||||
* timeout: 5000,
|
||||
* successMessage: 'Web UI is up'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
import { runHealthScript } from "./runHealthScript"
|
||||
export { checkPortListening } from "./checkPortListening"
|
||||
export { HealthCheckResult } from "./HealthCheckResult"
|
||||
export { checkWebUrl } from "./checkWebUrl"
|
||||
|
||||
/**
|
||||
* Creates a promise that rejects after the specified timeout.
|
||||
* Useful for adding timeouts to async operations using `Promise.race()`.
|
||||
*
|
||||
* @param ms - Timeout duration in milliseconds
|
||||
* @param options.message - Error message when timeout occurs
|
||||
* @returns A promise that rejects after `ms` milliseconds
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Add a 5-second timeout to an async operation
|
||||
* const result = await Promise.race([
|
||||
* someAsyncOperation(),
|
||||
* timeoutPromise(5000, { message: 'Operation timed out' })
|
||||
* ])
|
||||
* ```
|
||||
*/
|
||||
export function timeoutPromise(ms: number, { message = "Timed out" } = {}) {
|
||||
return new Promise<never>((resolve, reject) =>
|
||||
setTimeout(() => reject(new Error(message)), ms),
|
||||
|
||||
@@ -4,11 +4,47 @@ import { SubContainer } from "../../util/SubContainer"
|
||||
import { SDKManifest } from "../../types"
|
||||
|
||||
/**
|
||||
* Running a health script, is used when we want to have a simple
|
||||
* script in bash or something like that. It should return something that is useful
|
||||
* in {result: string} else it is considered an error
|
||||
* @param param0
|
||||
* @returns
|
||||
* Runs a command inside a subcontainer and uses the exit code for health status.
|
||||
*
|
||||
* This is useful when the service provides a CLI health check command or when
|
||||
* you want to run a custom bash script to determine health status. The command
|
||||
* must exit with code 0 for success; any other exit code is treated as failure.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
* @param runCommand - Command and arguments to execute (e.g., ['pg_isready', '-U', 'postgres'])
|
||||
* @param subcontainer - The SubContainer to run the command in
|
||||
* @param options.timeout - Maximum time to wait for the command in milliseconds (default: 30000)
|
||||
* @param options.errorMessage - Message to include when the command fails
|
||||
* @param options.message - Function to generate success message from stdout
|
||||
* @returns Promise resolving to a HealthCheckResult
|
||||
* @throws HealthCheckResult with result: "failure" if the command fails or times out
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check PostgreSQL readiness using pg_isready
|
||||
* const check = () => runHealthScript(
|
||||
* ['pg_isready', '-U', 'postgres'],
|
||||
* subcontainer,
|
||||
* {
|
||||
* timeout: 5000,
|
||||
* errorMessage: 'PostgreSQL is not ready'
|
||||
* }
|
||||
* )
|
||||
*
|
||||
* // Custom bash health check
|
||||
* const check = () => runHealthScript(
|
||||
* ['bash', '-c', 'curl -sf http://localhost:8080/health || exit 1'],
|
||||
* subcontainer,
|
||||
* { errorMessage: 'Health endpoint check failed' }
|
||||
* )
|
||||
*
|
||||
* // Use in health check config
|
||||
* daemons.addHealthCheck({
|
||||
* id: 'cli',
|
||||
* name: 'CLI Health Check',
|
||||
* fn: () => runHealthScript(['myapp', 'healthcheck'], subcontainer)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const runHealthScript = async <Manifest extends SDKManifest>(
|
||||
runCommand: string[],
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
/**
|
||||
* @module Daemons
|
||||
*
|
||||
* This module provides the Daemons class for managing service processes (daemons)
|
||||
* and their health checks. Daemons are long-running processes that make up the
|
||||
* core functionality of a StartOS service.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic daemon setup
|
||||
* export const main = sdk.setupMain(async ({ effects }) => {
|
||||
* const container = await sdk.SubContainer.of(effects, { imageId: 'main' }, mounts, 'main')
|
||||
*
|
||||
* return sdk.Daemons.of(effects)
|
||||
* .addDaemon('primary', {
|
||||
* subcontainer: container,
|
||||
* exec: { command: ['my-server', '--config', '/data/config.json'] },
|
||||
* ready: {
|
||||
* display: 'Server',
|
||||
* fn: () => sdk.healthCheck.checkPortListening(effects, 8080, {
|
||||
* successMessage: 'Server is ready',
|
||||
* errorMessage: 'Server is not responding',
|
||||
* }),
|
||||
* gracePeriod: 30000, // 30 second startup grace period
|
||||
* },
|
||||
* requires: [],
|
||||
* })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Signals } from "../../../base/lib/types"
|
||||
|
||||
import { HealthCheckResult } from "../health/checkFns"
|
||||
@@ -16,48 +47,124 @@ import { Daemon } from "./Daemon"
|
||||
import { CommandController } from "./CommandController"
|
||||
import { Oneshot } from "./Oneshot"
|
||||
|
||||
/** @internal Promisified child process exec */
|
||||
export const cpExec = promisify(CP.exec)
|
||||
/** @internal Promisified child process execFile */
|
||||
export const cpExecFile = promisify(CP.execFile)
|
||||
|
||||
/**
|
||||
* Configuration for a daemon's readiness/health check.
|
||||
*
|
||||
* Health checks determine when a daemon is considered "ready" and report
|
||||
* status to the StartOS UI. They run periodically and can be customized
|
||||
* with grace periods and triggers.
|
||||
*/
|
||||
export type Ready = {
|
||||
/** A human-readable display name for the health check. If null, the health check itself will be from the UI */
|
||||
display: string | null
|
||||
/**
|
||||
* @description The function to determine the health status of the daemon
|
||||
*
|
||||
* The SDK provides some built-in health checks. To see them, type sdk.healthCheck.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
fn: () =>
|
||||
sdk.healthCheck.checkPortListening(effects, 80, {
|
||||
successMessage: 'service listening on port 80',
|
||||
errorMessage: 'service is unreachable',
|
||||
})
|
||||
* ```
|
||||
*/
|
||||
fn: () => Promise<HealthCheckResult> | HealthCheckResult
|
||||
/**
|
||||
* A duration in milliseconds to treat a failing health check as "starting"
|
||||
* Human-readable display name for the health check shown in the UI.
|
||||
* If null, the health check will not be visible in the UI.
|
||||
*
|
||||
* defaults to 5000
|
||||
* @example "Web Interface"
|
||||
* @example "Database Connection"
|
||||
*/
|
||||
display: string | null
|
||||
|
||||
/**
|
||||
* Function that determines the health status of the daemon.
|
||||
*
|
||||
* The SDK provides built-in health checks:
|
||||
* - `sdk.healthCheck.checkPortListening()` - Check if a port is listening
|
||||
* - `sdk.healthCheck.checkWebUrl()` - Check if an HTTP endpoint responds
|
||||
* - `sdk.healthCheck.runHealthScript()` - Run a custom health check script
|
||||
*
|
||||
* @returns HealthCheckResult with status ("success", "failure", or "starting") and message
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* fn: () => sdk.healthCheck.checkPortListening(effects, 8080, {
|
||||
* successMessage: 'Server is ready',
|
||||
* errorMessage: 'Server is not responding',
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Custom health check
|
||||
* fn: async () => {
|
||||
* const result = await container.exec(['my-health-check'])
|
||||
* return result.exitCode === 0
|
||||
* ? { result: 'success', message: 'Healthy' }
|
||||
* : { result: 'failure', message: 'Unhealthy' }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
fn: () => Promise<HealthCheckResult> | HealthCheckResult
|
||||
|
||||
/**
|
||||
* Duration in milliseconds to treat a failing health check as "starting" instead of "failure".
|
||||
*
|
||||
* This gives the daemon time to initialize before health check failures are reported.
|
||||
* After the grace period expires, failures will be reported normally.
|
||||
*
|
||||
* @default 5000 (5 seconds)
|
||||
*
|
||||
* @example 30000 // 30 second startup time
|
||||
* @example 120000 // 2 minutes for slow-starting services
|
||||
*/
|
||||
gracePeriod?: number
|
||||
|
||||
/**
|
||||
* Optional trigger configuration for when to run the health check.
|
||||
* If not specified, uses the default trigger (periodic checks).
|
||||
*
|
||||
* @see defaultTrigger, cooldownTrigger, changeOnFirstSuccess, successFailure
|
||||
*/
|
||||
trigger?: Trigger
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for executing a command as a daemon.
|
||||
*/
|
||||
export type ExecCommandOptions = {
|
||||
/** The command to execute (string, array, or UseEntrypoint) */
|
||||
command: T.CommandType
|
||||
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
|
||||
|
||||
/**
|
||||
* Timeout in milliseconds to wait for graceful shutdown after sending SIGTERM.
|
||||
* After this timeout, SIGKILL will be sent.
|
||||
* @default 30000 (30 seconds)
|
||||
*/
|
||||
sigtermTimeout?: number
|
||||
|
||||
/**
|
||||
* If true, run the command as PID 1 (init process).
|
||||
* This affects signal handling and zombie process reaping.
|
||||
*/
|
||||
runAsInit?: boolean
|
||||
|
||||
/** Environment variables to set for the process */
|
||||
env?:
|
||||
| {
|
||||
[variable in string]?: string
|
||||
}
|
||||
| undefined
|
||||
|
||||
/** Working directory for the process */
|
||||
cwd?: string | undefined
|
||||
|
||||
/** User to run the process as (e.g., "root", "nobody") */
|
||||
user?: string | undefined
|
||||
|
||||
/**
|
||||
* Callback invoked for each chunk of stdout output.
|
||||
* Useful for logging or monitoring process output.
|
||||
*/
|
||||
onStdout?: (chunk: Buffer | string | any) => void
|
||||
|
||||
/**
|
||||
* Callback invoked for each chunk of stderr output.
|
||||
* Useful for logging or monitoring process errors.
|
||||
*/
|
||||
onStderr?: (chunk: Buffer | string | any) => void
|
||||
}
|
||||
|
||||
@@ -127,31 +234,61 @@ type AddHealthCheckParams<Ids extends string, Id extends string> = {
|
||||
|
||||
type ErrorDuplicateId<Id extends string> = `The id '${Id}' is already used`
|
||||
|
||||
/** @internal Helper to create a CommandController */
|
||||
export const runCommand = <Manifest extends T.SDKManifest>() =>
|
||||
CommandController.of<Manifest, SubContainer<Manifest>>()
|
||||
|
||||
/**
|
||||
* A class for defining and controlling the service daemons
|
||||
```ts
|
||||
Daemons.of({
|
||||
effects,
|
||||
started,
|
||||
interfaceReceipt, // Provide the interfaceReceipt to prove it was completed
|
||||
healthReceipts, // Provide the healthReceipts or [] to prove they were at least considered
|
||||
}).addDaemon('webui', {
|
||||
command: 'hello-world', // The command to start the daemon
|
||||
ready: {
|
||||
display: 'Web Interface',
|
||||
// The function to run to determine the health status of the daemon
|
||||
fn: () =>
|
||||
checkPortListening(effects, 80, {
|
||||
successMessage: 'The web interface is ready',
|
||||
errorMessage: 'The web interface is not ready',
|
||||
}),
|
||||
},
|
||||
requires: [],
|
||||
})
|
||||
```
|
||||
* Manager class for defining and controlling service daemons.
|
||||
*
|
||||
* Exposed via `sdk.Daemons`. Daemons are long-running processes that make up
|
||||
* your service. The Daemons class provides a fluent API for:
|
||||
* - Defining multiple daemons with dependencies between them
|
||||
* - Configuring health checks for each daemon
|
||||
* - Managing startup order based on dependency requirements
|
||||
* - Handling graceful shutdown in reverse dependency order
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type
|
||||
* @typeParam Ids - Union type of all daemon IDs (accumulates as daemons are added)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Single daemon service
|
||||
* return sdk.Daemons.of(effects)
|
||||
* .addDaemon('primary', {
|
||||
* subcontainer,
|
||||
* exec: { command: sdk.useEntrypoint() },
|
||||
* ready: {
|
||||
* display: 'Server',
|
||||
* fn: () => sdk.healthCheck.checkPortListening(effects, 8080, { ... }),
|
||||
* },
|
||||
* requires: [],
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Multi-daemon service with dependencies
|
||||
* return sdk.Daemons.of(effects)
|
||||
* .addDaemon('database', {
|
||||
* subcontainer: dbContainer,
|
||||
* exec: { command: ['postgres', '-D', '/data'] },
|
||||
* ready: { display: 'Database', fn: checkDbReady },
|
||||
* requires: [], // No dependencies
|
||||
* })
|
||||
* .addDaemon('api', {
|
||||
* subcontainer: apiContainer,
|
||||
* exec: { command: ['node', 'server.js'] },
|
||||
* ready: { display: 'API Server', fn: checkApiReady },
|
||||
* requires: ['database'], // Waits for database to be ready
|
||||
* })
|
||||
* .addDaemon('worker', {
|
||||
* subcontainer: workerContainer,
|
||||
* exec: { command: ['node', 'worker.js'] },
|
||||
* ready: { display: 'Background Worker', fn: checkWorkerReady },
|
||||
* requires: ['database', 'api'], // Waits for both
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
|
||||
implements T.DaemonBuildable
|
||||
|
||||
@@ -1,18 +1,65 @@
|
||||
/**
|
||||
* @module Mounts
|
||||
*
|
||||
* This module provides a fluent API for configuring volume mounts for SubContainers.
|
||||
* The Mounts class uses a builder pattern to accumulate mount configurations that
|
||||
* are then applied when a container starts.
|
||||
*
|
||||
* Mount types supported:
|
||||
* - **Volumes** - Service-owned data directories defined in the manifest
|
||||
* - **Assets** - Static files bundled with the service package
|
||||
* - **Dependencies** - Volumes from other services this service depends on
|
||||
* - **Backups** - Special mount for backup operations
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const mounts = Mounts.of<Manifest>()
|
||||
* .mountVolume({
|
||||
* volumeId: 'main',
|
||||
* mountpoint: '/data',
|
||||
* readonly: false,
|
||||
* subpath: null
|
||||
* })
|
||||
* .mountAssets({
|
||||
* mountpoint: '/config',
|
||||
* subpath: 'default-config'
|
||||
* })
|
||||
* .mountDependency({
|
||||
* dependencyId: 'bitcoind',
|
||||
* volumeId: 'data',
|
||||
* mountpoint: '/bitcoin',
|
||||
* readonly: true,
|
||||
* subpath: null
|
||||
* })
|
||||
* .build()
|
||||
* ```
|
||||
*/
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { IdMap, MountOptions } from "../util/SubContainer"
|
||||
|
||||
/**
|
||||
* Array of mount configurations ready to be applied to a container.
|
||||
* Each entry maps a mountpoint path to its mount options.
|
||||
*/
|
||||
type MountArray = { mountpoint: string; options: MountOptions }[]
|
||||
|
||||
/**
|
||||
* Common options shared across all mount types.
|
||||
* These options control where and how a resource is mounted into a container.
|
||||
*/
|
||||
type SharedOptions = {
|
||||
/** The path within the resource to mount. Use `null` to mount the entire resource */
|
||||
subpath: string | null
|
||||
/** Where to mount the resource. e.g. /data */
|
||||
/** The absolute path inside the container where the resource will be accessible (e.g., "/data") */
|
||||
mountpoint: string
|
||||
/**
|
||||
* Whether to mount this as a file or directory
|
||||
* Whether to mount this as a file or directory.
|
||||
* - `"file"` - Mount a single file
|
||||
* - `"directory"` - Mount a directory (default)
|
||||
* - `"infer"` - Automatically detect based on the source
|
||||
*
|
||||
* defaults to "directory"
|
||||
* */
|
||||
* @default "directory"
|
||||
*/
|
||||
type?: "file" | "directory" | "infer"
|
||||
// /**
|
||||
// * Whether to map uids/gids for the mount
|
||||
@@ -33,37 +80,142 @@ type SharedOptions = {
|
||||
// }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for mounting one of the service's own volumes.
|
||||
* Volumes are persistent storage areas defined in the service manifest.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type, used for type-safe volume ID validation
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* {
|
||||
* volumeId: 'main', // Must match a volume defined in manifest
|
||||
* mountpoint: '/data', // Where it appears in the container
|
||||
* readonly: false, // Allow writes
|
||||
* subpath: null // Mount the entire volume
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
type VolumeOpts<Manifest extends T.SDKManifest> = {
|
||||
/** The ID of the volume to mount. Must be one of the volume IDs defined in the manifest */
|
||||
volumeId: Manifest["volumes"][number]
|
||||
/** Whether or not the resource should be readonly for this subcontainer */
|
||||
/** If true, the volume will be mounted read-only (writes will fail) */
|
||||
readonly: boolean
|
||||
} & SharedOptions
|
||||
|
||||
/**
|
||||
* Options for mounting a volume from a dependency service.
|
||||
* This allows accessing data from services that this service depends on.
|
||||
*
|
||||
* @typeParam Manifest - The dependency's manifest type, used for type-safe volume ID validation
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* {
|
||||
* dependencyId: 'bitcoind', // The dependency's package ID
|
||||
* volumeId: 'data', // A volume from the dependency's manifest
|
||||
* mountpoint: '/bitcoin-data', // Where it appears in this container
|
||||
* readonly: true, // Usually read-only for safety
|
||||
* subpath: 'blocks' // Optionally mount only a subdirectory
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
type DependencyOpts<Manifest extends T.SDKManifest> = {
|
||||
/** The ID of the dependency */
|
||||
/** The package ID of the dependency service */
|
||||
dependencyId: Manifest["id"]
|
||||
/** The ID of the volume to mount. Must be one of the volume IDs defined in the manifest of the dependency */
|
||||
/** The ID of the volume to mount from the dependency. Must be defined in the dependency's manifest */
|
||||
volumeId: Manifest["volumes"][number]
|
||||
/** Whether or not the resource should be readonly for this subcontainer */
|
||||
/** If true, the volume will be mounted read-only (writes will fail) */
|
||||
readonly: boolean
|
||||
} & SharedOptions
|
||||
|
||||
/**
|
||||
* Fluent builder for configuring container volume mounts.
|
||||
*
|
||||
* Exposed via `sdk.Mounts`. The Mounts class uses an immutable builder pattern -
|
||||
* each method returns a new Mounts instance with the additional configuration,
|
||||
* leaving the original unchanged. Call `build()` at the end to get the final mount array.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type for volume ID validation
|
||||
* @typeParam Backups - Type tracking whether backup mounts have been added
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage with a single volume
|
||||
* const mounts = Mounts.of<Manifest>()
|
||||
* .mountVolume({
|
||||
* volumeId: 'main',
|
||||
* mountpoint: '/data',
|
||||
* readonly: false,
|
||||
* subpath: null
|
||||
* })
|
||||
* .build()
|
||||
*
|
||||
* // Complex setup with multiple mount types
|
||||
* const mounts = Mounts.of<Manifest>()
|
||||
* .mountVolume({ volumeId: 'main', mountpoint: '/data', readonly: false, subpath: null })
|
||||
* .mountVolume({ volumeId: 'logs', mountpoint: '/var/log/app', readonly: false, subpath: null })
|
||||
* .mountAssets({ mountpoint: '/etc/app', subpath: 'config' })
|
||||
* .mountDependency<BitcoinManifest>({
|
||||
* dependencyId: 'bitcoind',
|
||||
* volumeId: 'data',
|
||||
* mountpoint: '/bitcoin',
|
||||
* readonly: true,
|
||||
* subpath: null
|
||||
* })
|
||||
* .build()
|
||||
* ```
|
||||
*/
|
||||
export class Mounts<
|
||||
Manifest extends T.SDKManifest,
|
||||
Backups extends SharedOptions = never,
|
||||
> {
|
||||
private constructor(
|
||||
/** @internal Accumulated volume mount configurations */
|
||||
readonly volumes: VolumeOpts<Manifest>[],
|
||||
/** @internal Accumulated asset mount configurations */
|
||||
readonly assets: SharedOptions[],
|
||||
/** @internal Accumulated dependency mount configurations */
|
||||
readonly dependencies: DependencyOpts<T.SDKManifest>[],
|
||||
/** @internal Accumulated backup mount configurations */
|
||||
readonly backups: Backups[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new empty Mounts builder.
|
||||
* This is the starting point for building mount configurations.
|
||||
*
|
||||
* @typeParam Manifest - The service manifest type for volume ID validation
|
||||
* @returns A new empty Mounts builder instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const mounts = Mounts.of<MyManifest>()
|
||||
* .mountVolume({ ... })
|
||||
* .build()
|
||||
* ```
|
||||
*/
|
||||
static of<Manifest extends T.SDKManifest>() {
|
||||
return new Mounts<Manifest>([], [], [], [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a volume mount to the configuration.
|
||||
* Volumes are persistent storage areas owned by this service.
|
||||
*
|
||||
* @param options - Volume mount configuration
|
||||
* @returns A new Mounts instance with the volume added
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* mounts.mountVolume({
|
||||
* volumeId: 'main', // Must exist in manifest.volumes
|
||||
* mountpoint: '/data', // Container path
|
||||
* readonly: false, // Allow writes
|
||||
* subpath: null // Mount entire volume
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
mountVolume(options: VolumeOpts<Manifest>) {
|
||||
return new Mounts<Manifest, Backups>(
|
||||
[...this.volumes, options],
|
||||
@@ -73,6 +225,21 @@ export class Mounts<
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an assets mount to the configuration.
|
||||
* Assets are static files bundled with the service package (read-only).
|
||||
*
|
||||
* @param options - Asset mount configuration
|
||||
* @returns A new Mounts instance with the asset mount added
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* mounts.mountAssets({
|
||||
* mountpoint: '/etc/myapp', // Where to mount in container
|
||||
* subpath: 'default-config' // Subdirectory within assets
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
mountAssets(options: SharedOptions) {
|
||||
return new Mounts<Manifest, Backups>(
|
||||
[...this.volumes],
|
||||
@@ -82,6 +249,27 @@ export class Mounts<
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a dependency volume mount to the configuration.
|
||||
* This mounts a volume from another service that this service depends on.
|
||||
*
|
||||
* @typeParam DependencyManifest - The manifest type of the dependency service
|
||||
* @param options - Dependency mount configuration
|
||||
* @returns A new Mounts instance with the dependency mount added
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { manifest as bitcoinManifest } from 'bitcoind-startos'
|
||||
*
|
||||
* mounts.mountDependency<typeof bitcoinManifest>({
|
||||
* dependencyId: 'bitcoind',
|
||||
* volumeId: 'data',
|
||||
* mountpoint: '/bitcoin',
|
||||
* readonly: true, // Usually read-only for safety
|
||||
* subpath: null
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
mountDependency<DependencyManifest extends T.SDKManifest>(
|
||||
options: DependencyOpts<DependencyManifest>,
|
||||
) {
|
||||
@@ -93,6 +281,21 @@ export class Mounts<
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a backup mount to the configuration.
|
||||
* This is used during backup operations to provide access to the backup destination.
|
||||
*
|
||||
* @param options - Backup mount configuration
|
||||
* @returns A new Mounts instance with the backup mount added
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* mounts.mountBackups({
|
||||
* mountpoint: '/backup',
|
||||
* subpath: null
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
mountBackups(options: SharedOptions) {
|
||||
return new Mounts<
|
||||
Manifest,
|
||||
@@ -108,6 +311,24 @@ export class Mounts<
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizes the mount configuration and returns the mount array.
|
||||
* Validates that no two mounts use the same mountpoint.
|
||||
*
|
||||
* @returns Array of mount configurations ready to apply to a container
|
||||
* @throws Error if the same mountpoint is used more than once
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const mountArray = Mounts.of<Manifest>()
|
||||
* .mountVolume({ volumeId: 'main', mountpoint: '/data', readonly: false, subpath: null })
|
||||
* .mountAssets({ mountpoint: '/config', subpath: null })
|
||||
* .build()
|
||||
*
|
||||
* // Use with SubContainer
|
||||
* subcontainer.exec({ command: 'myapp', mounts: mountArray })
|
||||
* ```
|
||||
*/
|
||||
build(): MountArray {
|
||||
const mountpoints = new Set()
|
||||
for (let mountpoint of this.volumes
|
||||
|
||||
@@ -42,11 +42,11 @@ export function buildManifest<
|
||||
): Manifest & T.Manifest {
|
||||
const images = Object.entries(manifest.images).reduce(
|
||||
(images, [k, v]) => {
|
||||
v.arch = v.arch || ["aarch64", "x86_64"]
|
||||
v.arch = v.arch ?? ["aarch64", "x86_64", "riscv64"]
|
||||
if (v.emulateMissingAs === undefined)
|
||||
v.emulateMissingAs = (v.arch as string[]).includes("aarch64")
|
||||
? "aarch64"
|
||||
: v.arch[0] || null
|
||||
v.emulateMissingAs = (v.arch as string[]).includes("x86_64")
|
||||
? "x86_64"
|
||||
: (v.arch[0] ?? null)
|
||||
v.nvidiaContainer = !!v.nvidiaContainer
|
||||
images[k] = v as ImageConfig
|
||||
return images
|
||||
@@ -75,21 +75,18 @@ export function buildManifest<
|
||||
hardwareRequirements: {
|
||||
device: manifest.hardwareRequirements?.device || [],
|
||||
ram: manifest.hardwareRequirements?.ram || null,
|
||||
arch:
|
||||
manifest.hardwareRequirements?.arch === undefined
|
||||
? Object.values(images).reduce(
|
||||
(arch, inputSpec) => {
|
||||
if (inputSpec.emulateMissingAs) {
|
||||
return arch
|
||||
}
|
||||
if (arch === null) {
|
||||
return inputSpec.arch
|
||||
}
|
||||
return arch.filter((a) => inputSpec.arch.includes(a))
|
||||
},
|
||||
null as string[] | null,
|
||||
)
|
||||
: manifest.hardwareRequirements?.arch,
|
||||
arch: Object.values(images).reduce(
|
||||
(arch, inputSpec) => {
|
||||
if (inputSpec.emulateMissingAs) {
|
||||
return arch
|
||||
}
|
||||
if (arch === null) {
|
||||
return inputSpec.arch
|
||||
}
|
||||
return arch.filter((a) => inputSpec.arch.includes(a))
|
||||
},
|
||||
null as string[] | null,
|
||||
),
|
||||
},
|
||||
hardwareAcceleration: manifest.hardwareAcceleration ?? false,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import { HealthStatus } from "../../../base/lib/types"
|
||||
|
||||
/**
|
||||
* Input state provided to trigger functions.
|
||||
* Contains information about the health check's current state
|
||||
* that triggers can use to adjust their timing behavior.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const myTrigger: Trigger = (getInput) => {
|
||||
* return (async function* () {
|
||||
* while (true) {
|
||||
* const input: TriggerInput = getInput()
|
||||
* // Check more frequently if last result was failure
|
||||
* const delay = input.lastResult === 'failure' ? 1000 : 30000
|
||||
* await new Promise(r => setTimeout(r, delay))
|
||||
* yield
|
||||
* }
|
||||
* })()
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type TriggerInput = {
|
||||
/** The result of the most recent health check execution, if any */
|
||||
lastResult?: HealthStatus
|
||||
}
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
import { Trigger } from "./index"
|
||||
|
||||
/**
|
||||
* Creates a trigger that uses different timing before and after the first successful health check.
|
||||
*
|
||||
* This is useful for services that need frequent checks during startup (to quickly report
|
||||
* when they become healthy) but can reduce check frequency once they're running stably.
|
||||
*
|
||||
* The trigger switches permanently to `afterFirstSuccess` timing once a success is seen.
|
||||
* It does NOT switch back even if the service later becomes unhealthy.
|
||||
*
|
||||
* @param o.beforeFirstSuccess - Trigger to use until the first successful health check
|
||||
* @param o.afterFirstSuccess - Trigger to use after the first successful health check
|
||||
* @returns A composite trigger that switches behavior after first success
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check every second while starting, every 30 seconds once healthy
|
||||
* const trigger = changeOnFirstSuccess({
|
||||
* beforeFirstSuccess: cooldownTrigger(1000), // 1s while starting
|
||||
* afterFirstSuccess: cooldownTrigger(30000) // 30s after healthy
|
||||
* })
|
||||
*
|
||||
* // Use in a health check
|
||||
* daemons.addHealthCheck({
|
||||
* id: 'main',
|
||||
* name: 'Main Health',
|
||||
* trigger,
|
||||
* fn: checkServiceHealth
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function changeOnFirstSuccess(o: {
|
||||
beforeFirstSuccess: Trigger
|
||||
afterFirstSuccess: Trigger
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
/**
|
||||
* Creates a simple timer-based trigger that fires at regular intervals.
|
||||
* This is the most basic trigger type - it just waits the specified
|
||||
* time between each health check.
|
||||
*
|
||||
* @param timeMs - Interval between health checks in milliseconds
|
||||
* @returns A trigger factory function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check health every 5 seconds
|
||||
* const trigger = cooldownTrigger(5000)
|
||||
*
|
||||
* // Use in a health check
|
||||
* daemons.addHealthCheck({
|
||||
* id: 'main',
|
||||
* name: 'Main Check',
|
||||
* trigger: cooldownTrigger(10000), // Every 10 seconds
|
||||
* fn: async () => ({ result: 'success', message: 'OK' })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function cooldownTrigger(timeMs: number) {
|
||||
return async function* () {
|
||||
while (true) {
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import { cooldownTrigger } from "./cooldownTrigger"
|
||||
import { changeOnFirstSuccess } from "./changeOnFirstSuccess"
|
||||
|
||||
/**
|
||||
* The default trigger used when no custom trigger is specified for a health check.
|
||||
*
|
||||
* Provides sensible defaults for most services:
|
||||
* - **Before first success**: Checks every 1 second (rapid during startup)
|
||||
* - **After first success**: Checks every 30 seconds (stable once healthy)
|
||||
*
|
||||
* This trigger is automatically used by `Daemons.addDaemon()` and `Daemons.addHealthCheck()`
|
||||
* when no `trigger` option is provided.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // These are equivalent - both use defaultTrigger
|
||||
* daemons.addHealthCheck({
|
||||
* id: 'main',
|
||||
* name: 'Main',
|
||||
* fn: checkHealth
|
||||
* // trigger: defaultTrigger // implicit
|
||||
* })
|
||||
*
|
||||
* // Custom trigger overrides the default
|
||||
* daemons.addHealthCheck({
|
||||
* id: 'main',
|
||||
* name: 'Main',
|
||||
* trigger: cooldownTrigger(5000), // Check every 5s instead
|
||||
* fn: checkHealth
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const defaultTrigger = changeOnFirstSuccess({
|
||||
beforeFirstSuccess: cooldownTrigger(1000),
|
||||
afterFirstSuccess: cooldownTrigger(30000),
|
||||
|
||||
@@ -1,7 +1,57 @@
|
||||
/**
|
||||
* @module trigger
|
||||
*
|
||||
* Triggers control when health checks are executed. They are async generators
|
||||
* that yield when a health check should run. This allows fine-grained control
|
||||
* over check frequency based on the service's current state.
|
||||
*
|
||||
* Built-in triggers:
|
||||
* - `cooldownTrigger(ms)` - Simple timer-based trigger
|
||||
* - `changeOnFirstSuccess` - Different timing before/after first successful check
|
||||
* - `successFailure` - Different timing based on success/failure state
|
||||
* - `lastStatus` - Configurable timing per health status
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Check every 5 seconds
|
||||
* const trigger = cooldownTrigger(5000)
|
||||
*
|
||||
* // Fast checks until healthy, then slow down
|
||||
* const adaptiveTrigger = changeOnFirstSuccess({
|
||||
* beforeFirstSuccess: cooldownTrigger(1000), // 1s while starting
|
||||
* afterFirstSuccess: cooldownTrigger(30000) // 30s once healthy
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
import { TriggerInput } from "./TriggerInput"
|
||||
export { changeOnFirstSuccess } from "./changeOnFirstSuccess"
|
||||
export { cooldownTrigger } from "./cooldownTrigger"
|
||||
|
||||
/**
|
||||
* A trigger function that controls when health checks execute.
|
||||
*
|
||||
* Triggers are async generator factories. Given a function to get the current
|
||||
* input state (e.g., last health result), they return an async iterator that
|
||||
* yields when a health check should run.
|
||||
*
|
||||
* @param getInput - Function returning the current trigger input state
|
||||
* @returns An async iterator that yields when a check should run
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Custom trigger that checks every 10s during success, 2s during failure
|
||||
* const myTrigger: Trigger = (getInput) => {
|
||||
* return (async function* () {
|
||||
* while (true) {
|
||||
* const { lastResult } = getInput()
|
||||
* const delay = lastResult === 'success' ? 10000 : 2000
|
||||
* await new Promise(r => setTimeout(r, delay))
|
||||
* yield
|
||||
* }
|
||||
* })()
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type Trigger = (
|
||||
getInput: () => TriggerInput,
|
||||
) => AsyncIterator<unknown, unknown, never>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user