Compare commits

...

54 commits

Author SHA1 Message Date
Nico
7c1f0c52d5
refactor(repo-mutex): use effect.ts (#950)
Some checks failed
Checks / lint (push) Has been cancelled
Checks / typecheck (push) Has been cancelled
Checks / test-server (push) Has been cancelled
Checks / test-client (push) Has been cancelled
Checks / build (push) Has been cancelled
Scorecard analysis / Scorecard analysis (push) Has been cancelled
* refactor(repo-mutex): use effect.ts

* refactor(mutext): run with signal to preserve abort reason
2026-06-06 15:06:26 +02:00
Nico
2318b6bdd0
fix: limit concurrent ls to 2 in flight calls (#948)
* fix: limit concurrent ls to 2 in flight calls

* refactor: get shared lock after semaphore take
2026-06-05 18:07:59 +02:00
Copilot
34fc4f0cbb
docs: update Docker Compose Zerobyte image tags to v0.38 (#945)
* Initial plan

* docs: update Docker Compose Zerobyte image tags to v0.38

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-04 21:49:15 +02:00
Nico
68002b0308
fix: disable add passkey in insecure contexts (#943)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / integration-tests (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-06-04 21:06:48 +02:00
Nicolas Meienberger
4bba2c2493
refactor: remove repository lock diagnostics logs 2026-06-04 19:58:02 +02:00
Nicolas Meienberger
9d63f7cb2d
Revert "fix(volumes): decrypt values before testing connection (#939)"
Some checks failed
Release Workflow / integration-tests (push) Has been cancelled
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
This reverts commit 885ea10f2a.
2026-06-03 19:56:55 +02:00
Nicolas Meienberger
4a4e5c0abe
ci: skip nfs container entirely in github ci 2026-06-03 19:26:45 +02:00
Nico
885ea10f2a
fix(volumes): decrypt values before testing connection (#939)
* fix(volumes): decrypt values before testing connection

* chore: lint issue
2026-06-03 19:05:39 +02:00
Nicolas Meienberger
edab1b231b
fix(restic): validate lock diagnostics object IDs 2026-06-03 18:47:04 +02:00
Nico
333c11986d
feat: enforce protocol version between agent and controller (#938)
* feat: enforce protocol version between agent and controller

* chore: add logging for protocol rejected message
2026-06-03 18:29:36 +02:00
Nicolas Meienberger
be3182793d
fix(agent-backups): validate agent ownership 2026-06-03 17:28:43 +02:00
Nicolas Meienberger
a279129ad8
ci(integration): include only necessary logs in artifacts 2026-06-03 17:21:32 +02:00
Nicolas Meienberger
027c6efb32
ci(docs): allow deploy only on repo default branch 2026-06-03 17:18:10 +02:00
renovate[bot]
755cbe4dae
fix(deps): update bun minor and patch dependencies (#925)
* fix(deps): update bun minor and patch dependencies

* chore: fix linting issues

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-06-03 17:15:53 +02:00
Nicolas Meienberger
111a5843ef
refactor: reject all non uv passkeys 2026-06-03 17:03:00 +02:00
renovate[bot]
49e3977199
fix(deps): update dependency fumadocs-mdx to v15 (#870)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-03 16:59:25 +02:00
Nicolas Meienberger
ca325a01c5
chore: re-generate openapi client 2026-06-02 21:04:04 +02:00
renovate[bot]
25f60db703
chore(deps): update docker/build-push-action digest to f9f3042 (#910)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:56:25 +02:00
renovate[bot]
478a5fcba3
chore(deps): update docker/login-action digest to 650006c (#913)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:56:03 +02:00
renovate[bot]
bf69fc5f65
chore(deps): update docker/metadata-action digest to 80c7e94 (#915)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:55:42 +02:00
renovate[bot]
0fd88b2cdf
chore(deps): update docker/setup-buildx-action digest to d7f5e7f (#916)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:55:18 +02:00
renovate[bot]
8302893233
fix(deps): update dependency commander to v15 (#932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:47:47 +02:00
Nicolas Meienberger
1526e3d441
test: automate NFS integration 2026-06-02 20:36:33 +02:00
Nicolas Meienberger
dfd787c8ae
test(integration): skip volume mounting in CI 2026-06-02 20:26:50 +02:00
Nico
a488bbc754
fix: block login for 2fa users with un-verified passkeys (#934)
* fix: block login for 2fa users with un-verified passkeys

* refactor(passkey): show proper login error

* refactor: show passkey generic error on all failures
2026-06-02 19:48:40 +02:00
Nicolas Meienberger
ce23bded90
test: automate SMB integration 2026-06-02 19:45:17 +02:00
Nicolas Meienberger
c793785e30
docs: user and role permission model 2026-06-02 19:21:25 +02:00
Nicolas Meienberger
036382d82d
test: automate webdav integration 2026-06-02 17:30:49 +02:00
Nicolas Meienberger
756ecbddcd
test: automate SFTP integration coverage 2026-06-02 17:25:10 +02:00
Nico
648ccae5fc
test(integration): s3 repository with rustfs & rclone (#933)
* test(integration): s3 repository with rustfs

* ci: run integration tests before release

* chore: fix linting issue

* ci: persist-creds -> false
2026-06-01 21:37:12 +02:00
Nicolas Meienberger
62cdf5dcca
fix(backup-config): throw if include patterns have un-supported characters 2026-06-01 20:14:12 +02:00
Nico
00d1dac515
fix(backup-config): throw if include patterns have un-supported chars (#931) 2026-06-01 20:02:07 +02:00
Nico
d479bfaddc
feat: snapshot restores through rpc (#930)
* feat: snapshot restores through rpc

* fix(restore): do not wait for lock before returning response

* chore: fix liniting issue
2026-05-31 21:48:32 +02:00
Nico
8fedeef4d1
feat: add restore agent RPC foundation (#929)
* feat: add restore agent RPC foundation

* chore: temp event handlers

* refactor: export restore progress from dto file
2026-05-31 19:39:20 +02:00
Nico
2d877cee5a
feat: durable tasks (#927) 2026-05-30 16:54:49 +02:00
Nico
0a2c6bca0c
refactor(restic): auto try to unlock and remove stale locks (#926) 2026-05-30 13:21:22 +02:00
Nico
d4436b0cdc
refactor(restic): all commands return effects (#924)
* refactor(restic): all commands are effects

* fix(restic): preserve effect failure errors

* chore: pr feedbacks
2026-05-30 10:10:54 +02:00
Nicolas Meienberger
ddf7cab503 chore(vite): allow tailscale hosts for dev server 2026-05-30 10:09:01 +02:00
renovate[bot]
74ddf574a8
fix(deps): update bun minor and patch dependencies (#902)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 22:24:59 +02:00
Nicolas Meienberger
d502b96509
test: fix webhook mocks 2026-05-29 20:45:16 +02:00
Nicolas Meienberger
dee3bb098a
e2e: fix 2fa leakage in next tests 2026-05-29 20:45:16 +02:00
Nicolas Meienberger
5ee65bf0af
refactor(sftp): mixed options style 2026-05-29 20:45:15 +02:00
Nico
7b5c53bb7d
fix(sftp): allow legacy ssh rsa to add support for older servers (#921)
* refactor(e2e): use more stable assertion

* feat(sftp): add legacy ssh-rsa option
2026-05-27 21:19:23 +02:00
Nicolas Meienberger
17a8838569
deps: bump rclone & shoutrrr to latest versions 2026-05-27 21:16:08 +02:00
Nicolas Meienberger
25b2f71b24
e2e: add missing host-gateway mapping 2026-05-27 21:15:03 +02:00
Nico
008296238b
test(e2e): add test suite to test webhooks (#922) 2026-05-27 21:12:53 +02:00
Nicolas Meienberger
2f78c4a1fb
chore: bump nitro & @better-auth/passkey 2026-05-27 21:07:12 +02:00
Nico
20d202c20e
fix(core): enforce webhook timeout (#920) 2026-05-27 20:35:01 +02:00
Nicolas Meienberger
47f8f2f03a
docs: update local dev server instructions 2026-05-27 17:40:43 +02:00
Nicolas Meienberger
4ea9f34154
chore: portless setup 2026-05-27 17:14:34 +02:00
Nico
e4898b97ea
fix(2fa): add missing 2fa column (#917) 2026-05-22 20:09:30 +02:00
Nico
98338e80c3
Add passkey authentication support (#845)
* feat(auth): add passkey authentication support

* fix: implement AI review feedback

* fix: use non-unique index for passkey_credentialID_idx in migration

* refactor(passkeys): use TanStack mutations for passkey CRUD operations

* chore: restore lockfile from main and add @better-auth/passkey

* chore: fix conflicts

* refactor(passkey-login): simplify passkey autofill event

* refactor(settings-passkeys): ux improvements

---------

Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-21 21:18:46 +02:00
Nico
273408cdb8
fix(shutdown): keep the current volume status after shutdown cleanup (#906)
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled
2026-05-20 15:04:48 +02:00
Copilot
0589fd63b1
Update Docker Compose docs examples to Zerobyte v0.37 image tag (#905)
* Initial plan

* docs: update docker compose image tags to v0.37

Agent-Logs-Url: https://github.com/nicotsx/zerobyte/sessions/067f22a5-8223-4fd3-8e9c-c50f6ab2cc6e

Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>
2026-05-20 14:38:51 +02:00
192 changed files with 17572 additions and 2412 deletions

View file

@ -3,6 +3,7 @@
!bun.lock
!package.json
!.gitignore
!bunfig.toml
!**/package.json
!**/bun.lock
@ -23,3 +24,4 @@
node_modules/**
dist/**
.output/**
app/test/integration/artifacts/**

View file

@ -4,6 +4,6 @@ RESTIC_CACHE_DIR=./data/restic/cache
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
ZEROBYTE_VOLUMES_DIR=./data/volumes
APP_SECRET=<openssl rand -hex 32>
BASE_URL=http://localhost:300
BASE_URL=http://*.localhost
TRUST_PROXY=false
PORT=3000

View file

@ -13,6 +13,7 @@ on:
jobs:
deploy:
name: Deploy docs
if: github.event.repository.default_branch == github.ref_name
runs-on: ubuntu-latest
timeout-minutes: 10

35
.github/workflows/integration.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: Integration Tests
permissions:
contents: read
on:
workflow_dispatch:
workflow_call:
jobs:
integration:
name: Integration
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Install dependencies
uses: "./.github/actions/install-dependencies"
- name: Run integration tests
env:
SKIP_VOLUME_MOUNT_INTEGRATION_TESTS: "true"
run: bun run test:integration
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: failure()
with:
name: integration-artifacts
path: |
app/test/integration/artifacts/compose.log
app/test/integration/artifacts/docker-output.log
app/test/integration/artifacts/runs/**
retention-days: 5

View file

@ -20,20 +20,20 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
with:
driver: cloud
endpoint: "meienberger/runtipi-builder"
install: true
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -41,14 +41,14 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: |
type=raw,value=nightly
- name: Push images to GitHub Container Registry
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
target: production

View file

@ -43,10 +43,13 @@ jobs:
e2e-tests:
uses: ./.github/workflows/e2e.yml
integration-tests:
uses: ./.github/workflows/integration.yml
build-images:
environment: release
timeout-minutes: 15
needs: [determine-release-type, checks, e2e-tests]
needs: [determine-release-type, checks, e2e-tests, integration-tests]
runs-on: ubuntu-latest
permissions:
contents: read
@ -60,20 +63,20 @@ jobs:
ref: ${{ github.ref }}
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
with:
driver: cloud
endpoint: "meienberger/runtipi-builder"
install: true
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -81,7 +84,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
with:
images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: |
@ -93,7 +96,7 @@ jobs:
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
- name: Push images to GitHub Container Registry
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
context: .
target: production

5
.gitignore vendored
View file

@ -15,7 +15,7 @@ notes.md
smb-password.txt
cache.db
data/
/data
.env*
!.env.example
@ -34,8 +34,11 @@ node_modules/
playwright/.auth
playwright/temp
playwright/data
.idea/
.deepsec/
.codex/
# OpenAPI error logs
openapi-ts-error-*.log

View file

@ -2,7 +2,7 @@
- Never create migration files manually. Always use the provided command to generate migrations
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
- The dev server is running at http://localhost:3000. Username is `admin` and password is `password`
- The dev server runs through Portless. Start it with `bun run dev`, then use `portless get zerobyte` to get the current worktree-specific URL. Do not assume a fixed port like `3000` or `4096`. Username is `admin` and password is `password`
- The repo is https://github.com/nicotsx/zerobyte
- If you need to run a specific restic command on a repository, you can open and use the dev panel with `Meta+Shift+D`
@ -10,6 +10,21 @@
Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, SFTP, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage).
### Development Server
```bash
# Start the dev server through Portless
bun run dev
# Get the current app URL for this worktree
portless get zerobyte
# Inspect active Portless routes if needed
portless list
```
Portless applies git worktree prefixes automatically, so linked worktrees may return URLs like `https://branch-name.zerobyte.localhost`. Use the Portless URL for browser testing and manual verification.
### Type Checking
```bash

View file

@ -1,8 +1,8 @@
FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base
ARG RESTIC_VERSION="0.18.1"
ARG RCLONE_VERSION="1.74.1"
ARG SHOUTRRR_VERSION="0.15.0"
ARG RCLONE_VERSION="1.74.2"
ARG SHOUTRRR_VERSION="0.15.1"
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
@ -44,6 +44,15 @@ RUN bzip2 -d restic.bz2 && chmod +x restic
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
# ------------------------------
# RUNTIME TOOLS
# ------------------------------
FROM base AS runtime-tools
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
# ------------------------------
# DEVELOPMENT
# ------------------------------

View file

@ -50,7 +50,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.36
image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -99,22 +99,22 @@ Zerobyte can be customized using environment variables. Below are the available
### Environment variables
| Variable | Description | Default |
| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
| `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
| `PORT` | The port the web interface and API will listen on. | `4096` |
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `WEBHOOK_ALLOWED_ORIGINS` | Comma-separated list of HTTP origins allowed for backup webhooks and outbound HTTP notification destinations. | (none) |
| `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests in seconds. | `60` |
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
| `RCLONE_CONFIG_DIR` | Path to the directory containing `rclone.conf` inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
| Variable | Description | Default |
| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
| `APP_SECRET` | **Required.** A random secret key (32+ chars) used to encrypt sensitive data in the database. Generate with `openssl rand -hex 32`. | (none) |
| `APP_SECRET_FILE` | Path to a file containing `APP_SECRET`, useful with Docker or Kubernetes secrets. Mutually exclusive with `APP_SECRET`. | (none) |
| `PORT` | The port the web interface and API will listen on. | `4096` |
| `RESTIC_HOSTNAME` | The hostname used by Restic when creating snapshots. Automatically detected if a custom hostname is set in Docker. | `zerobyte` |
| `TZ` | Timezone for the container (e.g., `Europe/Zurich`). **Crucial for accurate backup scheduling.** | `UTC` |
| `TRUST_PROXY` | When `true`, trust an existing `X-Forwarded-For` header from your reverse proxy. Leave `false` for direct deployments. | `false` |
| `TRUSTED_ORIGINS` | Comma-separated list of extra trusted origins for CORS (e.g., `http://localhost:3000,http://example.com`). | (none) |
| `WEBHOOK_ALLOWED_ORIGINS` | Comma-separated list of HTTP origins allowed for backup webhooks and outbound HTTP notification destinations. | (none) |
| `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests in seconds. | `60` |
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
| `RCLONE_CONFIG_DIR` | Path to the directory containing `rclone.conf` inside the container. Change this if running as a non-root user. | `/root/.config/rclone` |
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
### Webhook and notification network policy
@ -156,7 +156,7 @@ If you only need to back up locally-mounted folders and don't require remote sha
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.36
image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte
restart: unless-stopped
ports:
@ -195,7 +195,7 @@ If you want to backup a local directory on the same host where Zerobyte is runni
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.36
image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -270,7 +270,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.36
image: ghcr.io/nicotsx/zerobyte:v0.38
container_name: zerobyte
restart: unless-stopped
cap_add:
@ -336,6 +336,12 @@ Zerobyte allows you to easily restore your data from backups. To restore data, n
Zerobyte uses [better-auth](https://github.com/better-auth/better-auth) for authentication and session management. The authentication system automatically adapts to your deployment scenario:
### Users and roles
Zerobyte does not currently provide fine-grained RBAC for backup operations. Authenticated organization members are trusted operators for repositories, volumes, backup schedules, restores, and notification destinations in their organization.
Organization roles mainly restrict organization-management and instance-management actions. For example, normal members cannot manage members, SSO settings, invitations, registration, or global users, but they can still operate backup resources. Only add users to an organization if you are comfortable with them using the storage, volume, and notification capabilities configured for the instance.
### Cookie security
- **IP Address/HTTP access**: Set `BASE_URL=http://192.168.1.50:4096` (or your IP). Cookies will use `Secure: false`, allowing immediate login without SSL.
@ -398,7 +404,7 @@ RESTIC_PASS_FILE=./data/restic.pass
RESTIC_CACHE_DIR=./data/restic/cache
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
ZEROBYTE_VOLUMES_DIR=./data/volumes
BASE_URL=http://localhost:4096
BASE_URL=https://*.localhost
```
Notes:

View file

@ -49,10 +49,7 @@ export const createClient = (config: Config = {}): Client => {
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
await setAuthParams(opts);
}
if (opts.requestValidator) {

View file

@ -119,14 +119,12 @@ const checkForExistence = (
return false;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
export async function setAuthParams(
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
headers: Headers;
}) => {
for (const auth of security) {
},
): Promise<void> {
for (const auth of options.security ?? []) {
if (checkForExistence(options, auth.name)) {
continue;
}
@ -155,7 +153,7 @@ export const setAuthParams = async ({
break;
}
}
};
}
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({

View file

@ -337,14 +337,7 @@ export type ListVolumesResponses = {
200: Array<{
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -359,6 +352,7 @@ export type ListVolumesResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -392,7 +386,15 @@ export type ListVolumesResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
}>;
};
@ -416,6 +418,7 @@ export type CreateVolumeData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -449,6 +452,7 @@ export type CreateVolumeData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path?: never;
@ -463,14 +467,7 @@ export type CreateVolumeResponses = {
201: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -485,6 +482,7 @@ export type CreateVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -518,7 +516,15 @@ export type CreateVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
};
@ -541,6 +547,7 @@ export type TestConnectionData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -574,6 +581,7 @@ export type TestConnectionData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path?: never;
@ -637,14 +645,7 @@ export type GetVolumeResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -659,6 +660,7 @@ export type GetVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -692,13 +694,21 @@ export type GetVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
statfs: {
total: number;
used: number;
free: number;
total?: number;
used?: number;
free?: number;
};
};
};
@ -723,6 +733,7 @@ export type UpdateVolumeData = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -756,6 +767,7 @@ export type UpdateVolumeData = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
};
path: {
@ -779,14 +791,7 @@ export type UpdateVolumeResponses = {
200: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -801,6 +806,7 @@ export type UpdateVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -834,7 +840,15 @@ export type UpdateVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
};
@ -855,8 +869,8 @@ export type MountVolumeResponses = {
* Volume mounted successfully
*/
200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error';
error?: string;
};
};
@ -876,8 +890,8 @@ export type UnmountVolumeResponses = {
* Volume unmounted successfully
*/
200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error';
error?: string;
};
};
@ -904,8 +918,8 @@ export type HealthCheckVolumeResponses = {
* Volume health check result
*/
200: {
error?: string;
status: 'mounted' | 'unmounted' | 'error';
error?: string;
};
};
@ -932,7 +946,7 @@ export type ListFilesResponses = {
files: Array<{
name: string;
path: string;
type: 'file' | 'directory';
type: 'directory' | 'file';
size?: number;
modifiedAt?: number;
}>;
@ -966,8 +980,8 @@ export type BrowseFilesystemResponses = {
directories: Array<{
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
type: 'directory';
size?: unknown;
modifiedAt?: number;
}>;
path: string;
@ -1136,6 +1150,7 @@ export type ListRepositoriesResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1319,6 +1334,7 @@ export type CreateRepositoryData = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1557,6 +1573,7 @@ export type GetRepositoryResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1740,6 +1757,7 @@ export type UpdateRepositoryData = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -1931,6 +1949,7 @@ export type UpdateRepositoryResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -2248,6 +2267,7 @@ export type RestoreSnapshotData = {
excludeXattr?: Array<string>;
delete?: boolean;
targetPath?: string;
targetAgentId?: string;
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
};
path: {
@ -2259,13 +2279,11 @@ export type RestoreSnapshotData = {
export type RestoreSnapshotResponses = {
/**
* Snapshot restored successfully
* Snapshot restore started
*/
200: {
success: boolean;
message: string;
filesRestored: number;
filesSkipped: number;
202: {
restoreId: string;
status: 'started';
};
};
@ -2457,14 +2475,7 @@ export type ListBackupSchedulesResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -2479,6 +2490,7 @@ export type ListBackupSchedulesResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -2512,7 +2524,15 @@ export type ListBackupSchedulesResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
repository: {
@ -2664,6 +2684,7 @@ export type ListBackupSchedulesResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -2875,14 +2896,7 @@ export type GetBackupScheduleResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -2897,6 +2911,7 @@ export type GetBackupScheduleResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -2930,7 +2945,15 @@ export type GetBackupScheduleResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
repository: {
@ -3082,6 +3105,7 @@ export type GetBackupScheduleResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -3274,14 +3298,7 @@ export type GetBackupScheduleForVolumeResponses = {
volume: {
id: number;
shortId: string;
provisioningId: string | null;
name: string;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
config: {
backend: 'nfs';
server: string;
@ -3296,6 +3313,7 @@ export type GetBackupScheduleForVolumeResponses = {
username?: string;
password?: string;
guest?: boolean;
mapToContainerUidGid?: boolean;
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
domain?: string;
port?: string | number;
@ -3329,7 +3347,15 @@ export type GetBackupScheduleForVolumeResponses = {
readOnly?: boolean;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
};
createdAt: number;
updatedAt: number;
lastHealthCheck: number;
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
status: 'mounted' | 'unmounted' | 'error';
lastError: string | null;
provisioningId?: string | null;
autoRemount: boolean;
};
repository: {
@ -3481,6 +3507,7 @@ export type GetBackupScheduleForVolumeResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -3955,6 +3982,7 @@ export type GetScheduleMirrorsResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;
@ -4167,6 +4195,7 @@ export type UpdateScheduleMirrorsResponses = {
privateKey: string;
skipHostKeyCheck?: boolean;
knownHosts?: string;
allowLegacySshRsa?: boolean;
isExistingRepository?: boolean;
customPassword?: string;
cacert?: string;

View file

@ -224,29 +224,6 @@ describe("SnapshotTreeBrowser", () => {
});
});
test("prefetches using the query path when display and query roots differ", async () => {
const requests = mockListSnapshotFiles();
renderSnapshotTreeBrowser();
const row = await screen.findByRole("button", { name: "project" });
const initialRequestCount = requests.length;
await userEvent.hover(row);
await waitFor(() => {
expect(requests.length).toBe(initialRequestCount + 1);
});
expect(requests.at(-1)).toEqual({
shortId: "repo-1",
snapshotId: "snap-1",
path: "/mnt/project",
offset: "0",
limit: "500",
});
});
test("shows the query root contents when display and query roots differ", async () => {
mockListSnapshotFiles();

View file

@ -5,7 +5,6 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
import { logger } from "~/client/lib/logger";
function createPathPrefixFns(basePath: string) {
return {
@ -84,16 +83,6 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => {
}),
);
},
prefetchFolder: (displayPath) => {
void queryClient
.prefetchQuery(
listSnapshotFilesOptions({
path: { shortId: repositoryId, snapshotId },
query: { path: displayPath, offset: 0, limit: pageSize },
}),
)
.catch((e) => logger.error(e));
},
pathTransform: displayPathFns,
});

View file

@ -1,6 +1,5 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "~/client/lib/utils";
@ -55,12 +54,8 @@ function InputOTPSlot({
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
function InputOTPSeparator({ ...props }: React.ComponentProps<"hr">) {
return <hr data-slot="input-otp-separator" {...props} />;
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View file

@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { getIsSecureContextForRequest, isSecureContextOrigin } from "../is-secure-context";
describe("isSecureContextOrigin", () => {
test("treats HTTPS and loopback HTTP origins as secure contexts", () => {
expect(isSecureContextOrigin("https://example.com")).toBe(true);
expect(isSecureContextOrigin("http://localhost:3000")).toBe(true);
expect(isSecureContextOrigin("http://app.localhost")).toBe(true);
expect(isSecureContextOrigin("http://127.0.0.1:3000")).toBe(true);
expect(isSecureContextOrigin("http://[::1]:3000")).toBe(true);
});
test("treats non-local HTTP origins as insecure contexts", () => {
expect(isSecureContextOrigin("http://example.com")).toBe(false);
expect(isSecureContextOrigin("http://127.example.com")).toBe(false);
});
});
describe("getIsSecureContextForRequest", () => {
test("uses forwarded protocol and host when present", () => {
const request = new Request("http://internal.local/settings", {
headers: {
host: "internal.local",
"x-forwarded-host": "zerobyte.example.com",
"x-forwarded-proto": "https",
},
});
expect(getIsSecureContextForRequest(request)).toBe(true);
});
test("uses the request URL when forwarded headers are absent", () => {
expect(getIsSecureContextForRequest(new Request("http://zerobyte.example.com/settings"))).toBe(false);
expect(getIsSecureContextForRequest(new Request("https://zerobyte.example.com/settings"))).toBe(true);
});
});

View file

@ -0,0 +1,51 @@
import { createIsomorphicFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import ipaddr from "ipaddr.js";
const getFirstHeaderValue = (value: string | null) => value?.split(",")[0]?.trim();
const isLoopbackIp = (hostname: string) => {
try {
return ipaddr.parse(hostname).range() === "loopback";
} catch {
return false;
}
};
const getRequestOrigin = (request: Request) => {
const requestUrl = new URL(request.url);
const forwardedProto = getFirstHeaderValue(request.headers.get("x-forwarded-proto"));
const forwardedHost = getFirstHeaderValue(request.headers.get("x-forwarded-host"));
const host = forwardedHost || getFirstHeaderValue(request.headers.get("host"));
if (!host) {
return requestUrl.origin;
}
const protocol = (forwardedProto || requestUrl.protocol).replace(/:$/, "");
return `${protocol}://${host}`;
};
export const isSecureContextOrigin = (origin: string) => {
const url = new URL(origin);
if (url.protocol === "https:") {
return true;
}
const hostname = url.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
return hostname === "localhost" || hostname.endsWith(".localhost") || isLoopbackIp(hostname);
};
export const getIsSecureContextForRequest = (request: Request) => isSecureContextOrigin(getRequestOrigin(request));
export const getIsSecureContext = createIsomorphicFn()
.server(() => {
try {
return getIsSecureContextForRequest(getRequest());
} catch {
return true;
}
})
.client(() => window.isSecureContext ?? true);

View file

@ -7,6 +7,7 @@ import {
inferAdditionalFields,
} from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import { passkeyClient } from "@better-auth/passkey/client";
import type { auth } from "~/server/lib/auth";
export const authClient = createAuthClient({
@ -17,5 +18,6 @@ export const authClient = createAuthClient({
organizationClient(),
ssoClient(),
twoFactorClient(),
passkeyClient(),
],
});

View file

@ -0,0 +1,35 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useServerFn } from "@tanstack/react-start";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { SsoLoginButtons } from "~/client/modules/sso/components/sso-login-buttons";
import { getLoginOptions } from "~/server/lib/functions/login-options";
import { PasskeySignInButton } from "./passkey-sign-in-button";
type AlternativeSignInSectionProps = {
onPasskeySignIn: () => Promise<void>;
};
export function AlternativeSignInSection({ onPasskeySignIn }: AlternativeSignInSectionProps) {
const getOptions = useServerFn(getLoginOptions);
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const { data: loginOptions } = useSuspenseQuery({
queryKey: ["login-options"],
queryFn: getOptions,
});
if (ssoProviders.providers.length === 0 && !loginOptions.hasPasskeySignIn) {
return null;
}
return (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{loginOptions.hasPasskeySignIn && <PasskeySignInButton onSignIn={onPasskeySignIn} />}
<SsoLoginButtons providers={ssoProviders.providers} />
</div>
</div>
);
}

View file

@ -0,0 +1,64 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Fingerprint } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { LOGIN_ERROR_CODES, PASSKEY_LOGIN_FAILED_ERROR, type LoginErrorCode } from "~/lib/sso-errors";
type PasskeySignInButtonProps = {
onSignIn: () => Promise<void>;
};
type PasskeySignInError = {
code?: string;
message?: string;
status?: number;
statusText?: string;
};
const LOGIN_ERROR_CODE_SET = new Set<string>(LOGIN_ERROR_CODES);
function getPasskeyLoginErrorCode(code: string | undefined): LoginErrorCode {
if (code && LOGIN_ERROR_CODE_SET.has(code)) {
return code as LoginErrorCode;
}
return PASSKEY_LOGIN_FAILED_ERROR;
}
export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
const navigate = useNavigate();
const passkeyLoginMutation = useMutation<void, PasskeySignInError>({
mutationFn: async () => {
const { error } = await authClient.signIn.passkey();
if (error) throw error;
await onSignIn();
},
onError: (error) => {
logger.error(error);
void navigate({
to: "/login",
search: {
error: getPasskeyLoginErrorCode(error.code),
},
});
},
});
return (
<Button
type="button"
variant="outline"
className="w-full"
loading={passkeyLoginMutation.isPending}
disabled={passkeyLoginMutation.isPending}
onClick={() => passkeyLoginMutation.mutate()}
>
<Fingerprint className="h-4 w-4 mr-2" />
Sign in with passkey
</Button>
);
}

View file

@ -1,16 +1,49 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen } from "~/test/test-utils";
import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils";
import { getLoginErrorDescription, PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({
mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })),
mockNavigate: vi.fn(async () => {}),
mockPasskeySignIn: vi.fn(
async (): Promise<{
data: unknown;
error: { code: string; message: string } | null;
}> => ({ data: null, error: null }),
),
}));
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate,
useNavigate: (() => mockNavigate) as typeof actual.useNavigate,
};
});
vi.mock("@tanstack/react-start", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-start")>();
return {
...actual,
useServerFn: (fn: unknown) => fn,
};
});
vi.mock("~/server/lib/functions/login-options", () => ({
getLoginOptions: mockGetLoginOptions,
}));
vi.mock("~/client/lib/auth-client", () => ({
authClient: {
signIn: {
passkey: mockPasskeySignIn,
},
},
}));
import { LoginPage } from "../login";
const inviteOnlyMessage =
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
@ -29,6 +62,12 @@ const mockSsoProvidersRequest = (
};
afterEach(() => {
mockGetLoginOptions.mockClear();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false });
mockNavigate.mockClear();
mockPasskeySignIn.mockClear();
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
vi.unstubAllGlobals();
cleanup();
});
@ -81,6 +120,14 @@ describe("LoginPage", () => {
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
});
test("shows passkey login failure message when passkey returns the login error code", async () => {
mockSsoProvidersRequest();
render(<LoginPage error={"PASSKEY_LOGIN_FAILED"} />, { withSuspense: true });
expect(await screen.findByText(getLoginErrorDescription(PASSKEY_LOGIN_FAILED_ERROR))).toBeTruthy();
});
test("does not show error message for invalid error codes", async () => {
mockSsoProvidersRequest();
@ -90,11 +137,141 @@ describe("LoginPage", () => {
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
});
test("renders available SSO providers from the real SSO section", async () => {
test("renders available SSO providers from the alternative sign-in section", async () => {
mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]);
render(<LoginPage />, { withSuspense: true });
expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy();
});
test("renders passkey sign-in when an active user has a passkey", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
render(<LoginPage />, { withSuspense: true });
expect(await screen.findByRole("button", { name: "Sign in with passkey" })).toBeTruthy();
});
test("redirects passkey verification failures to the login error box", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
});
render(<LoginPage />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
});
});
test("redirects unauthorized passkey failures to the login error box", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "UNAUTHORIZED", message: "Unauthorized" },
});
render(<LoginPage />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
});
});
test("preserves specific passkey login error codes", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "ERROR_INVALID_RP_ID", message: "Auth cancelled" },
});
render(<LoginPage />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: "ERROR_INVALID_RP_ID",
},
});
});
});
test("redirects conditional passkey autofill failures to the login error box", async () => {
mockSsoProvidersRequest();
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
});
vi.stubGlobal("PublicKeyCredential", {
isConditionalMediationAvailable: vi.fn(async () => true),
});
render(<LoginPage />, { withSuspense: true });
await waitFor(() => {
expect(mockPasskeySignIn).toHaveBeenCalledWith({
autoFill: true,
});
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: "PASSKEY_LOGIN_FAILED",
},
});
});
});
test("ignores conditional passkey autofill cancellation", async () => {
mockSsoProvidersRequest();
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTH_CANCELLED", message: "Authentication cancelled" },
});
vi.stubGlobal("PublicKeyCredential", {
isConditionalMediationAvailable: vi.fn(async () => true),
});
render(<LoginPage />, { withSuspense: true });
await waitFor(() => {
expect(mockPasskeySignIn).toHaveBeenCalledWith({
autoFill: true,
});
});
expect(mockNavigate).not.toHaveBeenCalled();
});
test("hides alternative sign-in when no SSO providers or passkeys are available", async () => {
mockSsoProvidersRequest();
render(<LoginPage />, { withSuspense: true });
await screen.findByText("Login to your account");
expect(screen.queryByText("Alternative Sign-in")).toBeNull();
expect(screen.queryByRole("button", { name: "Sign in with passkey" })).toBeNull();
});
});

View file

@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { AuthLayout } from "~/client/components/auth-layout";
@ -12,11 +12,12 @@ import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
import { ResetPasswordDialog } from "../components/reset-password-dialog";
import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
import { cn } from "~/client/lib/utils";
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
import { AlternativeSignInSection } from "../components/alternative-sign-in-section";
import { z } from "zod";
const loginSchema = z.object({
@ -33,6 +34,17 @@ type LoginPageProps = {
error?: string;
};
type PasskeySignInError = {
code?: string;
message?: string;
status?: number;
statusText?: string;
};
function isPasskeyVerificationFailure(error: PasskeySignInError | null) {
return error?.code === "AUTHENTICATION_FAILED" || error?.code === "UNAUTHORIZED";
}
function hasSkippedRecoveryKeyDownload(userId: string) {
return document.cookie
.split(";")
@ -50,6 +62,52 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const errorCode = decodeLoginError(error);
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const navigateAfterLogin = useCallback(async () => {
const session = await authClient.getSession();
if (
session.data?.user &&
!session.data.user.hasDownloadedResticPassword &&
!hasSkippedRecoveryKeyDownload(session.data.user.id)
) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
}
}, [navigate]);
useEffect(() => {
const autoSignIn = async () => {
if (
typeof PublicKeyCredential === "undefined" ||
!PublicKeyCredential.isConditionalMediationAvailable ||
!(await PublicKeyCredential.isConditionalMediationAvailable())
) {
return;
}
const { data, error } = await authClient.signIn.passkey({
autoFill: true,
});
if (isPasskeyVerificationFailure(error)) {
void navigate({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
return;
}
if (data) {
await navigateAfterLogin();
}
};
void autoSignIn();
}, [navigate, navigateAfterLogin]);
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
@ -83,12 +141,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
return;
}
const d = await authClient.getSession();
if (data.user && !d.data?.user.hasDownloadedResticPassword && !hasSkippedRecoveryKeyDownload(data.user.id)) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
}
await navigateAfterLogin();
};
const handleVerify2FA = async () => {
@ -175,6 +228,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<input
type="checkbox"
id="trust-device"
aria-label="Trust this device for 30 days"
checked={trustDevice}
onChange={(e) => setTrustDevice(e.target.checked)}
className="h-4 w-4"
@ -227,7 +281,13 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} />
<Input
{...field}
type="text"
placeholder="admin"
disabled={isLoggingIn}
autoComplete="username webauthn"
/>
</FormControl>
<FormMessage />
</FormItem>
@ -249,7 +309,12 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</button>
</div>
<FormControl>
<Input {...field} type="password" disabled={isLoggingIn} />
<Input
{...field}
type="password"
disabled={isLoggingIn}
autoComplete="current-password webauthn"
/>
</FormControl>
<FormMessage />
</FormItem>
@ -261,7 +326,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</form>
</Form>
<SsoLoginSection />
<AlternativeSignInSection onPasskeySignIn={navigateAfterLogin} />
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
</AuthLayout>

View file

@ -63,9 +63,7 @@ export function ScheduleDetailsPage(props: Props) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(
initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id,
);
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(initialSnapshotId);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);

View file

@ -92,7 +92,13 @@ const defaultValuesForType = (repoBase: string) => ({
azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
rest: { backend: "rest" as const, compressionMode: "auto" as const },
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22, skipHostKeyCheck: false },
sftp: {
backend: "sftp" as const,
compressionMode: "auto" as const,
port: 22,
skipHostKeyCheck: false,
allowLegacySshRsa: false,
},
});
export const CreateRepositoryForm = ({
@ -251,7 +257,9 @@ export const CreateRepositoryForm = ({
</FormControl>
<div className="space-y-1">
<FormLabel>Import existing repository</FormLabel>
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
<FormDescription>
Check this if the repository already exists at the specified location
</FormDescription>
</div>
</FormItem>
)}
@ -281,8 +289,8 @@ export const CreateRepositoryForm = ({
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
a custom password for the existing repository.
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your
account) or enter a custom password for the existing repository.
</FormDescription>
</FormItem>

View file

@ -26,6 +26,7 @@ export const AdvancedForm = ({ form }: Props) => {
const cacert = useWatch({ control: form.control, name: "cacert" });
const uploadLimitEnabled = useWatch({ control: form.control, name: "uploadLimit.enabled" });
const downloadLimitEnabled = useWatch({ control: form.control, name: "downloadLimit.enabled" });
const backend = useWatch({ control: form.control, name: "backend" });
return (
<Collapsible>
@ -44,7 +45,9 @@ export const AdvancedForm = ({ form }: Props) => {
</FormControl>
<div className="space-y-1">
<FormLabel>Enable upload speed limit</FormLabel>
<FormDescription className="text-xs">Limit upload speed to the repository</FormDescription>
<FormDescription className="text-xs">
Limit upload speed to the repository
</FormDescription>
</div>
</FormItem>
)}
@ -66,7 +69,9 @@ export const AdvancedForm = ({ form }: Props) => {
placeholder="10"
className="pr-12"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 1)
}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" />
@ -120,7 +125,9 @@ export const AdvancedForm = ({ form }: Props) => {
</FormControl>
<div className="space-y-1">
<FormLabel>Enable download speed limit</FormLabel>
<FormDescription className="text-xs">Limit download speed from the repository</FormDescription>
<FormDescription className="text-xs">
Limit download speed from the repository
</FormDescription>
</div>
</FormItem>
)}
@ -144,7 +151,9 @@ export const AdvancedForm = ({ form }: Props) => {
disabled={!downloadLimitEnabled}
className="pr-12"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 1)
}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" />
@ -208,8 +217,8 @@ export const AdvancedForm = ({ form }: Props) => {
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !cacert })}>
<p className="max-w-xs">
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
TLS validation instead.
This option is disabled because a CA certificate is provided. Remove the CA
certificate to skip TLS validation instead.
</p>
</TooltipContent>
</Tooltip>
@ -217,8 +226,8 @@ export const AdvancedForm = ({ form }: Props) => {
<div className="space-y-1 leading-none">
<FormLabel>Skip TLS certificate verification</FormLabel>
<FormDescription>
Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is
insecure and should only be used for testing.
Disable TLS certificate verification for HTTPS connections with self-signed
certificates. This is insecure and should only be used for testing.
</FormDescription>
</div>
</FormItem>
@ -235,7 +244,9 @@ export const AdvancedForm = ({ form }: Props) => {
<TooltipTrigger asChild>
<div>
<Textarea
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
}
rows={6}
disabled={insecureTls}
{...field}
@ -244,8 +255,8 @@ export const AdvancedForm = ({ form }: Props) => {
</TooltipTrigger>
<TooltipContent className={cn({ hidden: !insecureTls })}>
<p className="max-w-xs">
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
Verification" to provide a custom CA certificate.
CA certificate is disabled because TLS validation is being skipped. Uncheck
"Skip TLS Certificate Verification" to provide a custom CA certificate.
</p>
</TooltipContent>
</Tooltip>
@ -266,6 +277,26 @@ export const AdvancedForm = ({ form }: Props) => {
</FormItem>
)}
/>
{backend === "sftp" && (
<FormField
control={form.control}
name="allowLegacySshRsa"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
<FormDescription>
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
</FormDescription>
</div>
<FormControl>
<Checkbox checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
</CollapsibleContent>
</Collapsible>
);

View file

@ -0,0 +1,322 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Fingerprint, Plus, Trash2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { getIsSecureContext } from "~/client/functions/is-secure-context";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { useTimeFormat } from "~/client/lib/datetime";
import { cn } from "~/client/lib/utils";
type PasskeyEntry = {
id: string;
name?: string | null;
createdAt: Date | string;
deviceType?: string;
};
export function PasskeysSection() {
const { formatDateTime } = useTimeFormat();
const isSecureContext = getIsSecureContext();
const { data: passkeys, isPending } = useQuery({
queryKey: ["passkeys"],
queryFn: async () => {
const { data, error } = await authClient.passkey.listUserPasskeys();
if (error) throw error;
return data;
},
});
const [deletePasskeyOpen, setDeletePasskeyOpen] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [newPasskeyName, setNewPasskeyName] = useState("");
const [renameTarget, setRenameTarget] = useState<PasskeyEntry | null>(null);
const [renameValue, setRenameValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<PasskeyEntry | null>(null);
const addPasskeyMutation = useMutation({
mutationFn: async (name: string | undefined) => {
const { error } = await authClient.passkey.addPasskey({ name });
if (error) throw error;
},
onSuccess: () => {
toast.success("Passkey added");
setAddDialogOpen(false);
setNewPasskeyName("");
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to add passkey", { description: error.message });
},
});
const renamePasskeyMutation = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
const { error } = await authClient.$fetch("/passkey/update-passkey", {
method: "POST",
body: { id, name },
});
if (error) throw error;
},
onSuccess: () => {
toast.success("Passkey renamed");
setRenameTarget(null);
setRenameValue("");
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to rename passkey", { description: error.message });
},
});
const deletePasskeyMutation = useMutation({
mutationFn: async (id: string) => {
const { error } = await authClient.passkey.deletePasskey({ id });
if (error) throw error;
},
onMutate: () => {
setDeletePasskeyOpen(false);
},
onSuccess: () => {
toast.success("Passkey deleted");
setDeleteTarget(null);
},
onError: (error: Error) => {
logger.error(error);
toast.error("Failed to delete passkey", { description: error.message });
},
});
const handleAddPasskey = (e: React.ChangeEvent) => {
e.preventDefault();
if (!isSecureContext) return;
const name = newPasskeyName.trim() || undefined;
addPasskeyMutation.mutate(name);
};
const handleRename = (e: React.ChangeEvent) => {
e.preventDefault();
if (!renameTarget) return;
const name = renameValue.trim();
if (!name) {
toast.error("Name is required");
return;
}
renamePasskeyMutation.mutate({ id: renameTarget.id, name });
};
const handleDelete = () => {
if (!deleteTarget) return;
deletePasskeyMutation.mutate(deleteTarget.id);
};
return (
<>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5" />
Passkeys
</CardTitle>
<CardDescription className="mt-1.5">
Sign in faster and more securely with passkeys stored on your device or password manager. You can
add more than one.
</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-xl">
Passkeys use your device's biometrics or screen lock instead of a password. They are
phishing-resistant and cannot be reused across sites.
</p>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button onClick={() => setAddDialogOpen(true)} disabled={!isSecureContext}>
<Plus className="h-4 w-4 mr-2" />
Add passkey
</Button>
</span>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: isSecureContext })}>
<p>Passkeys can only be added over HTTPS or from localhost.</p>
</TooltipContent>
</Tooltip>
</div>
<p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading passkeys...</p>
<p className={cn("text-sm text-muted-foreground", { hidden: passkeys && passkeys.length > 0 })}>
No passkeys yet. Add one to enable passwordless sign-in.
</p>
<ul
className={cn("divide-y divide-border/50 rounded-md border border-border/50", {
hidden: passkeys?.length === 0,
})}
>
{passkeys?.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-4 p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.name?.trim() || "Unnamed passkey"}</p>
<p className="text-xs text-muted-foreground">
Added {formatDateTime(new Date(p.createdAt))}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
aria-label={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`}
title={`Rename passkey ${p.name?.trim() || "Unnamed passkey"}`}
onClick={() => {
setRenameTarget(p);
setRenameValue(p.name ?? "");
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
aria-label={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`}
title={`Delete passkey ${p.name?.trim() || "Unnamed passkey"}`}
onClick={() => {
setDeleteTarget(p);
setDeletePasskeyOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
</CardContent>
<Dialog
open={addDialogOpen}
onOpenChange={(open) => {
setAddDialogOpen(open);
if (!open) setNewPasskeyName("");
}}
>
<DialogContent>
<form onSubmit={handleAddPasskey}>
<DialogHeader>
<DialogTitle>Add a passkey</DialogTitle>
<DialogDescription>
Give this passkey a name so you can recognize it later (e.g. "MacBook Touch ID").
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkey-name">Name (optional)</Label>
<Input
id="passkey-name"
value={newPasskeyName}
onChange={(e) => setNewPasskeyName(e.target.value)}
placeholder="My Laptop"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setAddDialogOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={addPasskeyMutation.isPending} disabled={!isSecureContext}>
<Fingerprint className="h-4 w-4 mr-2" />
Add passkey
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(renameTarget)}
onOpenChange={(open) => {
if (!open) {
setRenameTarget(null);
setRenameValue("");
}
}}
>
<DialogContent>
<form onSubmit={handleRename}>
<DialogHeader>
<DialogTitle>Rename passkey</DialogTitle>
<DialogDescription>Choose a name to recognize this passkey.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkey-rename">Name</Label>
<Input
id="passkey-rename"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
required
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRenameTarget(null)}>
Cancel
</Button>
<Button type="submit" loading={renamePasskeyMutation.isPending}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={deletePasskeyOpen} onOpenChange={setDeletePasskeyOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete passkey?</AlertDialogTitle>
<AlertDialogDescription>
This will remove "{deleteTarget?.name?.trim() || "this passkey"}" from your account. You
won't be able to use it to sign in anymore.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deletePasskeyMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={deletePasskeyMutation.isPending}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -42,6 +42,7 @@ import { logger } from "~/client/lib/logger";
import { parseError } from "~/client/lib/errors";
import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { PasskeysSection } from "../components/passkeys-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
import { OrgMembersSection } from "../components/org-members-section";
@ -458,6 +459,8 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</CardContent>
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
<PasskeysSection />
</Card>
</TabsContent>

View file

@ -0,0 +1,54 @@
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
type SsoProvider = {
providerId: string;
};
type SsoLoginButtonsProps = {
providers: SsoProvider[];
};
export function SsoLoginButtons({ providers }: SsoLoginButtonsProps) {
const ssoLoginMutation = useMutation({
mutationFn: async (providerId: string) => {
const callbackPath = "/login";
const { data, error } = await authClient.signIn.sso({
providerId: providerId,
callbackURL: callbackPath,
errorCallbackURL: "/api/v1/auth/login-error",
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
logger.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
return (
<>
{providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</>
);
}

View file

@ -1,58 +0,0 @@
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
export function SsoLoginSection() {
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const ssoLoginMutation = useMutation({
mutationFn: async (providerId: string) => {
const callbackPath = "/login";
const { data, error } = await authClient.signIn.sso({
providerId: providerId,
callbackURL: callbackPath,
errorCallbackURL: "/api/v1/auth/login-error",
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
logger.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
if (ssoProviders.providers.length === 0) {
return null;
}
return (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{ssoProviders.providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</div>
</div>
);
}

View file

@ -69,7 +69,7 @@ const defaultValuesForType = {
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const, mapToContainerUidGid: false },
webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" },
rclone: { backend: "rclone" as const, path: "/" },
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false },
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false, allowLegacySshRsa: false },
};
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
@ -230,7 +230,10 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<Tooltip>
<TooltipTrigger asChild>
<div>
<SelectItem disabled={!capabilities.rclone || !capabilities.sysAdmin} value="rclone">
<SelectItem
disabled={!capabilities.rclone || !capabilities.sysAdmin}
value="rclone"
>
rclone
</SelectItem>
</div>
@ -238,7 +241,11 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
<p>Remote mounts require SYS_ADMIN capability</p>
</TooltipContent>
<TooltipContent className={cn({ hidden: !capabilities.sysAdmin || capabilities.rclone })}>
<TooltipContent
className={cn({
hidden: !capabilities.sysAdmin || capabilities.rclone,
})}
>
<p>Setup rclone to use this backend</p>
</TooltipContent>
</Tooltip>

View file

@ -101,6 +101,7 @@ export const NFSForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Mount volume as read-only"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"

View file

@ -108,6 +108,7 @@ export const RcloneForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Mount volume as read-only"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"

View file

@ -12,6 +12,7 @@ import { Input } from "../../../../components/ui/input";
import { SecretInput } from "../../../../components/ui/secret-input";
import { Textarea } from "../../../../components/ui/textarea";
import { Switch } from "../../../../components/ui/switch";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
type Props = {
form: UseFormReturn<FormValues>;
@ -78,7 +79,9 @@ export const SFTPForm = ({ form }: Props) => {
<FormControl>
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for SFTP authentication (optional if using private key).</FormDescription>
<FormDescription>
Password for SFTP authentication (optional if using private key).
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -98,7 +101,9 @@ export const SFTPForm = ({ form }: Props) => {
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>SSH private key for authentication (optional if using password).</FormDescription>
<FormDescription>
SSH private key for authentication (optional if using password).
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -158,6 +163,29 @@ export const SFTPForm = ({ form }: Props) => {
)}
/>
)}
<Collapsible>
<CollapsibleTrigger>Advanced Settings</CollapsibleTrigger>
<CollapsibleContent className="pb-4 pt-4">
<FormField
control={form.control}
name="allowLegacySshRsa"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Allow legacy SSH RSA/SHA1 algorithms</FormLabel>
<FormDescription>
Only enable this for legacy SFTP servers that offer <code>ssh-rsa</code> only.
It permits RSA/SHA1 signatures, which are weaker than modern SSH algorithms.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value ?? false} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</>
);
};

View file

@ -60,6 +60,7 @@ export const SMBForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Connect as guest"
checked={field.value ?? false}
onChange={(e) => {
field.onChange(e.target.checked);
@ -179,6 +180,7 @@ export const SMBForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Map all files to container user/group"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
@ -187,8 +189,8 @@ export const SMBForm = ({ form }: Props) => {
</div>
</FormControl>
<FormDescription>
Keep the old behavior by forcing the SMB mount to present every file and directory as owned by the
container user and group instead of using server reported ownership.
Keep the old behavior by forcing the SMB mount to present every file and directory as owned
by the container user and group instead of using server reported ownership.
</FormDescription>
<FormMessage />
</FormItem>
@ -205,6 +207,7 @@ export const SMBForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Mount volume as read-only"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"

View file

@ -105,6 +105,7 @@ export const WebDAVForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Enable HTTPS for secure connections"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
@ -128,6 +129,7 @@ export const WebDAVForm = ({ form }: Props) => {
<div className="flex items-center space-x-2">
<input
type="checkbox"
aria-label="Mount volume as read-only"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"

View file

@ -33,7 +33,9 @@ function ConfigRow({ icon, label, value, mono }: ConfigRowProps) {
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<span className="text-muted-foreground shrink-0">{icon}</span>
<span className="text-sm text-muted-foreground w-40 shrink-0">{label}</span>
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>{value}</span>
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>
{value}
</span>
</div>
);
}
@ -43,12 +45,19 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
switch (config.backend) {
case "directory":
return <ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />;
return (
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />
);
case "nfs":
return (
<>
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Export Path" value={config.exportPath} mono />
<ConfigRow
icon={<FolderOpen className="h-4 w-4" />}
label="Export Path"
value={config.exportPath}
mono
/>
</>
);
case "smb":
@ -84,6 +93,8 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
}
function DonutChart({ statfs }: { statfs: StatFs }) {
const { used = 0, total = 0 } = statfs;
const chartData = useMemo(
() => [
{ name: "Used", value: statfs.used, fill: "var(--strong-accent)" },
@ -93,8 +104,8 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
);
const usagePercentage = useMemo(() => {
return Math.round((statfs.used / statfs.total) * 100);
}, [statfs]);
return Math.round((used / total) * 100);
}, [used, total]);
return (
<ChartContainer config={{}} className="mx-auto aspect-square max-h-[200px]">
@ -114,10 +125,18 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-2xl font-bold">
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-2xl font-bold"
>
{usagePercentage}%
</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 20} className="fill-muted-foreground text-xs">
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 20}
className="fill-muted-foreground text-xs"
>
Used
</tspan>
</text>
@ -132,7 +151,9 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
}
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const hasStorage = statfs.total > 0;
const { total = 0, used = 0, free = 0 } = statfs;
const hasStorage = total > 0;
return (
<Card className="px-6 py-6 @container/inner">
@ -144,7 +165,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
</CardTitle>
<div className="space-y-0 divide-y divide-border/50">
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Name" value={volume.name} />
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Backend" value={backendLabels[volume.type]} />
<ConfigRow
icon={<HardDrive className="h-4 w-4" />}
label="Backend"
value={backendLabels[volume.type]}
/>
<BackendConfigRows volume={volume} />
</div>
</div>
@ -162,21 +187,21 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Total</span>
</div>
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
<ByteSize bytes={total} className="font-mono text-sm" />
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-strong-accent" />
<span className="text-sm font-medium">Used</span>
</div>
<ByteSize bytes={statfs.used} className="font-mono text-sm" />
<ByteSize bytes={used} className="font-mono text-sm" />
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-primary" />
<span className="text-sm font-medium">Free</span>
</div>
<ByteSize bytes={statfs.free} className="font-mono text-sm" />
<ByteSize bytes={free} className="font-mono text-sm" />
</div>
</div>
</div>

View file

@ -0,0 +1,17 @@
CREATE TABLE `passkey` (
`id` text PRIMARY KEY,
`name` text,
`public_key` text NOT NULL,
`user_id` text NOT NULL,
`credential_id` text NOT NULL,
`counter` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`aaguid` text,
CONSTRAINT `fk_passkey_user_id_users_table_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `passkey_userId_idx` ON `passkey` (`user_id`);--> statement-breakpoint
CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
ALTER TABLE `two_factor` ADD `verified` integer DEFAULT true NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
CREATE TABLE `tasks` (
`id` text PRIMARY KEY,
`organization_id` text NOT NULL,
`kind` text NOT NULL,
`status` text NOT NULL,
`resource_type` text NOT NULL,
`resource_id` text NOT NULL,
`target_agent_id` text,
`input` text NOT NULL,
`progress` text,
`result` text,
`error` text,
`cancellation_requested` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`started_at` integer,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`finished_at` integer,
CONSTRAINT `fk_tasks_organization_id_organization_id_fk` FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `tasks_org_kind_resource_status_idx` ON `tasks` (`organization_id`,`kind`,`resource_type`,`resource_id`,`status`);--> statement-breakpoint
CREATE INDEX `tasks_org_status_updated_at_idx` ON `tasks` (`organization_id`,`status`,`updated_at`);

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,13 @@
export const PASSKEY_LOGIN_FAILED_ERROR = "PASSKEY_LOGIN_FAILED";
export const LOGIN_ERROR_CODES = [
"ACCOUNT_LINK_REQUIRED",
"EMAIL_NOT_VERIFIED",
"INVITE_REQUIRED",
"BANNED_USER",
"SSO_LOGIN_FAILED",
PASSKEY_LOGIN_FAILED_ERROR,
"ERROR_INVALID_RP_ID",
] as const;
export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number];
@ -20,5 +24,9 @@ export function getLoginErrorDescription(errorCode: LoginErrorCode): string {
return "You have been banned from this application. Please contact support if you believe this is an error.";
case "SSO_LOGIN_FAILED":
return "SSO authentication failed. Please try again.";
case PASSKEY_LOGIN_FAILED_ERROR:
return "Passkey sign-in failed. The passkey didn't verify your identity with a PIN, biometrics, or screen lock. Please use a verified passkey or sign in with your password.";
case "ERROR_INVALID_RP_ID":
return "You can only sign in with a passkey on the domain set by the BASE_URL environment variable";
}
}

View file

@ -2,7 +2,7 @@ import { z } from "zod";
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "@zerobyte/core/restic";
const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
const restoreEventStatusSchema = z.enum(["success", "error"]);
const restoreEventStatusSchema = z.enum(["success", "error", "cancelled"]);
const backupEventBaseSchema = z.object({
scheduleId: z.string(),
@ -15,6 +15,7 @@ const organizationScopedSchema = z.object({
});
const restoreEventBaseSchema = z.object({
restoreId: z.string(),
repositoryId: z.string(),
snapshotId: z.string(),
});
@ -51,6 +52,8 @@ const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgress
const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
status: restoreEventStatusSchema,
error: z.string().optional(),
filesRestored: z.number().optional(),
filesSkipped: z.number().optional(),
});
const serverBackupStartedEventSchema = organizationScopedSchema.extend(backupStartedEventSchema.shape);

View file

@ -134,6 +134,37 @@ describe("RepositoryMutex", () => {
expect(repoMutex.isLocked(repoId)).toBe(false);
});
test("should reject aborted queued acquisitions only after waiter cleanup finishes", async () => {
const repoId = "abort-waits-for-cleanup";
const releaseHolder = await repoMutex.acquireExclusive(repoId, "holder");
const controller = new AbortController();
const waitingAcquisition = repoMutex.acquireShared(repoId, "waiter", controller.signal);
try {
await new Promise((resolve) => setTimeout(resolve, 50));
const waitersBeforeAbort = await db.query.repositoryLockWaitersTable.findMany({
where: { repositoryId: { eq: repoId } },
});
expect(waitersBeforeAbort.map((waiter) => waiter.operation)).toEqual(["waiter"]);
controller.abort(new Error("stop"));
await expect(waitingAcquisition).rejects.toThrow("stop");
const waitersAfterAbort = await db.query.repositoryLockWaitersTable.findMany({
where: { repositoryId: { eq: repoId } },
});
expect(waitersAfterAbort).toEqual([]);
expect(repoMutex.isLocked(repoId)).toBe(true);
} finally {
releaseHolder();
await db.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.repositoryId, repoId));
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
}
});
test("should allow concurrent shared locks", async () => {
const repoId = "concurrent-shared";
const release1 = await repoMutex.acquireShared(repoId, "op1");

View file

@ -18,6 +18,8 @@ const envSchema = z
MIGRATIONS_PATH: z.string().optional(),
APP_VERSION: z.string().default("dev"),
TRUSTED_ORIGINS: z.string().optional(),
PORTLESS_URL: z.string().optional(),
PORTLESS_TAILSCALE_URL: z.string().optional(),
TRUST_PROXY: z.string().default("false"),
DISABLE_RATE_LIMITING: z.string().default("false"),
APP_SECRET: z.preprocess((value) => (value === "" ? undefined : value), z.string().min(32).max(256).optional()),
@ -29,10 +31,24 @@ const envSchema = z
PROVISIONING_PATH: z.string().optional(),
})
.transform((s, ctx) => {
const baseUrl = unquote(s.BASE_URL);
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean).concat(baseUrl) ?? [baseUrl];
let baseUrl = unquote(s.BASE_URL);
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean) ?? [];
if (s.NODE_ENV === "development") {
if (s.PORTLESS_URL) {
trustedOrigins.push(unquote(s.PORTLESS_URL));
}
if (s.PORTLESS_TAILSCALE_URL) {
baseUrl = unquote(s.PORTLESS_TAILSCALE_URL);
trustedOrigins.push(baseUrl);
}
}
trustedOrigins.push(baseUrl);
const uniqueTrustedOrigins = Array.from(new Set(trustedOrigins));
const webhookAllowedOrigins = s.WEBHOOK_ALLOWED_ORIGINS?.split(",").map(unquote).filter(Boolean) ?? [];
const authOrigins = [baseUrl, ...trustedOrigins];
const authOrigins = [baseUrl, ...uniqueTrustedOrigins];
const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins);
let appSecret = s.APP_SECRET;
@ -108,7 +124,7 @@ const envSchema = z
port: s.PORT,
migrationsPath: s.MIGRATIONS_PATH,
appVersion: s.APP_VERSION,
trustedOrigins: trustedOrigins,
trustedOrigins: uniqueTrustedOrigins,
trustProxy: s.TRUST_PROXY === "true",
appSecret: appSecret ?? "",
baseUrl,

View file

@ -7,6 +7,7 @@ import {
type RepositoryLock,
type RepositoryLockWaiter,
} from "../db/schema";
import { Effect, Exit, Fiber, Schedule, Scope } from "effect";
type LockType = "shared" | "exclusive";
@ -33,59 +34,30 @@ const LOCK_HEARTBEAT_MS = 5_000;
const LOCK_POLL_MS = 250;
const LOCK_POLL_CLEANUP_MS = 5_000;
const REPOSITORY_MUTEX_INSTANCE = Symbol.for("zerobyte.repositoryMutex.instance");
function getRepositoryMutex() {
const globalObject = globalThis as typeof globalThis & Record<symbol, RepositoryMutex | undefined>;
const mutex = globalObject[REPOSITORY_MUTEX_INSTANCE];
if (mutex) return mutex;
const newMutex = new RepositoryMutex();
globalObject[REPOSITORY_MUTEX_INSTANCE] = newMutex;
return newMutex;
}
class RepositoryMutex {
private ownerId = `owner_${Bun.randomUUIDv7()}`;
private heartbeatTimers = new Map<string, ReturnType<typeof setInterval>>();
private nextPollCleanupAt = 0;
private generateLockId(): string {
return `lock_${Bun.randomUUIDv7()}`;
}
private abortReason(signal: AbortSignal) {
private abortReason(signal: AbortSignal): Error {
return signal.reason || new Error("Operation aborted");
}
private throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) {
throw this.abortReason(signal);
}
}
private releaseIfAborted(releaseLock: () => void, signal?: AbortSignal) {
if (!signal?.aborted) return;
releaseLock();
throw this.abortReason(signal);
}
private waitForPoll(signal?: AbortSignal) {
this.throwIfAborted(signal);
return new Promise<void>((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => settle(resolve), LOCK_POLL_MS);
const onAbort = () => {
settle(() => reject(this.abortReason(signal!)));
};
const cleanup = () => {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
};
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
cleanup();
callback();
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
private cleanupExpired(tx: RepositoryMutexTransaction, now: number) {
tx.delete(repositoryLocksTable).where(lte(repositoryLocksTable.expiresAt, now)).run();
tx.delete(repositoryLockWaitersTable).where(lte(repositoryLockWaitersTable.expiresAt, now)).run();
@ -181,43 +153,49 @@ class RepositoryMutex {
});
}
private tryAcquireImmediately(request: LockRequest, signal?: AbortSignal) {
const locks = this.tryAcquireManyRows([request]);
if (!locks || locks.length === 0) return null;
private tryAcquireImmediately(request: LockRequest) {
return Effect.gen(this, function* () {
const locks = this.tryAcquireManyRows([request]);
if (!locks || locks.length === 0) return null;
const [lock] = locks;
const releaseLock = this.createRelease(lock);
this.releaseIfAborted(releaseLock, signal);
return releaseLock;
const [lock] = locks;
return yield* this.createRelease(lock);
});
}
private createWaiter(request: LockRequest, waiterId: string) {
const now = Date.now();
db.transaction((tx) => {
this.cleanupExpired(tx, now);
tx.insert(repositoryLockWaitersTable)
.values({
id: waiterId,
repositoryId: request.repositoryId,
type: request.type,
operation: request.operation,
ownerId: this.ownerId,
requestedAt: now,
expiresAt: now + LOCK_LEASE_MS,
heartbeatAt: now,
})
.run();
return Effect.sync(() => {
const now = Date.now();
db.transaction((tx) => {
this.cleanupExpired(tx, now);
tx.insert(repositoryLockWaitersTable)
.values({
id: waiterId,
repositoryId: request.repositoryId,
type: request.type,
operation: request.operation,
ownerId: this.ownerId,
requestedAt: now,
expiresAt: now + LOCK_LEASE_MS,
heartbeatAt: now,
})
.run();
});
});
}
private deleteWaiter(waiterId: string) {
db.delete(repositoryLockWaitersTable)
.where(
and(eq(repositoryLockWaitersTable.id, waiterId), eq(repositoryLockWaitersTable.ownerId, this.ownerId)),
)
.run();
return Effect.sync(() =>
db
.delete(repositoryLockWaitersTable)
.where(
and(
eq(repositoryLockWaitersTable.id, waiterId),
eq(repositoryLockWaitersTable.ownerId, this.ownerId),
),
)
.run(),
);
}
private deleteWaiterRow(tx: RepositoryMutexTransaction, waiterId: string): void {
@ -293,97 +271,49 @@ class RepositoryMutex {
});
}
private async waitForQueuedLock(request: LockRequest, signal?: AbortSignal) {
this.throwIfAborted(signal);
private waitForQueuedLock(request: LockRequest) {
const waiterId = this.generateLockId();
this.createWaiter(request, waiterId);
this.startHeartbeat("waiter", waiterId);
try {
while (true) {
this.throwIfAborted(signal);
const attempt = this.tryPromoteWaiter(waiterId);
const attempt = Effect.sync(() => this.tryPromoteWaiter(waiterId)).pipe(
Effect.flatMap((attempt) => {
if (attempt.status === "acquired") {
this.stopHeartbeat(waiterId);
const releaseLock = this.createRelease(attempt.lock);
this.releaseIfAborted(releaseLock, signal);
return releaseLock;
return Effect.succeed(attempt.lock);
}
if (attempt.status === "missing") {
this.createWaiter(request, waiterId);
this.startHeartbeat("waiter", waiterId);
return Effect.gen(this, function* () {
yield* this.createWaiter(request, waiterId);
yield* this.startHeartbeat("waiter", waiterId);
return yield* Effect.fail("retry");
});
}
await this.waitForPoll(signal);
}
} catch (error) {
this.stopHeartbeat(waiterId);
this.deleteWaiter(waiterId);
this.release({ id: waiterId });
throw error;
}
}
return Effect.fail("retry");
}),
);
async acquireShared(repositoryId: string, operation: string, signal?: AbortSignal) {
this.throwIfAborted(signal);
const cleanupAbandonedWaiter = Effect.gen(this, function* () {
yield* this.deleteWaiter(waiterId);
yield* this.release({ id: waiterId });
});
const request: LockRequest = { repositoryId, type: "shared", operation };
const releaseLock = this.tryAcquireImmediately(request, signal);
if (releaseLock) {
return releaseLock;
}
return Effect.scoped(
Effect.gen(this, function* () {
const lock = yield* attempt.pipe(
Effect.retry(Schedule.spaced(LOCK_POLL_MS)),
Effect.onExit((exit) => {
if (Exit.isSuccess(exit)) {
return Effect.void;
}
logger.debug(`[Mutex] Waiting for shared lock on repo ${repositoryId}: ${operation}`);
return await this.waitForQueuedLock(request, signal);
}
return cleanupAbandonedWaiter;
}),
);
async acquireExclusive(repositoryId: string, operation: string, signal?: AbortSignal) {
this.throwIfAborted(signal);
const request: LockRequest = { repositoryId, type: "exclusive", operation };
const releaseLock = this.tryAcquireImmediately(request, signal);
if (releaseLock) {
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation}`);
return releaseLock;
}
logger.debug(`[Mutex] Waiting for exclusive lock on repo ${repositoryId}: ${operation}`);
const queuedReleaseLock = await this.waitForQueuedLock(request, signal);
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation}`);
return queuedReleaseLock;
}
async acquireMany(requests: LockRequest[], signal?: AbortSignal) {
this.throwIfAborted(signal);
if (requests.length === 0) {
return () => {};
}
const seenRepositoryIds = new Set<string>();
for (const request of requests) {
if (seenRepositoryIds.has(request.repositoryId)) {
throw new Error(`Duplicate repository lock request: ${request.repositoryId}`);
}
seenRepositoryIds.add(request.repositoryId);
}
const sortedRequests = [...requests].sort((a, b) => a.repositoryId.localeCompare(b.repositoryId));
while (true) {
const locks = this.tryAcquireManyRows(sortedRequests);
if (locks) {
const releaseLocks = this.createReleaseMany(locks);
this.releaseIfAborted(releaseLocks, signal);
return releaseLocks;
}
await this.waitForPoll(signal);
}
return yield* this.createRelease(lock);
}),
);
}
isLocked(repositoryId: string) {
@ -396,69 +326,91 @@ class RepositoryMutex {
}
private createReleaseMany(locks: AcquiredLock[]) {
const releases = locks.map((lock) => this.createRelease(lock));
let released = false;
return Effect.gen(this, function* () {
const releases = yield* Effect.all(locks.map((lock) => this.createRelease(lock)));
let released = false;
return () => {
if (released) return;
return () => {
if (released) return;
released = true;
for (const release of releases.toReversed()) {
release();
}
};
released = true;
for (const release of releases.toReversed()) {
release();
}
};
});
}
private createRelease(lock: AcquiredLock) {
this.startHeartbeat("lock", lock.id);
let released = false;
return Effect.gen(this, function* () {
const heartbeatFiber = yield* this.startHeartbeat("lock", lock.id);
let released = false;
return () => {
if (released) return;
return () => {
if (released) return;
released = true;
this.stopHeartbeat(lock.id);
this.release(lock);
};
released = true;
Effect.runFork(Fiber.interrupt(heartbeatFiber));
Effect.runSync(this.release(lock));
};
});
}
private release(lock: Pick<AcquiredLock, "id">) {
const releasedLock = db.transaction((tx) => {
const row = tx.query.repositoryLocksTable
.findFirst({ where: { AND: [{ id: { eq: lock.id } }, { ownerId: { eq: this.ownerId } }] } })
.sync();
return Effect.gen(this, function* () {
const releasedLock = yield* Effect.sync(() =>
db.transaction((tx) => {
const row = tx.query.repositoryLocksTable
.findFirst({ where: { AND: [{ id: { eq: lock.id } }, { ownerId: { eq: this.ownerId } }] } })
.sync();
if (!row) return null;
if (!row) return null;
tx.delete(repositoryLocksTable)
.where(and(eq(repositoryLocksTable.id, lock.id), eq(repositoryLocksTable.ownerId, this.ownerId)))
.run();
tx.delete(repositoryLocksTable)
.where(
and(eq(repositoryLocksTable.id, lock.id), eq(repositoryLocksTable.ownerId, this.ownerId)),
)
.run();
return row;
return row;
}),
);
if (!releasedLock) return;
const duration = Date.now() - releasedLock.acquiredAt;
yield* logger.effect.debug(
`[Mutex] Released ${releasedLock.type} lock for repo ${releasedLock.repositoryId}: ${releasedLock.operation} (held for ${duration}ms)`,
);
});
if (!releasedLock) return;
const duration = Date.now() - releasedLock.acquiredAt;
logger.debug(
`[Mutex] Released ${releasedLock.type} lock for repo ${releasedLock.repositoryId}: ${releasedLock.operation} (held for ${duration}ms)`,
);
}
private startHeartbeat(target: HeartbeatTarget, lockId: string) {
this.stopHeartbeat(lockId);
const heartbeat = () => {
private startHeartbeat(
target: "waiter",
lockId: string,
): Effect.Effect<Fiber.RuntimeFiber<void, never>, never, Scope.Scope>;
private startHeartbeat(
target: "lock",
lockId: string,
): Effect.Effect<Fiber.RuntimeFiber<void, never>, never, never>;
private startHeartbeat(
target: HeartbeatTarget,
lockId: string,
): Effect.Effect<Fiber.RuntimeFiber<unknown, never>, never, Scope.Scope> {
const heartbeat = Effect.gen(this, function* () {
const now = Date.now();
const values = { heartbeatAt: now, expiresAt: now + LOCK_LEASE_MS };
try {
if (target === "lock") {
if (target === "lock") {
yield* Effect.try(() => {
db.update(repositoryLocksTable)
.set(values)
.where(and(eq(repositoryLocksTable.id, lockId), eq(repositoryLocksTable.ownerId, this.ownerId)))
.run();
} else {
});
} else {
yield* Effect.try(() => {
db.update(repositoryLockWaitersTable)
.set(values)
.where(
@ -468,29 +420,142 @@ class RepositoryMutex {
),
)
.run();
}
} catch (error) {
logger.warn(`[Mutex] Failed to heartbeat ${target} ${lockId}: ${String(error)}`);
});
}
};
}).pipe(
Effect.catchAll((error) =>
logger.effect.warn(`[Mutex] Failed to heartbeat ${target} ${lockId}: ${String(error)}`),
),
);
const timer = setInterval(heartbeat, LOCK_HEARTBEAT_MS);
if (timer && "unref" in timer) {
timer.unref();
const repeat = heartbeat.pipe(Effect.repeat(Schedule.spaced(LOCK_HEARTBEAT_MS)));
if (target === "waiter") {
// For waiters, we can stop heartbeating when the releaser is dropped, so we use a scoped fiber
return repeat.pipe(Effect.forkScoped);
}
this.heartbeatTimers.set(lockId, timer);
// For locks, the heartbeat must outlive the acquire scope.
// It is interrupted manually by the returned release function.
// TODO: max lifetime for lock heartbeats to prevent leaks if the releaser is never called?
return repeat.pipe(Effect.forkDaemon);
}
private stopHeartbeat(lockId: string) {
const timer = this.heartbeatTimers.get(lockId);
if (!timer) {
return;
private acquireSharedEffect(repositoryId: string, operation: string) {
return Effect.gen(this, function* () {
const request: LockRequest = { repositoryId, type: "shared", operation };
const releaseLock = yield* this.tryAcquireImmediately(request);
if (releaseLock) return releaseLock;
yield* logger.effect.debug(`[Mutex] Waiting for shared lock on repo ${repositoryId}: ${operation}`);
return yield* this.waitForQueuedLock(request);
});
}
private acquireExclusiveEffect(repositoryId: string, operation: string) {
return Effect.gen(this, function* () {
const request: LockRequest = { repositoryId, type: "exclusive", operation };
const releaseLock = yield* this.tryAcquireImmediately(request);
if (releaseLock) {
yield* logger.effect.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation}`);
return releaseLock;
}
yield* logger.effect.debug(`[Mutex] Waiting for exclusive lock on repo ${repositoryId}: ${operation}`);
const queuedReleaseLock = yield* this.waitForQueuedLock(request);
yield* logger.effect.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation}`);
return queuedReleaseLock;
});
}
private acquireManyEffect(requests: LockRequest[]) {
return Effect.gen(this, function* () {
if (requests.length === 0) {
return () => {};
}
const seenRepositoryIds = new Set<string>();
for (const request of requests) {
if (seenRepositoryIds.has(request.repositoryId)) {
throw new Error(`Duplicate repository lock request: ${request.repositoryId}`);
}
seenRepositoryIds.add(request.repositoryId);
}
const sortedRequests = [...requests].sort((a, b) => a.repositoryId.localeCompare(b.repositoryId));
const locks = yield* Effect.sync(() => this.tryAcquireManyRows(sortedRequests)).pipe(
Effect.flatMap((locks) => {
if (locks) return Effect.succeed(locks);
return Effect.fail("retry");
}),
Effect.retry(Schedule.spaced(LOCK_POLL_MS)),
);
return yield* this.createReleaseMany(locks);
});
}
private runWithSignal<A, E>(effect: Effect.Effect<A, E>, signal?: AbortSignal) {
if (!signal) return Effect.runPromise(effect);
if (signal.aborted) {
return Promise.reject(this.abortReason(signal));
}
clearInterval(timer);
this.heartbeatTimers.delete(lockId);
return new Promise<A>((resolve, reject) => {
const fiber = Effect.runFork(effect);
let settled = false;
let aborting = false;
const complete = (callback: () => void) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
callback();
};
const onAbort = () => {
aborting = true;
Effect.runPromise(Fiber.interrupt(fiber)).then(
(exit) =>
complete(() => {
if (Exit.isSuccess(exit)) {
resolve(exit.value);
return;
}
reject(this.abortReason(signal));
}),
(error) => complete(() => reject(error)),
);
};
signal.addEventListener("abort", onAbort, { once: true });
Effect.runPromise(Fiber.join(fiber)).then(
(value) => complete(() => resolve(value)),
(error) => {
if (!aborting) {
complete(() => reject(error));
}
},
);
});
}
async acquireShared(repositoryId: string, operation: string, signal?: AbortSignal) {
return await this.runWithSignal(this.acquireSharedEffect(repositoryId, operation), signal);
}
async acquireExclusive(repositoryId: string, operation: string, signal?: AbortSignal) {
return await this.runWithSignal(this.acquireExclusiveEffect(repositoryId, operation), signal);
}
async acquireMany(requests: LockRequest[], signal?: AbortSignal) {
return await this.runWithSignal(this.acquireManyEffect(requests), signal);
}
}
export const repoMutex = new RepositoryMutex();
export const repoMutex = getRepositoryMutex();

View file

@ -76,6 +76,7 @@ export const relations = defineRelations(schema, (r) => ({
sessions: r.many.sessionsTable(),
members: r.many.member(),
twoFactors: r.many.twoFactor(),
passkeys: r.many.passkey(),
ssoProviders: r.many.ssoProvider(),
organizations: r.many.organization({
from: r.usersTable.id.through(r.member.userId),
@ -95,6 +96,12 @@ export const relations = defineRelations(schema, (r) => ({
to: r.usersTable.id,
}),
},
passkey: {
usersTable: r.one.usersTable({
from: r.passkey.userId,
to: r.usersTable.id,
}),
},
organization: {
users: r.many.usersTable({
alias: "usersTable_id_organization_id_via_member",

View file

@ -352,6 +352,48 @@ export const repositoryLockWaitersTable = sqliteTable(
);
export type RepositoryLockWaiter = typeof repositoryLockWaitersTable.$inferSelect;
type TaskJson = Record<string, unknown>;
export const tasksTable = sqliteTable(
"tasks",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
kind: text("kind").notNull(),
status: text("status").notNull(),
resourceType: text("resource_type").notNull(),
resourceId: text("resource_id").notNull(),
targetAgentId: text("target_agent_id"),
input: text("input", { mode: "json" }).$type<TaskJson>().notNull(),
progress: text("progress", { mode: "json" }).$type<TaskJson | null>(),
result: text("result", { mode: "json" }).$type<TaskJson | null>(),
error: text("error"),
cancellationRequested: int("cancellation_requested", { mode: "boolean" }).notNull().default(false),
createdAt: int("created_at", { mode: "number" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
startedAt: int("started_at", { mode: "number" }),
updatedAt: int("updated_at", { mode: "number" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
finishedAt: int("finished_at", { mode: "number" }),
},
(table) => [
index("tasks_org_kind_resource_status_idx").on(
table.organizationId,
table.kind,
table.resourceType,
table.resourceId,
table.status,
),
index("tasks_org_status_updated_at_idx").on(table.organizationId, table.status, table.updatedAt),
],
);
export type Task = typeof tasksTable.$inferSelect;
export type TaskInsert = typeof tasksTable.$inferInsert;
/**
* Backup Schedules Table
*/
@ -505,6 +547,30 @@ export const twoFactor = sqliteTable(
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
verified: integer("verified", { mode: "boolean" }).notNull().default(true),
},
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
);
export const passkey = sqliteTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
credentialID: text("credential_id").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"),
createdAt: int("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
aaguid: text("aaguid"),
},
(table) => [index("passkey_userId_idx").on(table.userId), index("passkey_credentialID_idx").on(table.credentialID)],
);
export type Passkey = typeof passkey.$inferSelect;

View file

@ -8,6 +8,7 @@ import {
import { APIError } from "better-auth/api";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";
import { createAuthMiddleware } from "better-auth/api";
import { config } from "../core/config";
import { db } from "../db/db";
@ -170,6 +171,26 @@ export const auth = betterAuth({
amount: 5,
},
}),
passkey({
rpID: new URL(config.baseUrl).hostname,
rpName: "Zerobyte",
authenticatorSelection: {
userVerification: "required",
residentKey: "required",
},
authentication: {
afterVerification: async ({ verification }) => {
if (verification.authenticationInfo.userVerified) {
return;
}
throw new APIError("UNAUTHORIZED", {
message:
"Your passkey was accepted, but it did not confirm your identity with a PIN, biometrics, or screen lock. Please use a verified passkey or sign in with your password.",
});
},
},
}),
tanstackStartCookies(),
...(process.env.NODE_ENV === "test" ? [testUtils()] : []),
],

View file

@ -0,0 +1,6 @@
import { createServerFn } from "@tanstack/react-start";
import { hasActivePasskeyUser } from "~/server/modules/auth/helpers";
export const getLoginOptions = createServerFn({ method: "GET" }).handler(async () => ({
hasPasskeySignIn: await hasActivePasskeyUser(),
}));

View file

@ -1,7 +1,7 @@
import { afterEach, expect, test, vi } from "vitest";
import { Effect } from "effect";
import { fromAny, fromPartial } from "@total-typescript/shoehorn";
import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
import type { BackupRunPayload, RestoreRunPayload } from "@zerobyte/contracts/agent-protocol";
import type { AgentManagerEvent } from "../controller/server";
import type { ProcessWithAgentRuntime } from "../agents-manager";
@ -9,6 +9,8 @@ const controllerMock = vi.hoisted(() => ({
onEvent: null as null | ((event: AgentManagerEvent) => void),
sendBackup: vi.fn(),
cancelBackup: vi.fn(),
sendRestore: vi.fn(),
cancelRestore: vi.fn(),
stop: vi.fn(),
}));
@ -22,6 +24,8 @@ vi.mock("../controller/server", async () => {
stop: Effect.sync(controllerMock.stop),
sendBackup: controllerMock.sendBackup,
cancelBackup: controllerMock.cancelBackup,
sendRestore: controllerMock.sendRestore,
cancelRestore: controllerMock.cancelRestore,
};
}),
};
@ -37,6 +41,7 @@ const resetAgentRuntime = () => {
localAgentRestartTimeout: null,
activeBackupsByScheduleId: new Map(),
activeBackupScheduleIdsByJobId: new Map(),
activeRestoresByRestoreId: new Map(),
};
};
@ -44,12 +49,17 @@ const backupPayload = fromPartial<BackupRunPayload>({
jobId: "job-1",
scheduleId: "schedule-1",
});
const restorePayload = fromPartial<RestoreRunPayload>({
restoreId: "restore-1",
});
afterEach(() => {
delete processWithAgentRuntime.__zerobyteAgentRuntime;
controllerMock.onEvent = null;
controllerMock.sendBackup.mockReset();
controllerMock.cancelBackup.mockReset();
controllerMock.sendRestore.mockReset();
controllerMock.cancelRestore.mockReset();
controllerMock.stop.mockReset();
vi.resetModules();
vi.restoreAllMocks();
@ -73,7 +83,11 @@ test("backup progress is delivered to the running backup callback", async () =>
type: "backup.progress",
agentId: "local",
agentName: "Local Agent",
payload: fromAny({ jobId: "job-1", scheduleId: "schedule-1", progress: { percentDone: 0.5 } }),
payload: fromAny({
jobId: "job-1",
scheduleId: "schedule-1",
progress: { percentDone: 0.5 },
}),
});
controllerMock.onEvent?.({
type: "backup.completed",
@ -92,6 +106,41 @@ test("backup progress is delivered to the running backup callback", async () =>
await stopAgentController();
});
test("backup events from agents that do not own the active run are ignored", async () => {
resetAgentRuntime();
controllerMock.sendBackup.mockImplementation(() => Effect.succeed(true));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
await startAgentController();
const resultPromise = agentManager.runBackup("local", {
scheduleId: 42,
payload: backupPayload,
signal: new AbortController().signal,
onProgress: vi.fn(),
});
controllerMock.onEvent?.({
type: "backup.completed",
agentId: "remote",
agentName: "Remote Agent",
payload: { jobId: "job-1", scheduleId: "schedule-1", exitCode: 0, result: null },
});
controllerMock.onEvent?.({
type: "backup.completed",
agentId: "local",
agentName: "Local Agent",
payload: { jobId: "job-1", scheduleId: "schedule-1", exitCode: 0, result: null },
});
await expect(resultPromise).resolves.toEqual({
status: "completed",
exitCode: 0,
result: null,
warningDetails: null,
});
await stopAgentController();
});
test("backup failed and cancelled events resolve the matching running backup", async () => {
resetAgentRuntime();
controllerMock.sendBackup.mockImplementation(() => Effect.succeed(true));
@ -108,7 +157,12 @@ test("backup failed and cancelled events resolve the matching running backup", a
type: "backup.failed",
agentId: "local",
agentName: "Local Agent",
payload: { jobId: "job-1", scheduleId: "schedule-1", error: "failed", errorDetails: "restic failed" },
payload: {
jobId: "job-1",
scheduleId: "schedule-1",
error: "failed",
errorDetails: "restic failed",
},
});
await expect(failedPromise).resolves.toEqual({ status: "failed", error: "restic failed" });
@ -124,7 +178,10 @@ test("backup failed and cancelled events resolve the matching running backup", a
agentName: "Local Agent",
payload: { jobId: "job-2", scheduleId: "schedule-2", message: "cancelled remotely" },
});
await expect(cancelledPromise).resolves.toEqual({ status: "cancelled", message: "cancelled remotely" });
await expect(cancelledPromise).resolves.toEqual({
status: "cancelled",
message: "cancelled remotely",
});
await stopAgentController();
});
@ -147,7 +204,11 @@ test("agent disconnect cancels only backups owned by that agent", async () => {
onProgress: vi.fn(),
});
controllerMock.onEvent?.({ type: "agent.disconnected", agentId: "local", agentName: "Local Agent" });
controllerMock.onEvent?.({
type: "agent.disconnected",
agentId: "local",
agentName: "Local Agent",
});
controllerMock.onEvent?.({
type: "backup.completed",
agentId: "remote",
@ -230,6 +291,117 @@ test("runBackup requests cancellation when the abort signal fires while sending"
});
expect(result).toEqual({ status: "cancelled" });
expect(controllerMock.cancelBackup).toHaveBeenCalledWith("local", { jobId: "job-1", scheduleId: "schedule-1" });
expect(controllerMock.cancelBackup).toHaveBeenCalledWith("local", {
jobId: "job-1",
scheduleId: "schedule-1",
});
await stopAgentController();
});
test("restore events are delivered to the running restore callbacks", async () => {
resetAgentRuntime();
controllerMock.sendRestore.mockImplementation(() => Effect.succeed(true));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
const onStarted = vi.fn();
const onProgress = vi.fn();
await startAgentController();
const started = await agentManager.startRestore("local", {
payload: restorePayload,
signal: new AbortController().signal,
onStarted,
onProgress,
});
if (started.status !== "started") {
throw new Error("Expected restore to start");
}
controllerMock.onEvent?.({
type: "restore.started",
agentId: "local",
agentName: "Local Agent",
payload: {
restoreId: "restore-1",
organizationId: "org-1",
repositoryId: "repo-1",
snapshotId: "snapshot-1",
},
});
controllerMock.onEvent?.({
type: "restore.progress",
agentId: "local",
agentName: "Local Agent",
payload: fromAny({ restoreId: "restore-1", progress: { percent_done: 0.5 } }),
});
controllerMock.onEvent?.({
type: "restore.completed",
agentId: "local",
agentName: "Local Agent",
payload: {
restoreId: "restore-1",
organizationId: "org-1",
repositoryId: "repo-1",
snapshotId: "snapshot-1",
result: { message_type: "summary", files_restored: 2, files_skipped: 1 },
},
});
await expect(started.result).resolves.toEqual({
status: "completed",
result: { message_type: "summary", files_restored: 2, files_skipped: 1 },
});
expect(onStarted).toHaveBeenCalledOnce();
expect(onProgress).toHaveBeenCalledWith({ percent_done: 0.5 });
await stopAgentController();
});
test("agent disconnect cancels only restores owned by that agent", async () => {
resetAgentRuntime();
controllerMock.sendRestore.mockImplementation(() => Effect.succeed(true));
const { agentManager, startAgentController, stopAgentController } = await import("../agents-manager");
await startAgentController();
const localStarted = await agentManager.startRestore("local", {
payload: restorePayload,
signal: new AbortController().signal,
onStarted: vi.fn(),
onProgress: vi.fn(),
});
const remoteStarted = await agentManager.startRestore("remote", {
payload: fromPartial<RestoreRunPayload>({ restoreId: "restore-2" }),
signal: new AbortController().signal,
onStarted: vi.fn(),
onProgress: vi.fn(),
});
if (localStarted.status !== "started" || remoteStarted.status !== "started") {
throw new Error("Expected restores to start");
}
controllerMock.onEvent?.({
type: "agent.disconnected",
agentId: "local",
agentName: "Local Agent",
});
controllerMock.onEvent?.({
type: "restore.completed",
agentId: "remote",
agentName: "Remote Agent",
payload: {
restoreId: "restore-2",
organizationId: "org-1",
repositoryId: "repo-1",
snapshotId: "snapshot-1",
result: { message_type: "summary", files_restored: 1, files_skipped: 0 },
},
});
await expect(localStarted.result).resolves.toEqual({
status: "cancelled",
message: "The connection to the restore agent was lost. Restart the restore to ensure it completes.",
});
await expect(remoteStarted.result).resolves.toEqual({
status: "completed",
result: { message_type: "summary", files_restored: 1, files_skipped: 0 },
});
await stopAgentController();
});

View file

@ -22,7 +22,11 @@ const processWithAgentRuntime = process as ProcessWithAgentRuntime;
const setAgentRuntime = () => {
processWithAgentRuntime.__zerobyteAgentRuntime = {
agentManager: fromAny({ stop: Effect.void, waitForAgentReady: vi.fn(async () => true) }),
agentManager: fromAny({
stop: Effect.void,
getControllerUrl: vi.fn(() => "ws://127.0.0.1:4567"),
waitForAgentReady: vi.fn(async () => true),
}),
localAgent: null,
isStoppingLocalAgent: false,
localAgentRestartTimeout: null,
@ -88,6 +92,13 @@ test("respawns the local agent after an unexpected exit", async () => {
await vi.advanceTimersByTimeAsync(1_000);
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock).toHaveBeenLastCalledWith(
"bun",
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({ ZEROBYTE_CONTROLLER_URL: "ws://127.0.0.1:4567" }),
}),
);
});
test("does not respawn the local agent after an intentional stop", async () => {

View file

@ -111,7 +111,7 @@ afterEach(() => {
test("websocket fetch rejects requests without a bearer token", async () => {
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
.mockReturnValue(fromPartial({ port: 4567, stop: vi.fn(() => Promise.resolve()) }));
const { runtime } = await startRuntime();
const fetch = serve.mock.calls[0]?.[0].fetch;
const upgrade = vi.fn();
@ -120,6 +120,7 @@ test("websocket fetch rejects requests without a bearer token", async () => {
const response = await invokeFetch(fetch, new Request("http://localhost:3001/agent"), srv);
await Effect.runPromise(runtime.stop);
expect(runtime.getControllerUrl()).toBeNull();
expect(response?.status).toBe(401);
expect(await response?.text()).toBe("Missing token");
expect(upgrade).not.toHaveBeenCalled();
@ -129,8 +130,9 @@ test("websocket fetch rejects invalid bearer tokens", async () => {
tokenMocks.validateAgentToken.mockResolvedValue(undefined);
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
.mockReturnValue(fromPartial({ port: 4567, stop: vi.fn(() => Promise.resolve()) }));
const { runtime } = await startRuntime();
expect(runtime.getControllerUrl()).toBe("ws://127.0.0.1:4567");
const fetch = serve.mock.calls[0]?.[0].fetch;
const upgrade = vi.fn();
const srv = fromPartial<Parameters<NonNullable<typeof fetch>>[1]>({ upgrade });
@ -205,6 +207,7 @@ test("websocket lifecycle updates agent connection status", async () => {
expect(agentsServiceMocks.markAgentOnline).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number), {
backup: true,
protocolVersion: 1,
protocolCompatible: true,
hostname: "host",
platform: "linux",
});
@ -213,6 +216,73 @@ test("websocket lifecycle updates agent connection status", async () => {
expect(stop).toHaveBeenCalledWith(true);
});
test("websocket protocol rejection forwards the event and closes the connection", async () => {
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
const { runtime, onEvent } = await startRuntime(vi.fn());
const websocket = serve.mock.calls[0]?.[0].websocket;
const socket = createSocket("connection-1");
await websocket?.open?.(fromPartial(socket));
await websocket?.message?.(
fromPartial(socket),
JSON.stringify({
type: "agent.ready",
payload: {
protocolVersion: 2,
hostname: "host",
platform: "linux",
},
}),
);
await Effect.runPromise(runtime.stop);
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: "agent.protocolRejected",
agentId: LOCAL_AGENT_ID,
agentName: LOCAL_AGENT_NAME,
payload: expect.objectContaining({ reason: "agent_too_new" }),
}),
);
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledWith(LOCAL_AGENT_ID);
expect(socket.close).toHaveBeenCalledWith(1002, "agent_too_new");
});
test("websocket restore events are forwarded with agent metadata", async () => {
const serve = vi
.spyOn(Bun, "serve")
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
const { runtime, onEvent } = await startRuntime(vi.fn());
const websocket = serve.mock.calls[0]?.[0].websocket;
const socket = createSocket("connection-1");
await websocket?.open?.(fromPartial(socket));
await websocket?.message?.(fromPartial(socket), createAgentMessage("agent.ready", readyPayload));
onEvent.mockClear();
await websocket?.message?.(
fromPartial(socket),
createAgentMessage("restore.completed", {
restoreId: "restore-1",
organizationId: "org-1",
repositoryId: "repo-1",
snapshotId: "snapshot-1",
result: { message_type: "summary", files_restored: 2, files_skipped: 0 },
}),
);
await Effect.runPromise(runtime.stop);
expect(onEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: "restore.completed",
agentId: LOCAL_AGENT_ID,
agentName: LOCAL_AGENT_NAME,
payload: expect.objectContaining({ restoreId: "restore-1" }),
}),
);
});
test("websocket open failure closes the upgraded socket", async () => {
agentsServiceMocks.markAgentConnecting.mockRejectedValueOnce(new Error("db unavailable"));
const serve = vi

View file

@ -2,7 +2,11 @@ import { Effect, Exit, Fiber, Scope } from "effect";
import { expect, test, vi } from "vitest";
import waitForExpect from "wait-for-expect";
import { fromPartial } from "@total-typescript/shoehorn";
import { createAgentMessage, type AgentMessage } from "@zerobyte/contracts/agent-protocol";
import {
createAgentMessage,
SUPPORTED_AGENT_PROTOCOL_MAX_VERSION,
type AgentMessage,
} from "@zerobyte/contracts/agent-protocol";
import type { Volume } from "@zerobyte/contracts/volumes";
import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
import { createControllerAgentSession } from "../controller/session";
@ -133,6 +137,19 @@ test("invalid inbound messages are ignored", () => {
const onEvent = vi.fn(() => Effect.void);
const { session, close } = createSession(onEvent);
Effect.runSync(
session.handleMessage(
createAgentMessage("agent.ready", {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
}),
),
);
onEvent.mockClear();
Effect.runSync(session.handleMessage("not json"));
Effect.runSync(session.handleMessage(JSON.stringify({ type: "backup.progress", payload: {} })));
@ -193,6 +210,19 @@ test("backup agent messages are forwarded unchanged", () => {
},
} satisfies Extract<AgentMessage, { type: "backup.progress" }>;
Effect.runSync(
session.handleMessage(
createAgentMessage("agent.ready", {
agentId: LOCAL_AGENT_ID,
protocolVersion: 1,
hostname: "host",
platform: "linux",
capabilities: { backup: true },
}),
),
);
onEvent.mockClear();
Effect.runSync(session.handleMessage(createAgentMessage(message.type, message.payload)));
expect(onEvent).toHaveBeenCalledWith(
@ -208,6 +238,53 @@ test("backup agent messages are forwarded unchanged", () => {
close();
});
test("unsupported agent protocol rejects startup and closes the session", () => {
const onEvent = vi.fn(() => Effect.void);
const { session, socket } = createSession(onEvent);
Effect.runSync(
session.handleMessage(
JSON.stringify({
type: "agent.ready",
payload: {
protocolVersion: SUPPORTED_AGENT_PROTOCOL_MAX_VERSION + 1,
hostname: "host",
platform: "linux",
},
}),
),
);
expect(Effect.runSync(session.isReady())).toBe(false);
expect(onEvent).toHaveBeenCalledWith({
type: "agent.protocolRejected",
payload: expect.objectContaining({
reason: "agent_too_new",
protocolVersion: SUPPORTED_AGENT_PROTOCOL_MAX_VERSION + 1,
hostname: "host",
platform: "linux",
}),
});
expect(socket.close).toHaveBeenCalledWith(1002, "agent_too_new");
});
test("pre-ready non-ready messages reject startup and close the session", () => {
const onEvent = vi.fn(() => Effect.void);
const { session, socket } = createSession(onEvent);
Effect.runSync(session.handleMessage(createAgentMessage("heartbeat.pong", { sentAt: 123 })));
expect(Effect.runSync(session.isReady())).toBe(false);
expect(onEvent).toHaveBeenCalledWith({
type: "agent.protocolRejected",
payload: expect.objectContaining({
reason: "unexpected_startup_message",
messageType: "heartbeat.pong",
}),
});
expect(socket.close).toHaveBeenCalledWith(1002, "unexpected_startup_message");
});
test("a dropped backup.cancel closes the session and reports a transport disconnect", async () => {
const send = vi.fn(() => 0);
const socket = createSocket({ send, close: vi.fn() });

View file

@ -1,5 +1,10 @@
import { logger } from "@zerobyte/core/node";
import type { BackupRunPayload, VolumeCommand, VolumeCommandResult } from "@zerobyte/contracts/agent-protocol";
import type {
BackupRunPayload,
RestoreRunPayload,
VolumeCommand,
VolumeCommandResult,
} from "@zerobyte/contracts/agent-protocol";
import { Effect } from "effect";
import { config } from "../../core/config";
import { createAgentManagerRuntime, type AgentManagerEvent } from "./controller/server";
@ -10,9 +15,16 @@ import {
type AgentRuntimeState,
type BackupExecutionProgress,
type BackupExecutionResult,
type RestoreExecutionProgress,
type RestoreExecutionResult,
} from "./helpers/runtime-state";
import { getDevAgentRuntimeState } from "./helpers/runtime-state.dev";
export type { BackupExecutionProgress, BackupExecutionResult } from "./helpers/runtime-state";
export type {
BackupExecutionProgress,
BackupExecutionResult,
RestoreExecutionProgress,
RestoreExecutionResult,
} from "./helpers/runtime-state";
export type { ProcessWithAgentRuntime } from "./helpers/runtime-state.dev";
type ProcessWithProductionAgentRuntime = NodeJS.Process & {
@ -26,6 +38,17 @@ type AgentRunBackupRequest = {
onProgress: (progress: BackupExecutionProgress) => void;
};
type AgentStartRestoreRequest = {
payload: RestoreRunPayload;
signal: AbortSignal;
onStarted: () => void;
onProgress: (progress: RestoreExecutionProgress) => void;
};
type AgentRestoreStartResult =
| { status: "started"; result: Promise<RestoreExecutionResult> }
| { status: "unavailable"; error: Error };
const getProductionAgentRuntimeState = () => {
// Nitro production builds can bundle startup plugins and API handlers into separate chunks.
// Keep the live controller on process so both chunks see the same agent sessions.
@ -41,6 +64,7 @@ const getAgentRuntimeState = () => (config.__prod__ ? getProductionAgentRuntimeS
const getAgentManagerRuntime = () => getAgentRuntimeState().agentManager;
const getActiveBackupsByScheduleId = () => getAgentRuntimeState().activeBackupsByScheduleId;
const getActiveBackupScheduleIdsByJobId = () => getAgentRuntimeState().activeBackupScheduleIdsByJobId;
const getActiveRestoresByRestoreId = () => getAgentRuntimeState().activeRestoresByRestoreId;
const clearActiveBackupRun = (scheduleId: number) => {
const activeBackupsByScheduleId = getActiveBackupsByScheduleId();
@ -65,6 +89,27 @@ const resolveActiveBackupRun = (scheduleId: number, result: BackupExecutionResul
return true;
};
const clearActiveRestoreRun = (restoreId: string) => {
const activeRestoresByRestoreId = getActiveRestoresByRestoreId();
const activeRestoreRun = activeRestoresByRestoreId.get(restoreId);
if (!activeRestoreRun) {
return null;
}
activeRestoresByRestoreId.delete(restoreId);
return activeRestoreRun;
};
const resolveActiveRestoreRun = (restoreId: string, result: RestoreExecutionResult) => {
const activeRestoreRun = clearActiveRestoreRun(restoreId);
if (!activeRestoreRun) {
return false;
}
activeRestoreRun.resolve(result);
return true;
};
const cancelActiveBackupRunsForAgent = (agentId: string, message: string) => {
const activeBackupsByScheduleId = getActiveBackupsByScheduleId();
const matchingScheduleIds = [...activeBackupsByScheduleId.values()]
@ -76,6 +121,17 @@ const cancelActiveBackupRunsForAgent = (agentId: string, message: string) => {
}
};
const cancelActiveRestoreRunsForAgent = (agentId: string, message: string) => {
const activeRestoresByRestoreId = getActiveRestoresByRestoreId();
const matchingRestoreIds = [...activeRestoresByRestoreId.values()]
.filter((activeRestoreRun) => activeRestoreRun.agentId === agentId)
.map((activeRestoreRun) => activeRestoreRun.restoreId);
for (const restoreId of matchingRestoreIds) {
resolveActiveRestoreRun(restoreId, { status: "cancelled", message });
}
};
const getActiveBackupRun = (jobId: string, scheduleId: string, eventName: string, agentId: string) => {
const trackedScheduleId = getActiveBackupScheduleIdsByJobId().get(jobId);
if (trackedScheduleId === undefined) {
@ -96,9 +152,29 @@ const getActiveBackupRun = (jobId: string, scheduleId: string, eventName: string
return null;
}
if (activeBackupRun.agentId !== agentId) {
logger.warn(`Ignoring ${eventName} for job ${jobId} from unexpected agent ${agentId}`);
return null;
}
return activeBackupRun;
};
const getActiveRestoreRun = (restoreId: string, eventName: string, agentId: string) => {
const activeRestoreRun = getActiveRestoresByRestoreId().get(restoreId);
if (!activeRestoreRun) {
logger.warn(`Received ${eventName} for unknown restore ${restoreId} from agent ${agentId}`);
return null;
}
if (activeRestoreRun.agentId !== agentId) {
logger.warn(`Ignoring ${eventName} for restore ${restoreId} from unexpected agent ${agentId}`);
return null;
}
return activeRestoreRun;
};
const requestBackupCancellation = async (agentId: string, scheduleId: number) => {
const activeBackupRun = getActiveBackupsByScheduleId().get(scheduleId);
if (!activeBackupRun) {
@ -132,6 +208,32 @@ const requestBackupCancellation = async (agentId: string, scheduleId: number) =>
return true;
};
const requestRestoreCancellation = async (agentId: string, restoreId: string) => {
const activeRestoreRun = getActiveRestoresByRestoreId().get(restoreId);
if (!activeRestoreRun) {
return false;
}
if (activeRestoreRun.cancellationRequested) {
return true;
}
activeRestoreRun.cancellationRequested = true;
const runtime = getAgentManagerRuntime();
if (!runtime) {
resolveActiveRestoreRun(restoreId, { status: "cancelled" });
return true;
}
if (await Effect.runPromise(runtime.cancelRestore(agentId, { restoreId }))) {
return true;
}
resolveActiveRestoreRun(restoreId, { status: "cancelled" });
return true;
};
const handleAgentManagerEvent = (event: AgentManagerEvent) => {
switch (event.type) {
case "agent.disconnected": {
@ -139,6 +241,14 @@ const handleAgentManagerEvent = (event: AgentManagerEvent) => {
event.agentId,
"The connection to the backup agent was lost. Restart the backup to ensure it completes.",
);
cancelActiveRestoreRunsForAgent(
event.agentId,
"The connection to the restore agent was lost. Restart the restore to ensure it completes.",
);
break;
}
case "agent.protocolRejected": {
logger.warn(`Rejected agent protocol for ${event.agentName} (${event.agentId}): ${event.payload.reason}`);
break;
}
case "backup.started": {
@ -212,6 +322,60 @@ const handleAgentManagerEvent = (event: AgentManagerEvent) => {
});
break;
}
case "restore.started": {
const activeRestoreRun = getActiveRestoreRun(event.payload.restoreId, event.type, event.agentId);
if (!activeRestoreRun) {
break;
}
activeRestoreRun.onStarted();
break;
}
case "restore.progress": {
const activeRestoreRun = getActiveRestoreRun(event.payload.restoreId, event.type, event.agentId);
if (!activeRestoreRun) {
break;
}
activeRestoreRun.onProgress(event.payload.progress);
break;
}
case "restore.completed": {
const activeRestoreRun = getActiveRestoreRun(event.payload.restoreId, event.type, event.agentId);
if (!activeRestoreRun) {
break;
}
resolveActiveRestoreRun(activeRestoreRun.restoreId, {
status: "completed",
result: event.payload.result,
});
break;
}
case "restore.failed": {
const activeRestoreRun = getActiveRestoreRun(event.payload.restoreId, event.type, event.agentId);
if (!activeRestoreRun) {
break;
}
resolveActiveRestoreRun(activeRestoreRun.restoreId, {
status: "failed",
error: event.payload.errorDetails ?? event.payload.error,
});
break;
}
case "restore.cancelled": {
const activeRestoreRun = getActiveRestoreRun(event.payload.restoreId, event.type, event.agentId);
if (!activeRestoreRun) {
break;
}
resolveActiveRestoreRun(activeRestoreRun.restoreId, {
status: "cancelled",
message: activeRestoreRun.cancellationRequested ? undefined : event.payload.message,
});
break;
}
}
};
@ -307,18 +471,70 @@ export const agentManager = {
return response.command;
},
startRestore: async (agentId: string, request: AgentStartRestoreRequest): Promise<AgentRestoreStartResult> => {
const runtime = getAgentManagerRuntime();
if (!runtime) {
return {
status: "unavailable",
error: new Error(`Restore agent ${agentId} is not connected`),
};
}
if (request.signal.aborted) {
throw request.signal.reason || new Error("Operation aborted");
}
const completion = new Promise<RestoreExecutionResult>((resolve) => {
getActiveRestoresByRestoreId().set(request.payload.restoreId, {
agentId,
restoreId: request.payload.restoreId,
onStarted: request.onStarted,
onProgress: request.onProgress,
resolve,
cancellationRequested: false,
});
});
try {
if (!(await Effect.runPromise(runtime.sendRestore(agentId, request.payload)))) {
clearActiveRestoreRun(request.payload.restoreId);
return {
status: "unavailable",
error: new Error(`Failed to send restore command to agent ${agentId}`),
};
}
if (request.signal.aborted) {
await requestRestoreCancellation(agentId, request.payload.restoreId);
}
return { status: "started", result: completion };
} catch (error) {
clearActiveRestoreRun(request.payload.restoreId);
throw error;
}
},
cancelRestore: async (agentId: string, restoreId: string) => {
return requestRestoreCancellation(agentId, restoreId);
},
};
export const startLocalAgent = async () => {
const runtime = getAgentRuntimeState();
await spawnLocalAgentProcess(runtime);
if (!runtime.agentManager) {
throw new Error(
`startLocalAgent spawned ${LOCAL_AGENT_ID} via spawnLocalAgentProcess, but runtime.agentManager is missing; waitForAgentReady cannot check readiness`,
`startLocalAgent cannot spawn ${LOCAL_AGENT_ID} because runtime.agentManager is missing; waitForAgentReady cannot check readiness`,
);
}
const controllerUrl = runtime.agentManager.getControllerUrl();
if (!controllerUrl) {
throw new Error(`startLocalAgent cannot spawn ${LOCAL_AGENT_ID} because the controller URL is not available`);
}
await spawnLocalAgentProcess(runtime, controllerUrl);
if (!(await runtime.agentManager.waitForAgentReady(LOCAL_AGENT_ID))) {
throw new Error("Local agent did not become ready before startup");
}

View file

@ -3,8 +3,11 @@ import { logger } from "@zerobyte/core/node";
import { toMessage } from "@zerobyte/core/utils";
import type {
AgentMessage,
AgentProtocolRejection,
BackupCancelPayload,
BackupRunPayload,
RestoreCancelPayload,
RestoreRunPayload,
VolumeCommand,
VolumeCommandResponsePayload,
} from "@zerobyte/contracts/agent-protocol";
@ -22,16 +25,10 @@ type AgentEventContext = {
agentName: string;
};
type AgentBackupMessage = Extract<
AgentMessage,
{
type: "backup.started" | "backup.progress" | "backup.completed" | "backup.failed" | "backup.cancelled";
}
>;
export type AgentManagerEvent =
| (AgentEventContext & { type: "agent.disconnected" })
| (AgentEventContext & AgentBackupMessage);
| (AgentEventContext & { type: "agent.protocolRejected"; payload: AgentProtocolRejection })
| (AgentEventContext & AgentMessage);
type ControllerAgentSessionHandle = {
agentId: string;
@ -46,6 +43,7 @@ class StopAgentManagerServerError extends Data.TaggedError("StopAgentManagerServ
export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) => void) {
let sessions = new Map<string, ControllerAgentSessionHandle>();
let runtimeScope: Scope.CloseableScope | null = null;
let controllerUrl: string | null = null;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const closeSession = (sessionHandle: ControllerAgentSessionHandle) =>
@ -85,7 +83,7 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
return !!session && Effect.runSync(session.isReady());
};
const handleSessionEvent = (params: { agentId: string; agentName: string; sessionId: string }) => {
const handleSessionEvent = (params: { agentId: string; agentName: string }) => {
const { agentId, agentName } = params;
return (event: ControllerAgentSessionEvent) => {
@ -96,11 +94,17 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
await agentsService.markAgentOnline(agentId, at, {
...event.payload.capabilities,
protocolVersion: event.payload.protocolVersion,
protocolCompatible: true,
hostname: event.payload.hostname,
platform: event.payload.platform,
});
});
}
case "agent.protocolRejected": {
return Effect.sync(() =>
onEvent({ type: "agent.protocolRejected", agentId, agentName, payload: event.payload }),
);
}
case "heartbeat.pong": {
const at = Date.now();
return Effect.promise(() => agentsService.markAgentSeen(agentId, at));
@ -125,7 +129,6 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
handleSessionEvent({
agentId: ws.data.agentId,
agentName: ws.data.agentName,
sessionId: ws.data.id,
}),
),
scope,
@ -215,7 +218,8 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
const acquireServer = Effect.acquireRelease(
Effect.sync(() =>
Bun.serve<AgentConnectionData>({
port: 3001,
hostname: "127.0.0.1",
port: 0,
async fetch(req, srv) {
const authorizationHeader = req.headers.get("authorization");
const token = authorizationHeader?.slice("Bearer ".length);
@ -279,6 +283,7 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
logger.info("Stopping Agent Manager...");
const scope = runtimeScope;
runtimeScope = null;
controllerUrl = null;
yield* Scope.close(scope, Exit.succeed(undefined));
});
@ -296,11 +301,13 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
),
);
runtimeScope = scope;
controllerUrl = `ws://127.0.0.1:${server.port}`;
logger.info(`Agent Manager listening on port ${server.port}`);
});
return {
start,
getControllerUrl: () => controllerUrl,
waitForAgentReady: async (agentId: string, timeoutMs = 10_000) => {
const deadline = Date.now() + timeoutMs;
@ -354,6 +361,47 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
logger.info(`Sent backup cancel for command ${payload.jobId} to agent ${agentId}`);
return true;
}),
sendRestore: (agentId: string, payload: RestoreRunPayload) =>
Effect.gen(function* () {
const session = getSession(agentId);
if (!session) {
logger.warn(`Cannot send restore command. Agent ${agentId} is not connected.`);
return false;
}
if (!(yield* session.isReady())) {
logger.warn(`Cannot send restore command. Agent ${agentId} is not ready.`);
return false;
}
if (!(yield* session.sendRestore(payload))) {
logger.warn(`Cannot send restore command. Agent ${agentId} is no longer accepting commands.`);
return false;
}
logger.info(
`Sent restore command ${payload.restoreId} to agent ${agentId} for snapshot ${payload.snapshotId}`,
);
return true;
}),
cancelRestore: (agentId: string, payload: RestoreCancelPayload) =>
Effect.gen(function* () {
const session = getSession(agentId);
if (!session) {
logger.warn(`Cannot cancel restore command. Agent ${agentId} is not connected.`);
return false;
}
if (!(yield* session.sendRestoreCancel(payload))) {
logger.warn(`Cannot cancel restore command. Agent ${agentId} is no longer accepting commands.`);
return false;
}
logger.info(`Sent restore cancel for command ${payload.restoreId} to agent ${agentId}`);
return true;
}),
runVolumeCommand: (
agentId: string,
command: VolumeCommand,

View file

@ -3,10 +3,16 @@ import type { AgentKind } from "../../../db/schema";
import {
createControllerMessage,
parseAgentMessage,
parseAgentStartupMessage,
SUPPORTED_AGENT_PROTOCOL_MAX_VERSION,
SUPPORTED_AGENT_PROTOCOL_MIN_VERSION,
type AgentMessage,
type AgentProtocolRejection,
type BackupCancelPayload,
type BackupRunPayload,
type ControllerWireMessage,
type RestoreCancelPayload,
type RestoreRunPayload,
type VolumeCommand,
type VolumeCommandResponsePayload,
} from "@zerobyte/contracts/agent-protocol";
@ -25,26 +31,25 @@ type AgentSocket = Bun.ServerWebSocket<AgentConnectionData>;
type SessionState = {
isReady: boolean;
protocolVersion: number | null;
lastSeenAt: number | null;
lastPongAt: number | null;
};
type PendingCommand = {
deferred: Deferred.Deferred<VolumeCommandResponsePayload, Error>;
description: string;
};
type PendingCommand = { deferred: Deferred.Deferred<VolumeCommandResponsePayload, Error>; description: string };
export type ControllerAgentSessionEvent =
| Exclude<AgentMessage, { type: "volume.commandResult" }>
| {
type: "agent.disconnected";
};
| { type: "agent.protocolRejected"; payload: AgentProtocolRejection }
| { type: "agent.disconnected" };
export type ControllerAgentSession = {
readonly connectionId: string;
handleMessage: (data: string) => Effect.Effect<void>;
sendBackup: (payload: BackupRunPayload) => Effect.Effect<boolean>;
sendBackupCancel: (payload: BackupCancelPayload) => Effect.Effect<boolean>;
sendRestore: (payload: RestoreRunPayload) => Effect.Effect<boolean>;
sendRestoreCancel: (payload: RestoreCancelPayload) => Effect.Effect<boolean>;
runVolumeCommand: (command: VolumeCommand) => Effect.Effect<VolumeCommandResponsePayload, Error>;
isReady: () => Effect.Effect<boolean>;
run: Effect.Effect<void, never, Scope.Scope>;
@ -60,6 +65,7 @@ export const createControllerAgentSession = (
const pendingCommands = yield* Ref.make(new Map<string, PendingCommand>());
const state = yield* Ref.make<SessionState>({
isReady: false,
protocolVersion: null,
lastSeenAt: null,
lastPongAt: null,
});
@ -68,7 +74,9 @@ export const createControllerAgentSession = (
Queue.offer(outboundQueue, message).pipe(
Effect.catchAllCause((cause) =>
Effect.sync(() => {
logger.error(`Failed to queue outbound message for agent ${socket.data.agentId}: ${toMessage(cause)}`);
logger.error(
`Failed to queue outbound message for agent ${socket.data.agentId}: ${toMessage(cause)}`,
);
return false;
}),
),
@ -182,14 +190,23 @@ export const createControllerAgentSession = (
switch (message.type) {
case "agent.ready": {
const readyAt = Date.now();
yield* updateState((current) => ({ ...current, isReady: true, lastSeenAt: readyAt }));
yield* updateState((current) => ({
...current,
isReady: true,
protocolVersion: message.payload.protocolVersion,
lastSeenAt: readyAt,
}));
yield* logger.effect.info(`Agent "${socket.data.agentName}" (${socket.data.agentId}) is ready`);
yield* onEvent(message);
break;
}
case "heartbeat.pong": {
const seenAt = Date.now();
yield* updateState((current) => ({ ...current, lastSeenAt: seenAt, lastPongAt: message.payload.sentAt }));
yield* updateState((current) => ({
...current,
lastSeenAt: seenAt,
lastPongAt: message.payload.sentAt,
}));
yield* onEvent(message);
break;
}
@ -204,10 +221,30 @@ export const createControllerAgentSession = (
}
});
const rejectStartupMessage = (rejection: AgentProtocolRejection) =>
Effect.gen(function* () {
yield* logger.effect.warn(
`Rejecting startup message from agent ${socket.data.agentId}: ${rejection.reason}`,
);
yield* onEvent({ type: "agent.protocolRejected", payload: rejection });
yield* Effect.sync(() => socket.close(1002, rejection.reason));
yield* closeSession();
});
return {
connectionId: socket.data.id,
handleMessage: (data: string) => {
return Effect.gen(function* () {
const currentState = yield* Ref.get(state);
if (!currentState.isReady) {
const startupMessage = parseAgentStartupMessage(data);
if (!("success" in startupMessage)) {
yield* rejectStartupMessage(startupMessage);
return;
}
}
const parsed = parseAgentMessage(data);
if (parsed === null) {
@ -216,7 +253,18 @@ export const createControllerAgentSession = (
}
if (!parsed.success) {
yield* logger.effect.warn(`Invalid agent message from ${socket.data.agentId}: ${parsed.error.message}`);
if (!currentState.isReady) {
yield* rejectStartupMessage({
reason: "invalid_agent_ready",
supportedProtocolMinVersion: SUPPORTED_AGENT_PROTOCOL_MIN_VERSION,
supportedProtocolMaxVersion: SUPPORTED_AGENT_PROTOCOL_MAX_VERSION,
});
return;
}
yield* logger.effect.warn(
`Invalid agent message from ${socket.data.agentId}: ${parsed.error.message}`,
);
return;
}
@ -225,6 +273,8 @@ export const createControllerAgentSession = (
},
sendBackup: (payload) => offerOutbound(createControllerMessage("backup.run", payload)),
sendBackupCancel: (payload) => offerOutbound(createControllerMessage("backup.cancel", payload)),
sendRestore: (payload) => offerOutbound(createControllerMessage("restore.run", payload)),
sendRestoreCancel: (payload) => offerOutbound(createControllerMessage("restore.cancel", payload)),
runVolumeCommand: (command) =>
Effect.gen(function* () {
const commandId = Bun.randomUUIDv7();
@ -232,7 +282,9 @@ export const createControllerAgentSession = (
const deferred = yield* Deferred.make<VolumeCommandResponsePayload, Error>();
yield* setPendingCommand(commandId, { deferred, description });
const queued = yield* offerOutbound(createControllerMessage("volume.command", { commandId, command }));
const queued = yield* offerOutbound(
createControllerMessage("volume.command", { commandId, command }),
);
if (!queued) {
yield* removePendingCommand(commandId);
return yield* Effect.fail(new Error(`Failed to queue volume command ${command.name}`));

View file

@ -1,20 +1,26 @@
import { createAgentRuntimeState, type AgentRuntimeState } from "./runtime-state";
type LegacyAgentRuntimeState = Omit<AgentRuntimeState, "activeBackupsByScheduleId" | "activeBackupScheduleIdsByJobId"> &
Partial<Pick<AgentRuntimeState, "activeBackupsByScheduleId" | "activeBackupScheduleIdsByJobId">>;
type RuntimeMapKey = "activeBackupsByScheduleId" | "activeBackupScheduleIdsByJobId" | "activeRestoresByRestoreId";
type LegacyAgentRuntimeState = Omit<AgentRuntimeState, RuntimeMapKey> & Partial<Pick<AgentRuntimeState, RuntimeMapKey>>;
export type ProcessWithAgentRuntime = NodeJS.Process & {
__zerobyteAgentRuntime?: LegacyAgentRuntimeState;
};
const hasActiveBackupMaps = (runtime: LegacyAgentRuntimeState): runtime is AgentRuntimeState => {
return runtime.activeBackupsByScheduleId instanceof Map && runtime.activeBackupScheduleIdsByJobId instanceof Map;
const hasActiveRuntimeMaps = (runtime: LegacyAgentRuntimeState): runtime is AgentRuntimeState => {
return (
runtime.activeBackupsByScheduleId instanceof Map &&
runtime.activeBackupScheduleIdsByJobId instanceof Map &&
runtime.activeRestoresByRestoreId instanceof Map
);
};
const hydrateAgentRuntimeState = (runtime: LegacyAgentRuntimeState): AgentRuntimeState => ({
...runtime,
activeBackupsByScheduleId: runtime.activeBackupsByScheduleId ?? new Map(),
activeBackupScheduleIdsByJobId: runtime.activeBackupScheduleIdsByJobId ?? new Map(),
activeRestoresByRestoreId: runtime.activeRestoresByRestoreId ?? new Map(),
});
export const getDevAgentRuntimeState = (): AgentRuntimeState => {
@ -27,7 +33,7 @@ export const getDevAgentRuntimeState = (): AgentRuntimeState => {
return runtime;
}
if (hasActiveBackupMaps(existingRuntime)) {
if (hasActiveRuntimeMaps(existingRuntime)) {
return existingRuntime;
}

View file

@ -1,12 +1,27 @@
import type { ChildProcess } from "node:child_process";
import type { ResticBackupOutputDto } from "@zerobyte/core/restic";
import type { BackupProgressPayload } from "@zerobyte/contracts/agent-protocol";
import type {
BackupProgressPayload,
RestoreCompletedPayload,
RestoreProgressPayload,
} from "@zerobyte/contracts/agent-protocol";
import type { AgentManagerRuntime } from "../controller/server";
export type BackupExecutionProgress = BackupProgressPayload["progress"];
export type BackupExecutionResult =
| { status: "unavailable"; error: Error }
| { status: "completed"; exitCode: number; result: ResticBackupOutputDto | null; warningDetails: string | null }
| {
status: "completed";
exitCode: number;
result: ResticBackupOutputDto | null;
warningDetails: string | null;
}
| { status: "failed"; error: string }
| { status: "cancelled"; message?: string };
export type RestoreExecutionProgress = RestoreProgressPayload["progress"];
export type RestoreExecutionResult =
| { status: "unavailable"; error: Error }
| { status: "completed"; result: RestoreCompletedPayload["result"] }
| { status: "failed"; error: string }
| { status: "cancelled"; message?: string };
@ -20,6 +35,15 @@ type ActiveBackupRun = {
cancellationRequested: boolean;
};
type ActiveRestoreRun = {
agentId: string;
restoreId: string;
onStarted: () => void;
onProgress: (progress: RestoreExecutionProgress) => void;
resolve: (result: RestoreExecutionResult) => void;
cancellationRequested: boolean;
};
export type AgentRuntimeState = {
agentManager: AgentManagerRuntime | null;
localAgent: ChildProcess | null;
@ -27,6 +51,7 @@ export type AgentRuntimeState = {
localAgentRestartTimeout: ReturnType<typeof setTimeout> | null;
activeBackupsByScheduleId: Map<number, ActiveBackupRun>;
activeBackupScheduleIdsByJobId: Map<string, number>;
activeRestoresByRestoreId: Map<string, ActiveRestoreRun>;
};
export const createAgentRuntimeState = (): AgentRuntimeState => ({
@ -36,4 +61,5 @@ export const createAgentRuntimeState = (): AgentRuntimeState => ({
localAgentRestartTimeout: null,
activeBackupsByScheduleId: new Map(),
activeBackupScheduleIdsByJobId: new Map(),
activeRestoresByRestoreId: new Map(),
});

View file

@ -11,7 +11,7 @@ type LocalAgentState = {
localAgentRestartTimeout: ReturnType<typeof setTimeout> | null;
};
export async function spawnLocalAgentProcess(runtime: LocalAgentState) {
export async function spawnLocalAgentProcess(runtime: LocalAgentState, controllerUrl: string) {
await stopLocalAgentProcess(runtime);
if (!config.flags.enableLocalAgent) {
@ -31,7 +31,7 @@ export async function spawnLocalAgentProcess(runtime: LocalAgentState) {
const agentProcess = spawn("bun", args, {
env: {
...process.env,
ZEROBYTE_CONTROLLER_URL: "ws://localhost:3001",
ZEROBYTE_CONTROLLER_URL: controllerUrl,
ZEROBYTE_AGENT_TOKEN: agentToken,
},
stdio: ["ignore", "pipe", "pipe"],
@ -62,8 +62,10 @@ export async function spawnLocalAgentProcess(runtime: LocalAgentState) {
runtime.localAgentRestartTimeout = setTimeout(() => {
runtime.localAgentRestartTimeout = null;
void spawnLocalAgentProcess(runtime).catch((error) => {
logger.error(`Failed to restart local agent: ${error instanceof Error ? error.message : String(error)}`);
void spawnLocalAgentProcess(runtime, controllerUrl).catch((error) => {
logger.error(
`Failed to restart local agent: ${error instanceof Error ? error.message : String(error)}`,
);
});
}, 1_000);
});

View file

@ -1,8 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { eq } from "drizzle-orm";
import { db } from "~/server/db/db";
import { account, usersTable } from "~/server/db/schema";
import { account, passkey, usersTable } from "~/server/db/schema";
import { createUser, randomId, randomSlug } from "~/test/helpers/user-org";
import { userHasCredentialPassword, verifyUserPassword } from "../helpers";
import { hasActivePasskeyUser, userHasCredentialPassword, verifyUserPassword } from "../helpers";
const { verifyPassword } = vi.hoisted(() => ({
verifyPassword: vi.fn(async ({ hash }: { hash: string }) => hash === "credential-password-hash"),
@ -30,9 +31,24 @@ async function createAccount({
});
}
async function createPasskey(userId: string) {
await db.insert(passkey).values({
id: randomId(),
name: "Test passkey",
publicKey: randomSlug("public-key"),
userId,
credentialID: randomSlug("credential"),
counter: 0,
deviceType: "singleDevice",
backedUp: false,
transports: "internal",
});
}
describe("verifyUserPassword", () => {
beforeEach(async () => {
verifyPassword.mockClear();
await db.delete(passkey);
await db.delete(account);
await db.delete(usersTable);
});
@ -64,6 +80,7 @@ describe("verifyUserPassword", () => {
describe("userHasCredentialPassword", () => {
beforeEach(async () => {
await db.delete(passkey);
await db.delete(account);
await db.delete(usersTable);
});
@ -89,3 +106,32 @@ describe("userHasCredentialPassword", () => {
await expect(userHasCredentialPassword(userId)).resolves.toBe(false);
});
});
describe("hasActivePasskeyUser", () => {
beforeEach(async () => {
await db.delete(passkey);
await db.delete(account);
await db.delete(usersTable);
});
test("returns true when a non-banned user has a passkey", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createPasskey(userId);
await expect(hasActivePasskeyUser()).resolves.toBe(true);
});
test("returns false when only banned users have passkeys", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await db.update(usersTable).set({ banned: true }).where(eq(usersTable.id, userId));
await createPasskey(userId);
await expect(hasActivePasskeyUser()).resolves.toBe(false);
});
test("returns false when no users have passkeys", async () => {
await createUser(`${randomSlug("user")}@example.com`);
await expect(hasActivePasskeyUser()).resolves.toBe(false);
});
});

View file

@ -1,5 +1,7 @@
import { eq } from "drizzle-orm";
import { verifyPassword } from "better-auth/crypto";
import { db } from "~/server/db/db";
import { passkey, usersTable } from "~/server/db/schema";
type PasswordVerificationBody = {
userId: string;
@ -31,3 +33,14 @@ export const userHasCredentialPassword = async (userId: string) => {
return Boolean(userAccount?.password);
};
export const hasActivePasskeyUser = async () => {
const [user] = await db
.select({ id: usersTable.id })
.from(passkey)
.innerJoin(usersTable, eq(passkey.userId, usersTable.id))
.where(eq(usersTable.banned, false))
.limit(1);
return Boolean(user);
};

View file

@ -10,24 +10,27 @@ import * as context from "~/server/core/request-context";
import * as resticModule from "~/server/core/restic";
import * as spawnModule from "@zerobyte/core/node";
import type { ShortId } from "~/server/utils/branded";
import { Effect } from "effect";
const setup = () => {
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
vi.spyOn(spawnModule, "safeSpawn").mockImplementation(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
vi.spyOn(spawnModule, "safeSpawn").mockImplementation(() =>
Promise.resolve({ exitCode: 0, summary: "", error: "" }),
);
return {
mockSnapshots: (sourceSnapshots: unknown[], mirrorSnapshots: unknown[]) => {
let callCount = 0;
vi.spyOn(resticModule.restic, "snapshots").mockImplementation(() => {
callCount++;
if (callCount === 1) return Promise.resolve(sourceSnapshots as never);
return Promise.resolve(mirrorSnapshots as never);
if (callCount === 1) return Effect.succeed(sourceSnapshots as never);
return Effect.succeed(mirrorSnapshots as never);
});
},
mockCopy: () => {
const copyMock = vi
.spyOn(resticModule.restic, "copy")
.mockImplementation(() => Promise.resolve({ success: true, output: "" }));
.mockImplementation(() => Effect.succeed({ success: true, output: "" }));
return copyMock;
},
};
@ -190,12 +193,14 @@ describe("syncMirror", () => {
const copyMock = mockCopy();
let releaseCopy: (() => void) | undefined;
const copyStarted = new Promise<void>((resolve) => {
copyMock.mockImplementation(
() =>
new Promise((copyResolve) => {
releaseCopy = () => copyResolve({ success: true, output: "" });
resolve();
}),
copyMock.mockImplementation(() =>
Effect.promise(
() =>
new Promise((copyResolve) => {
releaseCopy = () => copyResolve({ success: true, output: "" });
resolve();
}),
),
);
});
@ -208,9 +213,7 @@ describe("syncMirror", () => {
});
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
await expect(
backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]),
).resolves.toEqual({ success: true });
const firstSync = backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]);
await copyStarted;
@ -225,6 +228,8 @@ describe("syncMirror", () => {
releaseCopy?.();
await expect(firstSync).resolves.toEqual({ success: true });
await waitForExpect(async () => {
const mirrors = await backupsService.getMirrors(schedule.shortId);
expect(mirrors[0]?.lastCopyStatus).toBe("success");

View file

@ -24,13 +24,15 @@ import { getScheduleByIdOrShortId } from "../helpers/backup-schedule-lookups";
import { volumeService } from "~/server/modules/volumes/volume.service";
import { db } from "~/server/db/db";
import { config } from "~/server/core/config";
import { Effect } from "effect";
import { taskStore } from "~/server/modules/tasks/tasks.store";
const setup = () => {
const resticBackupMock = vi.fn((_: SafeSpawnParams) =>
Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }),
);
const resticForgetMock = vi.fn(() => Promise.resolve({ success: true, data: null }));
const resticCopyMock = vi.fn(() => Promise.resolve({ success: true, output: "" }));
const resticForgetMock = vi.fn(() => Effect.succeed({ success: true, data: null }));
const resticCopyMock = vi.fn(() => Effect.succeed({ success: true, output: "" }));
const { runBackupMock, cancelBackupMock } = createAgentBackupMocks(resticBackupMock);
const refreshStatsMock = vi.fn(() =>
Promise.resolve({
@ -89,6 +91,18 @@ const setup = () => {
};
};
const getBackupTaskForSchedule = (scheduleId: number) =>
db.query.tasksTable.findFirst({
where: {
AND: [
{ organizationId: TEST_ORG_ID },
{ kind: "backup" },
{ resourceType: "backup_schedule" },
{ resourceId: String(scheduleId) },
],
},
});
afterEach(() => {
vi.restoreAllMocks();
config.flags.enableLocalAgent = true;
@ -232,6 +246,102 @@ describe("backup execution - validation failures", () => {
).toBe(false);
});
test("creates a backup task and uses the task id as the agent job id", async () => {
const { runBackupMock } = setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
await backupsService.executeBackup(schedule.id);
const task = await getBackupTaskForSchedule(schedule.id);
expect(task).toBeDefined();
expect(task).toMatchObject({
organizationId: TEST_ORG_ID,
kind: "backup",
status: "succeeded",
resourceType: "backup_schedule",
resourceId: String(schedule.id),
targetAgentId: "local",
input: {
kind: "backup",
scheduleId: schedule.id,
scheduleShortId: schedule.shortId,
manual: false,
},
result: expect.objectContaining({
kind: "backup",
exitCode: 0,
warningDetails: null,
}),
});
expect(runBackupMock.mock.calls[0]?.[1].payload.jobId).toBe(task!.id);
});
test("does not leave a queued task behind when backup startup state fails", async () => {
const { runBackupMock } = setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
vi.spyOn(scheduleQueries, "updateStatus").mockRejectedValueOnce(new Error("status update failed"));
await expect(backupsService.executeBackup(schedule.id)).rejects.toThrow("status update failed");
expect(await getBackupTaskForSchedule(schedule.id)).toBeUndefined();
expect(runBackupMock).not.toHaveBeenCalled();
});
test("persists latest backup progress while preserving execution", async () => {
const { runBackupMock } = setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
runBackupMock.mockImplementationOnce(async (_agentId, request) => {
request.onProgress({
message_type: "status",
seconds_elapsed: 1,
seconds_remaining: 9,
percent_done: 0.25,
total_files: 100,
files_done: 25,
total_bytes: 1000,
bytes_done: 250,
current_files: ["file.txt"],
});
return {
status: "completed",
exitCode: 0,
result: JSON.parse(generateBackupOutput()),
warningDetails: null,
};
});
await backupsService.executeBackup(schedule.id);
const task = await getBackupTaskForSchedule(schedule.id);
expect(task?.status).toBe("succeeded");
expect(task?.progress).toMatchObject({
kind: "backup",
progress: {
percent_done: 0.25,
bytes_done: 250,
current_files: ["file.txt"],
},
});
});
test("passes configured backup webhooks to the backup agent", async () => {
const { runBackupMock } = setup();
const volume = await createTestVolume();
@ -279,6 +389,7 @@ describe("backup execution - validation failures", () => {
privateKey: "key",
path: "/data",
skipHostKeyCheck: false,
allowLegacySshRsa: false,
},
});
const repository = await createTestRepository();
@ -372,8 +483,52 @@ describe("backup execution - validation failures", () => {
await backupsService.executeBackup(schedule.id);
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
const task = await getBackupTaskForSchedule(schedule.id);
expect(updatedSchedule.lastBackupStatus).toBe("error");
expect(updatedSchedule.lastBackupError).toBe("Local backup agent is not connected");
expect(task?.status).toBe("failed");
expect(task?.error).toBe("Local backup agent is not connected");
});
test("removes stale locks and retries once when the local backup fallback hits a restic lock", async () => {
const { resticBackupMock, runBackupMock } = setup();
config.flags.enableLocalAgent = false;
const safeExecMock = vi.spyOn(spawnModule, "safeExec").mockResolvedValue({
exitCode: 0,
stdout: "",
stderr: "",
timedOut: false,
});
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
});
runBackupMock.mockResolvedValueOnce({
status: "unavailable",
error: new Error("Local backup agent is not connected"),
});
resticBackupMock
.mockImplementationOnce((params: SafeSpawnParams) => {
params.onStderr?.("unable to create lock in backend: repository is already locked");
return Promise.resolve({
exitCode: 11,
summary: "",
error: "unable to create lock in backend: repository is already locked",
});
})
.mockImplementationOnce(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }));
await backupsService.executeBackup(schedule.id);
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
expect(updatedSchedule.lastBackupStatus).toBe("success");
expect(resticBackupMock).toHaveBeenCalledTimes(2);
const unlockCalls = safeExecMock.mock.calls.filter(([params]) => params.args?.includes("unlock"));
expect(unlockCalls).toHaveLength(1);
expect(unlockCalls[0]?.[0].args).not.toContain("--remove-all");
});
});
@ -448,8 +603,15 @@ describe("stop backup", () => {
await backupsService.executeBackup(schedule.id);
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
const task = await getBackupTaskForSchedule(schedule.id);
expect(updatedSchedule.lastBackupStatus).toBe("warning");
expect(updatedSchedule.lastBackupError).toBe("error: open /mnt/data/private.db: permission denied");
expect(task?.status).toBe("succeeded");
expect(task?.result).toMatchObject({
kind: "backup",
exitCode: 3,
warningDetails: "error: open /mnt/data/private.db: permission denied",
});
expect(notificationSpy).toHaveBeenLastCalledWith(
schedule.id,
"warning",
@ -602,6 +764,48 @@ describe("stop backup", () => {
expect(updatedSchedule.lastBackupError).toBe("Backup was stopped by the user");
});
test("treats a task that finishes during stop as no longer running", async () => {
setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
lastBackupStatus: "in_progress",
});
const task = taskStore.create({
id: "task-stop-race",
organizationId: TEST_ORG_ID,
resourceType: "backup_schedule",
resourceId: String(schedule.id),
targetAgentId: "local",
input: {
kind: "backup",
scheduleId: schedule.id,
scheduleShortId: schedule.shortId,
manual: false,
},
});
const requestCancel = taskStore.requestCancel;
vi.spyOn(taskStore, "requestCancel").mockImplementation((taskId) => {
taskStore.complete(taskId, {
kind: "backup",
exitCode: 0,
result: JSON.parse(generateBackupOutput()),
warningDetails: null,
});
return requestCancel(taskId);
});
await expect(backupsService.stopBackup(schedule.id)).rejects.toThrow(
"No backup is currently running for this schedule",
);
const updatedTask = await getBackupTaskForSchedule(schedule.id);
expect(updatedTask).toMatchObject({ id: task.id, status: "succeeded" });
});
test("should stop a running backup when the cancel command cannot be delivered", async () => {
const { resticBackupMock, cancelBackupMock } = setup();
const volume = await createTestVolume();
@ -645,6 +849,8 @@ describe("stop backup", () => {
await waitForExpect(async () => {
const queuedSchedule = await getScheduleByIdOrShortId(schedule.id);
expect(queuedSchedule.lastBackupStatus).toBe("in_progress");
const task = await getBackupTaskForSchedule(schedule.id);
expect(task?.status).toBe("queued");
});
expect(resticBackupMock).not.toHaveBeenCalled();
@ -657,8 +863,11 @@ describe("stop backup", () => {
await executePromise;
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
const task = await getBackupTaskForSchedule(schedule.id);
expect(updatedSchedule.lastBackupStatus).toBe("warning");
expect(updatedSchedule.lastBackupError).toBe("Backup was stopped by the user");
expect(task?.status).toBe("cancelled");
expect(task?.cancellationRequested).toBe(true);
expect(resticBackupMock).not.toHaveBeenCalled();
});
@ -759,6 +968,41 @@ describe("stop backup", () => {
expect(updatedSchedule.lastBackupError).toBe("Backup was stopped by the user");
});
test("marks an active task stale when stopping a stuck schedule without a live executor", async () => {
setup();
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
lastBackupStatus: "in_progress",
});
taskStore.create({
id: "task-stuck-backup",
organizationId: TEST_ORG_ID,
resourceType: "backup_schedule",
resourceId: String(schedule.id),
targetAgentId: "local",
input: {
kind: "backup",
scheduleId: schedule.id,
scheduleShortId: schedule.shortId,
manual: false,
},
});
await expect(backupsService.stopBackup(schedule.id)).rejects.toThrow(
"No backup is currently running for this schedule",
);
const task = await getBackupTaskForSchedule(schedule.id);
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
expect(task?.status).toBe("stale");
expect(task?.error).toBe("No live backup execution was found for this schedule");
expect(updatedSchedule.lastBackupStatus).toBe("warning");
expect(updatedSchedule.lastBackupError).toBe("Backup was stopped by the user");
});
test("should throw NotFoundError when schedule does not exist", async () => {
setup();
// act & assert
@ -924,12 +1168,14 @@ describe("mirror operations", () => {
const originalMirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
resticCopyMock.mockImplementationOnce(async () => {
await backupsService.updateMirrors(schedule.id, {
mirrors: [{ repositoryId: mirrorRepository.id, enabled: true }],
});
return { success: true, output: "" };
});
resticCopyMock.mockImplementationOnce(() =>
Effect.promise(async () => {
await backupsService.updateMirrors(schedule.id, {
mirrors: [{ repositoryId: mirrorRepository.id, enabled: true }],
});
return { success: true, output: "" };
}),
);
// act
await backupsService.copyToMirrors(schedule.id, sourceRepository, null);
@ -956,7 +1202,11 @@ describe("mirror operations", () => {
const mirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
resticCopyMock.mockImplementationOnce(() => Promise.reject(new Error("Copy failed")));
resticCopyMock.mockImplementationOnce(() =>
Effect.sync(() => {
throw new Error("Copy failed");
}),
);
// act
await backupsService.copyToMirrors(schedule.id, sourceRepository, null);
@ -984,7 +1234,7 @@ describe("mirror operations", () => {
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
resticCopyMock.mockClear();
resticCopyMock.mockImplementation(() => Promise.resolve({ success: true, output: "" }));
resticCopyMock.mockImplementation(() => Effect.succeed({ success: true, output: "" }));
// act
await backupsService.copyToMirrors(schedule.id, sourceRepository, schedule.retentionPolicy);
@ -1052,14 +1302,16 @@ describe("mirror operations", () => {
resolveFirstCopyStarted = resolve;
});
resticCopyMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirstCopyStarted();
releaseFirstCopy = () => resolve({ success: true, output: "" });
}),
resticCopyMock.mockImplementationOnce(() =>
Effect.promise(
() =>
new Promise((resolve) => {
resolveFirstCopyStarted();
releaseFirstCopy = () => resolve({ success: true, output: "" });
}),
),
);
resticCopyMock.mockImplementation(() => Promise.resolve({ success: true, output: "" }));
resticCopyMock.mockImplementation(() => Effect.succeed({ success: true, output: "" }));
const firstCopyPromise = backupsService.copyToMirrors(firstSchedule.id, sourceRepository, null);
await firstCopyStarted;

View file

@ -1,4 +1,3 @@
import { Effect } from "effect";
import { runBackupLifecycle } from "@zerobyte/core/backup-hooks";
import type { BackupSchedule, Volume, Repository } from "../../db/schema";
import { config } from "../../core/config";
@ -10,12 +9,13 @@ import { getVolumePath } from "../volumes/helpers";
import { decryptVolumeConfig } from "../volumes/volume-config-secrets";
import { decryptRepositoryConfig } from "../repositories/repository-config-secrets";
import { createBackupOptions } from "./backup.helpers";
import { toErrorDetails } from "../../utils/errors";
import { runEffectPromise, toErrorDetails } from "../../utils/errors";
import { BadRequestError } from "http-errors-enhanced";
const FUSE_VOLUME_BACKENDS = new Set<Volume["type"]>(["rclone", "sftp", "webdav"]);
const IGNORE_INODE_FLAG = "--ignore-inode";
type BackupExecutionRequest = {
jobId: string;
scheduleId: number;
schedule: BackupSchedule;
volume: Volume;
@ -43,7 +43,7 @@ const createBackupRunPayload = async ({
volume,
repository,
organizationId,
}: BackupExecutionRequest & { jobId: string }): Promise<BackupRunPayload> => {
}: BackupExecutionRequest): Promise<BackupRunPayload> => {
const agentVolume = { ...volume, config: await decryptVolumeConfig(volume.config) };
const customResticParams = schedule.customResticParams ?? [];
@ -90,7 +90,7 @@ const executeBackupWithoutAgent = async (
compressionMode: payload.options.compressionMode,
};
return Effect.runPromise(
return runEffectPromise(
runBackupLifecycle({
restic,
repositoryConfig: payload.repositoryConfig,
@ -120,7 +120,7 @@ export const backupExecutor = {
activeControllersByScheduleId.delete(scheduleId);
}
},
execute: async (request: Omit<BackupExecutionRequest, "jobId">) => {
execute: async (request: BackupExecutionRequest) => {
const trackedExecution = activeControllersByScheduleId.get(request.scheduleId);
if (!trackedExecution || trackedExecution.abortController.signal !== request.signal) {
throw new Error(`Backup execution for schedule ${request.scheduleId} was not tracked`);
@ -130,9 +130,7 @@ export const backupExecutor = {
throw request.signal.reason || new Error("Operation aborted");
}
const jobId = Bun.randomUUIDv7();
const payload = await createBackupRunPayload({ ...request, jobId });
const payload = await createBackupRunPayload(request);
if (request.signal.aborted) {
throw request.signal.reason || new Error("Operation aborted");

View file

@ -1,5 +1,5 @@
import { CronExpressionParser } from "cron-parser";
import { createBackupOptions as createAgentBackupOptions } from "../../../../apps/agent/src/commands/backup.helpers";
import { createBackupOptions as createAgentBackupOptions } from "../../../../apps/agent/src/commands/helpers/backup.helpers";
import type { BackupSchedule } from "~/server/db/schema";
import { toMessage } from "~/server/utils/errors";
import { logger } from "@zerobyte/core/node";

View file

@ -28,7 +28,29 @@ import { getScheduleByIdOrShortId } from "./helpers/backup-schedule-lookups";
import { copyToMirrors, runForget, syncSnapshotsToMirror } from "./helpers/backup-maintenance";
import { restic } from "../../core/restic";
import { mirrorQueries } from "./backups.queries";
import { toMessage } from "../../utils/errors";
import { runEffectPromise, toMessage } from "../../utils/errors";
import { Effect } from "effect";
import { taskStore } from "../tasks/tasks.store";
const BACKUP_TASK_RESOURCE_TYPE = "backup_schedule";
const tryCancelTask = (
taskId: string,
activeTaskResource: { organizationId: string; kind: "backup"; resourceType: string; resourceId: string },
) => {
try {
taskStore.requestCancel(taskId);
return true;
} catch (error) {
const currentTask = taskStore.findActiveByResource(activeTaskResource);
if (!currentTask || currentTask.id !== taskId) {
return false;
}
throw error;
}
};
const listSchedules = async () => {
const organizationId = getOrganizationId();
const schedules = await db.query.backupSchedulesTable.findMany({
@ -73,7 +95,10 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
const repository = await db.query.repositoriesTable.findFirst({
where: {
AND: [{ OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] }, { organizationId }],
AND: [
{ OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] },
{ organizationId },
],
},
});
@ -149,7 +174,10 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
const repository = await db.query.repositoriesTable.findFirst({
where: {
AND: [{ OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] }, { organizationId }],
AND: [
{ OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] },
{ organizationId },
],
},
});
@ -159,7 +187,11 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
const cronExpression = data.cronExpression ?? schedule.cronExpression;
const nextBackupAt =
data.cronExpression === "" ? null : data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt;
data.cronExpression === ""
? null
: data.cronExpression
? calculateNextRun(cronExpression)
: schedule.nextBackupAt;
const [updated] = await db
.update(backupSchedulesTable)
@ -351,7 +383,12 @@ const reorderSchedules = async (scheduleShortIds: ShortId[]) => {
for (const [index, scheduleId] of scheduleIds.entries()) {
tx.update(backupSchedulesTable)
.set({ sortOrder: index, updatedAt: now })
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)))
.where(
and(
eq(backupSchedulesTable.id, scheduleId),
eq(backupSchedulesTable.organizationId, organizationId),
),
)
.run();
}
});
@ -402,7 +439,16 @@ const executeBackup = async (scheduleId: number, manual = false) => {
...(ctx.schedule.cronExpression ? { nextBackupAt: calculateNextRun(ctx.schedule.cronExpression) } : {}),
});
const task = taskStore.create({
organizationId: ctx.organizationId,
resourceType: BACKUP_TASK_RESOURCE_TYPE,
resourceId: String(scheduleId),
targetAgentId: ctx.volume.agentId,
input: { kind: "backup", scheduleId, scheduleShortId: ctx.schedule.shortId, manual },
});
const abortController = backupExecutor.track(scheduleId);
let domainHandlerCompleted = false;
try {
const releaseLock = await repoMutex.acquireShared(
@ -412,7 +458,10 @@ const executeBackup = async (scheduleId: number, manual = false) => {
);
try {
taskStore.markRunning(task.id);
const executionResult = await backupExecutor.execute({
jobId: task.id,
scheduleId,
schedule: ctx.schedule,
volume: ctx.volume,
@ -421,33 +470,63 @@ const executeBackup = async (scheduleId: number, manual = false) => {
signal: abortController.signal,
onProgress: (progress) => {
updateBackupProgress(ctx, progress);
try {
taskStore.updateProgress(task.id, { kind: "backup", progress });
} catch (error) {
logger.error(`Failed to persist backup task progress for ${task.id}: ${toMessage(error)}`);
}
},
});
switch (executionResult.status) {
case "unavailable":
return handleBackupFailure(scheduleId, ctx.organizationId, executionResult.error, manual, ctx);
case "unavailable": {
await handleBackupFailure(scheduleId, ctx.organizationId, executionResult.error, manual, ctx);
domainHandlerCompleted = true;
taskStore.fail(task.id, toMessage(executionResult.error));
return;
}
case "completed":
return finalizeSuccessfulBackup(
await finalizeSuccessfulBackup(
ctx,
executionResult.exitCode,
executionResult.result,
executionResult.warningDetails,
);
case "failed":
return handleBackupFailure(scheduleId, ctx.organizationId, executionResult.error, manual, ctx);
domainHandlerCompleted = true;
taskStore.complete(task.id, {
kind: "backup",
exitCode: executionResult.exitCode,
result: executionResult.result,
warningDetails: executionResult.warningDetails,
});
return;
case "failed": {
await handleBackupFailure(scheduleId, ctx.organizationId, executionResult.error, manual, ctx);
domainHandlerCompleted = true;
taskStore.fail(task.id, toMessage(executionResult.error));
return;
}
case "cancelled":
return handleBackupCancellation(scheduleId, ctx.organizationId, executionResult.message);
await handleBackupCancellation(scheduleId, ctx.organizationId, executionResult.message);
domainHandlerCompleted = true;
taskStore.cancel(task.id, executionResult.message ?? "Backup was stopped by the user");
return;
}
} finally {
releaseLock();
}
} catch (error) {
if (abortController.signal.aborted) {
taskStore.cancel(task.id, "Backup was stopped by the user");
return;
}
return handleBackupFailure(scheduleId, ctx.organizationId, error, manual, ctx);
if (domainHandlerCompleted) {
throw error;
}
await handleBackupFailure(scheduleId, ctx.organizationId, error, manual, ctx);
taskStore.fail(task.id, toMessage(error));
} finally {
backupExecutor.untrack(scheduleId, abortController);
cache.del(cacheKeys.backup.progress(scheduleId));
@ -467,8 +546,26 @@ const stopBackup = async (scheduleId: number) => {
throw new NotFoundError("Backup schedule not found");
}
const activeTaskResource = {
organizationId,
kind: "backup",
resourceType: BACKUP_TASK_RESOURCE_TYPE,
resourceId: String(scheduleId),
} as const;
const activeTask = taskStore.findActiveByResource(activeTaskResource);
let shouldMarkActiveTaskStale = false;
if (activeTask) {
shouldMarkActiveTaskStale = tryCancelTask(activeTask.id, activeTaskResource);
}
try {
if (!(await backupExecutor.cancel(scheduleId))) {
if (shouldMarkActiveTaskStale) {
taskStore.markActiveStale({
...activeTaskResource,
error: "No live backup execution was found for this schedule",
});
}
throw new ConflictError("No backup is currently running for this schedule");
}
@ -498,10 +595,15 @@ const getMirrorSyncStatus = async (scheduleIdOrShortId: number | string, mirrorS
throw new NotFoundError("Mirror not found for this schedule");
}
const [sourceSnapshots, mirrorSnapshots] = await Promise.all([
restic.snapshots(schedule.repository.config, { tags: [schedule.shortId], organizationId }),
restic.snapshots(mirrorRepo.config, { tags: [schedule.shortId], organizationId }),
]);
const [sourceSnapshots, mirrorSnapshots] = await runEffectPromise(
Effect.all(
[
restic.snapshots(schedule.repository.config, { tags: [schedule.shortId], organizationId }),
restic.snapshots(mirrorRepo.config, { tags: [schedule.shortId], organizationId }),
],
{ concurrency: "unbounded" },
),
);
const mirrorSnapshotTimes = new Set(mirrorSnapshots.map((s) => s.time));

View file

@ -5,7 +5,7 @@ import { restic } from "../../../core/restic";
import { repoMutex } from "../../../core/repository-mutex";
import { serverEvents } from "../../../core/events";
import { cache, cacheKeys } from "../../../utils/cache";
import { toMessage } from "../../../utils/errors";
import { runEffectPromise, toMessage } from "../../../utils/errors";
import { getOrganizationId } from "~/server/core/request-context";
import { mirrorQueries, repositoryQueries, scheduleQueries } from "../backups.queries";
@ -31,7 +31,9 @@ export async function runForget(scheduleId: number, repositoryId?: string, organ
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`);
try {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId });
await runEffectPromise(
restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId }),
);
cache.delByPrefix(cacheKeys.repository.all(repository.id));
} finally {
releaseLock();
@ -118,11 +120,13 @@ export async function syncSnapshotsToMirror(
]);
try {
await restic.copy(sourceRepository.config, mirrorRepository.config, {
tag: schedule.shortId,
organizationId,
snapshotIds,
});
await runEffectPromise(
restic.copy(sourceRepository.config, mirrorRepository.config, {
tag: schedule.shortId,
organizationId,
snapshotIds,
}),
);
cache.delByPrefix(cacheKeys.repository.all(mirrorRepository.id));
} finally {
releaseLocks();
@ -206,7 +210,12 @@ async function copyToSingleMirror(
]);
try {
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId });
await runEffectPromise(
restic.copy(sourceRepository.config, mirror.repository.config, {
tag: schedule.shortId,
organizationId,
}),
);
cache.delByPrefix(cacheKeys.repository.all(mirror.repository.id));
} finally {
releaseLocks();

View file

@ -58,7 +58,7 @@ describe("shutdown", () => {
expect(updated!.status).toBe("mounted");
});
test("keeps legacy controller-local fallback unmount on shutdown", async () => {
test("keeps mounted status while running the legacy controller-local fallback unmount on shutdown", async () => {
config.flags.enableLocalAgent = false;
const events: string[] = [];
vi.spyOn(Scheduler, "stop").mockImplementation(async () => {
@ -85,6 +85,6 @@ describe("shutdown", () => {
expect(events).toEqual(["scheduler.stop", "agents.stop"]);
expect(runVolumeCommand).not.toHaveBeenCalled();
expect(updated).toBeDefined();
expect(updated!.status).toBe("unmounted");
expect(updated!.status).toBe("mounted");
});
});

View file

@ -0,0 +1,82 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { Scheduler } from "~/server/core/scheduler";
import { serverEvents } from "~/server/core/events";
import { config } from "~/server/core/config";
import { db } from "~/server/db/db";
import { backupsService } from "~/server/modules/backups/backups.service";
import { repositoriesService } from "~/server/modules/repositories/repositories.service";
import { notificationsService } from "~/server/modules/notifications/notifications.service";
import { volumeService } from "~/server/modules/volumes/volume.service";
import * as provisioningModule from "~/server/modules/provisioning/provisioning";
import { taskStore } from "~/server/modules/tasks/tasks.store";
import { createTestBackupSchedule } from "~/test/helpers/backup";
import { createTestRepository } from "~/test/helpers/repository";
import { createTestVolume } from "~/test/helpers/volume";
import { TEST_ORG_ID } from "~/test/helpers/organization";
const loadStartupModule = async () => {
const moduleUrl = new URL("../startup.ts", import.meta.url);
moduleUrl.searchParams.set("test", crypto.randomUUID());
return import(moduleUrl.href);
};
let originalEnableLocalAgent: boolean;
beforeEach(() => {
originalEnableLocalAgent = config.flags.enableLocalAgent;
config.flags.enableLocalAgent = true;
vi.spyOn(Scheduler, "start").mockResolvedValue();
vi.spyOn(Scheduler, "clear").mockResolvedValue();
vi.spyOn(Scheduler, "build").mockImplementation(() => ({ schedule: vi.fn() }));
vi.spyOn(provisioningModule, "syncProvisionedResources").mockResolvedValue();
vi.spyOn(backupsService, "cleanupOrphanedSchedules").mockResolvedValue({ deletedSchedules: 0 });
vi.spyOn(volumeService, "updateVolume").mockResolvedValue(undefined as never);
vi.spyOn(repositoriesService, "updateRepository").mockResolvedValue(undefined as never);
vi.spyOn(notificationsService, "updateDestination").mockResolvedValue(undefined as never);
});
afterEach(() => {
config.flags.enableLocalAgent = originalEnableLocalAgent;
vi.restoreAllMocks();
});
test("marks active tasks stale and keeps stuck backup schedule recovery silent", async () => {
const emitSpy = vi.spyOn(serverEvents, "emit");
const notificationSpy = vi.spyOn(notificationsService, "sendBackupNotification").mockResolvedValue();
const volume = await createTestVolume();
const repository = await createTestRepository();
const schedule = await createTestBackupSchedule({
volumeId: volume.id,
repositoryId: repository.id,
lastBackupStatus: "in_progress",
});
const task = taskStore.create({
id: "task-startup-active",
organizationId: TEST_ORG_ID,
resourceType: "backup_schedule",
resourceId: String(schedule.id),
targetAgentId: "local",
input: {
kind: "backup",
scheduleId: schedule.id,
scheduleShortId: schedule.shortId,
manual: false,
},
});
taskStore.markRunning(task.id);
const { startup } = await loadStartupModule();
await startup();
const updatedTask = await db.query.tasksTable.findFirst({ where: { id: task.id } });
const updatedSchedule = await db.query.backupSchedulesTable.findFirst({ where: { id: schedule.id } });
expect(updatedTask?.status).toBe("stale");
expect(updatedTask?.error).toBe("Zerobyte was restarted before this task completed");
expect(updatedTask?.finishedAt).toEqual(expect.any(Number));
expect(updatedSchedule?.lastBackupStatus).toBe("warning");
expect(updatedSchedule?.lastBackupError).toBe("Zerobyte was restarted during the last scheduled backup");
expect(emitSpy).not.toHaveBeenCalledWith("backup:completed", expect.anything());
expect(notificationSpy).not.toHaveBeenCalled();
});

View file

@ -18,7 +18,7 @@ export const shutdown = async () => {
for (const volume of volumes) {
const { status, error } = await withContext({ organizationId: volume.organizationId }, () =>
volumeService.unmountVolume(volume.shortId),
volumeService.unmountVolume(volume.shortId, { persistStatus: false }),
).catch((error) => ({ status: "error" as const, error: toMessage(error) }));
logger.info(`Volume ${volume.name} unmount status: ${status}${error ? `, error: ${error}` : ""}`);

View file

@ -18,6 +18,7 @@ import { config } from "~/server/core/config";
import { syncProvisionedResources } from "../provisioning/provisioning";
import { toMessage } from "~/server/utils/errors";
import { LOCAL_AGENT_ID } from "../agents/constants";
import { taskStore } from "../tasks/tasks.store";
const ensureLatestConfigurationSchema = async () => {
const volumes = await db.query.volumesTable.findMany({});
@ -98,6 +99,16 @@ export const startup = async () => {
}
}
try {
const staleTasks = taskStore.markActiveStale({ error: "Zerobyte was restarted before this task completed" });
if (staleTasks.length > 0) {
logger.warn(`Marked ${staleTasks.length} active task(s) stale during startup`);
}
} catch (err) {
logger.error(`Failed to mark stale tasks on startup: ${toMessage(err)}`);
}
await db
.update(backupSchedulesTable)
.set({

View file

@ -8,6 +8,7 @@ import { restic } from "~/server/core/restic";
import { createTestSession } from "~/test/helpers/auth";
import { generateShortId } from "~/server/utils/id";
import { provisionedResourcesSchema, readProvisionedResourcesFile, syncProvisionedResources } from "./provisioning";
import { Effect } from "effect";
describe("provisioning", () => {
let session: Awaited<ReturnType<typeof createTestSession>>;
@ -17,7 +18,7 @@ describe("provisioning", () => {
});
beforeEach(async () => {
vi.spyOn(restic, "init").mockResolvedValue({ success: true, error: null });
vi.spyOn(restic, "init").mockReturnValue(Effect.succeed({ success: true, error: null }));
await db.delete(backupSchedulesTable);
await db.delete(volumesTable);
@ -361,7 +362,7 @@ describe("provisioning", () => {
const { organizationId } = session;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-provisioning-"));
const provisioningPath = path.join(tempDir, "provisioning.json");
const initMock = vi.fn(() => Promise.resolve({ success: true, error: null }));
const initMock = vi.fn(() => Effect.succeed({ success: true, error: null }));
vi.spyOn(restic, "init").mockImplementation(initMock);

View file

@ -18,7 +18,7 @@ import { mapRepositoryConfigSecrets } from "~/server/modules/repositories/reposi
import { mapVolumeConfigSecrets } from "~/server/modules/volumes/volume-config-secrets";
import { BACKEND_TYPES, volumeConfigSchema, type BackendConfig } from "@zerobyte/contracts/volumes";
import { cryptoUtils } from "~/server/utils/crypto";
import { toMessage } from "~/server/utils/errors";
import { runEffectPromise, toMessage } from "~/server/utils/errors";
import { generateShortId } from "~/server/utils/id";
const envSecretPrefix = "env://";
@ -171,9 +171,12 @@ const syncProvisionedRepositories = async (repositories: ProvisionedRepository[]
});
if (!repository.config.isExistingRepository) {
const result = await restic
.init(encryptedConfig, repository.organizationId, { timeoutMs: appConfig.serverIdleTimeout * 1000 })
.catch((error) => ({ success: false, error }));
const result = await runEffectPromise(
restic.init(encryptedConfig, {
organizationId: repository.organizationId,
timeoutMs: appConfig.serverIdleTimeout * 1000,
}),
).catch((error) => ({ success: false, error }));
await db
.update(repositoriesTable)
@ -186,7 +189,9 @@ const syncProvisionedRepositories = async (repositories: ProvisionedRepository[]
.where(eq(repositoriesTable.id, id));
if (result.error) {
logger.error(`Provisioned repository ${repository.name} failed to initialize: ${toMessage(result.error)}`);
logger.error(
`Provisioned repository ${repository.name} failed to initialize: ${toMessage(result.error)}`,
);
}
}
continue;

View file

@ -8,6 +8,7 @@ import { generateShortId } from "~/server/utils/id";
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
import type { RepositoryConfig } from "@zerobyte/core/restic";
import { restic } from "~/server/core/restic";
import { Effect } from "effect";
const app = createApp();
@ -18,7 +19,7 @@ beforeAll(async () => {
});
beforeEach(() => {
vi.spyOn(restic, "init").mockResolvedValue({ success: true, error: null });
vi.spyOn(restic, "init").mockReturnValue(Effect.succeed({ success: true, error: null }));
});
afterEach(() => {
@ -327,15 +328,17 @@ describe("repositories updates", () => {
});
describe("delete snapshot", () => {
test("should return 500 when restic deleteSnapshot throws ResticError", async () => {
test("should return ResticError details when restic deleteSnapshot fails", async () => {
const repository = await createRepositoryRecord(session.organizationId);
const { restic } = await import("~/server/core/restic");
const { ResticError } = await import("@zerobyte/core/restic");
const deleteSnapshotSpy = vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => {
throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden");
});
const deleteSnapshotSpy = vi
.spyOn(restic, "deleteSnapshot")
.mockImplementation(() =>
Effect.fail(new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden")),
);
try {
const res = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/snap123`, {
@ -345,8 +348,10 @@ describe("repositories updates", () => {
expect(res.status).toBe(500);
const body = await res.json();
expect(body.message).toBe("Command failed: An error occurred while executing the command.");
expect(body.details).toBe("Fatal: unexpected HTTP response (403): 403 Forbidden");
expect(body).toEqual({
message: "Command failed: An error occurred while executing the command.",
details: "Fatal: unexpected HTTP response (403): 403 Forbidden",
});
} finally {
deleteSnapshotSpy.mockRestore();
}
@ -359,27 +364,34 @@ describe("repositories updates", () => {
const stream = new PassThrough();
const expectedContent = "downloaded snapshot contents";
const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([
{
id: "test-snapshot",
short_id: "test-snapshot",
time: new Date().toISOString(),
paths: ["/mnt/project"],
hostname: "host",
},
]);
const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({
stream,
completion: Promise.resolve(),
abort: vi.fn(),
});
const snapshotsSpy = vi.spyOn(restic, "snapshots").mockReturnValue(
Effect.succeed([
{
id: "test-snapshot",
short_id: "test-snapshot",
time: new Date().toISOString(),
paths: ["/mnt/project"],
hostname: "host",
},
]),
);
const dumpSpy = vi.spyOn(restic, "dump").mockReturnValue(
Effect.succeed({
stream: stream as never,
completion: Promise.resolve(),
abort: vi.fn(),
}),
);
try {
const controller = new AbortController();
const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, {
headers: session.headers,
signal: controller.signal,
});
const response = await app.request(
`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`,
{
headers: session.headers,
signal: controller.signal,
},
);
queueMicrotask(() => {
controller.abort();
@ -397,20 +409,24 @@ describe("repositories updates", () => {
const repository = await createRepositoryRecord(session.organizationId);
const stream = new PassThrough();
const snapshotsSpy = vi.spyOn(restic, "snapshots").mockResolvedValue([
{
id: "test-snapshot",
short_id: "test-snapshot",
time: new Date().toISOString(),
paths: ["/mnt/project"],
hostname: "host",
},
]);
const dumpSpy = vi.spyOn(restic, "dump").mockResolvedValue({
stream,
completion: Promise.resolve(),
abort: vi.fn(),
});
const snapshotsSpy = vi.spyOn(restic, "snapshots").mockReturnValue(
Effect.succeed([
{
id: "test-snapshot",
short_id: "test-snapshot",
time: new Date().toISOString(),
paths: ["/mnt/project"],
hostname: "host",
},
]),
);
const dumpSpy = vi.spyOn(restic, "dump").mockReturnValue(
Effect.succeed({
stream: stream as never,
completion: Promise.resolve(),
abort: vi.fn(),
}),
);
try {
stream.end("downloaded snapshot contents");

View file

@ -4,21 +4,26 @@ import * as os from "node:os";
import nodePath from "node:path";
import { randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { Effect } from "effect";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import type { RepositoryConfig } from "@zerobyte/core/restic";
import { REPOSITORY_BASE } from "~/server/core/constants";
import { config } from "~/server/core/config";
import { withContext } from "~/server/core/request-context";
import { db } from "~/server/db/db";
import { repositoriesTable } from "~/server/db/schema";
import { agentsTable, repositoriesTable, type RepositoryInsert } from "~/server/db/schema";
import { generateShortId } from "~/server/utils/id";
import { restic } from "~/server/core/restic";
import { agentManager, type RestoreExecutionResult } from "~/server/modules/agents/agents-manager";
import { createTestSession } from "~/test/helpers/auth";
import { createTestBackupSchedule } from "~/test/helpers/backup";
import { cache, cacheKeys } from "~/server/utils/cache";
import { ResticError } from "@zerobyte/core/restic/server";
import { repoMutex } from "~/server/core/repository-mutex";
import { taskStore } from "~/server/modules/tasks/tasks.store";
import { repositoriesService } from "../repositories.service";
const createTestRepository = async (organizationId: string) => {
const createTestRepository = async (organizationId: string, overrides: Partial<RepositoryInsert> = {}) => {
const id = randomUUID();
const shortId = generateShortId();
const [repository] = await db
@ -32,6 +37,7 @@ const createTestRepository = async (organizationId: string) => {
compressionMode: "auto",
status: "healthy",
organizationId,
...overrides,
})
.returning();
return repository;
@ -44,7 +50,7 @@ beforeAll(async () => {
});
describe("repositoriesService.createRepository", () => {
const initMock = vi.fn(() => Promise.resolve({ success: true, error: null }));
const initMock = vi.fn(() => Effect.succeed({ success: true, error: null }));
beforeEach(() => {
initMock.mockClear();
@ -114,9 +120,13 @@ describe("repositoriesService.createRepository", () => {
test("keeps an explicit local repository path unchanged when importing existing repository", async () => {
// arrange
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true };
const config: RepositoryConfig = {
backend: "local",
path: explicitPath,
isExistingRepository: true,
};
vi.spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([]));
vi.spyOn(restic, "snapshots").mockImplementation(() => Effect.succeed([]));
// act
const result = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
@ -174,7 +184,7 @@ describe("repositoriesService repository stats", () => {
snapshots_count: 3,
};
const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats);
const statsSpy = vi.spyOn(restic, "stats").mockReturnValue(Effect.succeed(expectedStats));
const refreshed = await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.refreshRepositoryStats(repository.shortId),
@ -183,7 +193,9 @@ describe("repositoriesService repository stats", () => {
expect(refreshed).toEqual(expectedStats);
expect(statsSpy).toHaveBeenCalledTimes(1);
const persistedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } });
const persistedRepository = await db.query.repositoriesTable.findFirst({
where: { id: repository.id },
});
expect(persistedRepository?.stats).toEqual(expectedStats);
expect(typeof persistedRepository?.statsUpdatedAt).toBe("number");
@ -196,13 +208,148 @@ describe("repositoriesService repository stats", () => {
});
});
describe("repositoriesService.listSnapshotFiles", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("limits concurrent restic ls commands per repository", async () => {
const repository = await createTestRepository(session.organizationId);
let active = 0;
let maxActive = 0;
let releaseAll = false;
let exclusiveAcquired = false;
let releaseExclusive: (() => void) | undefined;
let exclusivePromise: Promise<() => void> | undefined;
const releaseWaiters: Array<() => void> = [];
const exclusiveController = new AbortController();
const releaseWaitingCommands = () => {
const waiters = releaseWaiters.splice(0);
for (const release of waiters) {
release();
}
};
const resolveWithin = async <T>(promise: Promise<T>, timeoutMs: number) => {
return await new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Expected promise to resolve within ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timeout);
resolve(value);
},
(error) => {
clearTimeout(timeout);
reject(error);
},
);
});
};
const lsSpy = vi.spyOn(restic, "ls").mockImplementation((_config, snapshotId, _path, options) =>
Effect.promise(async () => {
active++;
maxActive = Math.max(maxActive, active);
try {
if (!releaseAll) {
await new Promise<void>((resolve) => releaseWaiters.push(resolve));
}
return {
snapshot: {
id: snapshotId,
short_id: snapshotId,
time: new Date().toISOString(),
tree: "tree",
paths: ["/"],
hostname: "host",
struct_type: "snapshot" as const,
message_type: "snapshot" as const,
},
nodes: [],
pagination: {
offset: options.offset ?? 0,
limit: options.limit ?? 500,
total: 0,
hasMore: false,
},
};
} finally {
active--;
}
}),
);
const calls = Array.from({ length: 4 }, (_, index) =>
withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.listSnapshotFiles(repository.shortId, `snapshot-${index}`, "/", {
offset: 0,
limit: 100,
}),
),
);
try {
await waitForExpect(() => {
expect(releaseWaiters).toHaveLength(2);
});
expect(maxActive).toBe(2);
exclusivePromise = repoMutex
.acquireExclusive(repository.id, "delete", exclusiveController.signal)
.then((release) => {
exclusiveAcquired = true;
releaseExclusive = release;
return release;
});
releaseWaitingCommands();
releaseExclusive = await resolveWithin(exclusivePromise, 2000);
expect(exclusiveAcquired).toBe(true);
expect(active).toBe(0);
releaseExclusive();
releaseExclusive = undefined;
await waitForExpect(() => {
expect(releaseWaiters).toHaveLength(2);
});
expect(maxActive).toBe(2);
releaseWaitingCommands();
await Promise.all(calls);
} finally {
if (releaseExclusive) {
releaseExclusive();
} else {
exclusiveController.abort();
}
releaseAll = true;
releaseWaitingCommands();
await Promise.allSettled(calls);
if (exclusivePromise) {
await Promise.allSettled([exclusivePromise]);
}
}
expect(lsSpy).toHaveBeenCalledTimes(4);
expect(maxActive).toBeLessThanOrEqual(2);
});
});
describe("repositoriesService.dumpSnapshot", () => {
afterEach(() => {
vi.restoreAllMocks();
});
const createDumpResult = (payload: string) => ({
stream: Readable.from([payload]),
stream: Readable.from([payload]) as never,
completion: Promise.resolve(),
abort: () => {},
});
@ -242,22 +389,24 @@ describe("repositoriesService.dumpSnapshot", () => {
organizationId,
});
vi.spyOn(restic, "snapshots").mockResolvedValue([
{
id: snapshotId,
short_id: snapshotId,
time: new Date().toISOString(),
paths: snapshotPaths ?? [basePath],
hostname: "host",
},
]);
vi.spyOn(restic, "snapshots").mockReturnValue(
Effect.succeed([
{
id: snapshotId,
short_id: snapshotId,
time: new Date().toISOString(),
paths: snapshotPaths ?? [basePath],
hostname: "host",
},
]),
);
const dumpMock = vi.fn((_config: unknown, snapshotRef: string, options: Parameters<typeof restic.dump>[2]) => {
if (!options.path) {
throw new Error("Expected dump path in test");
}
return Promise.resolve(
return Effect.succeed(
createDumpResult(
JSON.stringify({
snapshotRef,
@ -401,7 +550,37 @@ describe("repositoriesService.dumpSnapshot", () => {
});
describe("repositoriesService.restoreSnapshot", () => {
let originalEnableLocalAgent: boolean;
const createPendingRestoreStart = () => ({
status: "started" as const,
result: new Promise<RestoreExecutionResult>(() => {}),
});
const resolveWithin = async <T>(promise: Promise<T>, timeoutMs: number) => {
return await new Promise<T>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Expected promise to resolve within ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timeout);
resolve(value);
},
(error) => {
clearTimeout(timeout);
reject(error);
},
);
});
};
beforeEach(() => {
originalEnableLocalAgent = config.flags.enableLocalAgent;
config.flags.enableLocalAgent = true;
});
afterEach(() => {
config.flags.enableLocalAgent = originalEnableLocalAgent;
vi.restoreAllMocks();
});
@ -409,45 +588,39 @@ describe("repositoriesService.restoreSnapshot", () => {
const organizationId = session.organizationId;
const repository = await createTestRepository(organizationId);
vi.spyOn(restic, "snapshots").mockResolvedValue([
{
id: "snapshot-restore",
short_id: "snapshot-restore",
time: new Date().toISOString(),
paths,
hostname: "host",
},
]);
const restoreMock = vi.fn(() =>
Promise.resolve({
message_type: "summary" as const,
seconds_elapsed: 1,
percent_done: 100,
files_skipped: 0,
total_files: 1,
files_restored: 1,
total_bytes: 1,
bytes_restored: 1,
}),
vi.spyOn(restic, "snapshots").mockReturnValue(
Effect.succeed([
{
id: "snapshot-restore",
short_id: "snapshot-restore",
time: new Date().toISOString(),
paths,
hostname: "host",
},
]),
);
vi.spyOn(restic, "restore").mockImplementation(restoreMock);
const restoreMock = vi.fn(() => Promise.resolve(createPendingRestoreStart()));
vi.spyOn(agentManager, "startRestore").mockImplementation(restoreMock);
return {
organizationId,
userId: session.user.id,
repositoryId: repository.id,
repositoryShortId: repository.shortId,
restoreMock,
};
};
test("rejects restore targets inside protected roots", async () => {
test("rejects protected targets even when the local agent is enabled", async () => {
const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario();
const targetPath = nodePath.join(os.tmpdir(), "zerobyte-restore-target");
await expect(
withContext({ organizationId, userId }, () =>
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", { targetPath }),
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", {
targetPath,
}),
),
).rejects.toThrow("Restore target path is not allowed");
@ -460,23 +633,266 @@ describe("repositoriesService.restoreSnapshot", () => {
try {
await withContext({ organizationId, userId }, () =>
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", { targetPath }),
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", {
targetPath,
}),
);
} finally {
await fs.rm(targetPath, { recursive: true, force: true });
}
expect(restoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backend: "local",
}),
"snapshot-restore",
targetPath,
expect.objectContaining({
await waitForExpect(() => {
expect(restoreMock).toHaveBeenCalledWith(
"local",
expect.objectContaining({
payload: expect.objectContaining({
snapshotId: "snapshot-restore",
target: targetPath,
repositoryConfig: expect.objectContaining({ backend: "local" }),
options: expect.objectContaining({
organizationId,
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
}),
}),
}),
);
});
});
test("rejects starting a second active restore for the same snapshot", async () => {
const { organizationId, userId, repositoryShortId } = await setupRestoreSnapshotScenario();
const targetPath = await fs.mkdtemp(nodePath.join(process.cwd(), "restore-target-"));
try {
await withContext({ organizationId, userId }, () =>
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", {
targetPath,
}),
);
await expect(
withContext({ organizationId, userId }, () =>
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", {
targetPath,
}),
),
).rejects.toThrow("A restore is already running for this snapshot");
} finally {
await fs.rm(targetPath, { recursive: true, force: true });
}
});
test("returns a restore id while waiting for the repository mutex", async () => {
const { organizationId, userId, repositoryId, repositoryShortId, restoreMock } =
await setupRestoreSnapshotScenario();
const targetPath = await fs.mkdtemp(nodePath.join(process.cwd(), "restore-target-"));
await withContext({ organizationId, userId }, () =>
repositoriesService.getSnapshotDetails(repositoryShortId, "snapshot-restore"),
);
let finishRestore: (result: RestoreExecutionResult) => void = () => {};
const restoreResult = new Promise<RestoreExecutionResult>((resolve) => {
finishRestore = resolve;
});
restoreMock.mockResolvedValueOnce({ status: "started", result: restoreResult });
const releaseExclusive = await repoMutex.acquireExclusive(repositoryId, "check");
let restoreId = "";
try {
const restoreStart = withContext({ organizationId, userId }, () =>
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", {
targetPath,
}),
);
const result = await resolveWithin(restoreStart, 1000);
restoreId = result.restoreId;
expect(result.status).toBe("started");
expect(restoreMock).not.toHaveBeenCalled();
const task = taskStore.findActiveByResource({
organizationId,
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
kind: "restore",
resourceType: "repository",
resourceId: repositoryShortId,
});
expect(task?.id).toBe(restoreId);
expect(task?.status).toBe("queued");
} finally {
releaseExclusive();
await fs.rm(targetPath, { recursive: true, force: true });
}
await waitForExpect(() => {
expect(restoreMock).toHaveBeenCalledTimes(1);
});
finishRestore({
status: "completed",
result: {
message_type: "summary",
files_skipped: 0,
files_restored: 1,
},
});
await waitForExpect(() => {
expect(
taskStore.findActiveByResource({
organizationId,
kind: "restore",
resourceType: "repository",
resourceId: repositoryShortId,
}),
).toBeNull();
});
});
test("routes restore to the requested target agent", async () => {
const organizationId = session.organizationId;
const agentId = `agent-${randomUUID()}`;
const repository = await createTestRepository(organizationId, {
type: "s3",
config: {
backend: "s3",
endpoint: "https://s3.example.com",
bucket: "bucket",
accessKeyId: "access-key",
secretAccessKey: "secret-key",
},
});
await db.insert(agentsTable).values({
id: agentId,
organizationId,
name: "Remote Agent",
kind: "remote",
status: "online",
capabilities: {},
updatedAt: Date.now(),
});
vi.spyOn(restic, "snapshots").mockReturnValue(
Effect.succeed([
{
id: "snapshot-restore",
short_id: "snapshot-restore",
time: new Date().toISOString(),
paths: ["/var/lib/zerobyte/volumes/vol123/_data"],
hostname: "host",
},
]),
);
const restoreMock = vi.fn(() => Promise.resolve(createPendingRestoreStart()));
vi.spyOn(agentManager, "startRestore").mockImplementation(restoreMock);
const targetPath = await fs.mkdtemp(nodePath.join(process.cwd(), "restore-target-"));
try {
await withContext({ organizationId, userId: session.user.id }, () =>
repositoriesService.restoreSnapshot(repository.shortId, "snapshot-restore", {
targetPath,
targetAgentId: agentId,
}),
);
} finally {
await fs.rm(targetPath, { recursive: true, force: true });
}
await waitForExpect(() => {
expect(restoreMock).toHaveBeenCalledWith(
agentId,
expect.objectContaining({
payload: expect.objectContaining({
target: targetPath,
}),
}),
);
});
});
test("rejects a target agent outside the current organization", async () => {
const organizationId = session.organizationId;
const otherSession = await createTestSession();
const otherAgentId = `agent-${randomUUID()}`;
const repository = await createTestRepository(organizationId, {
type: "s3",
config: {
backend: "s3",
endpoint: "https://s3.example.com",
bucket: "bucket",
accessKeyId: "access-key",
secretAccessKey: "secret-key",
},
});
await db.insert(agentsTable).values({
id: otherAgentId,
organizationId: otherSession.organizationId,
name: "Other Org Agent",
kind: "remote",
status: "online",
capabilities: {},
updatedAt: Date.now(),
});
vi.spyOn(restic, "snapshots").mockReturnValue(
Effect.succeed([
{
id: "snapshot-restore",
short_id: "snapshot-restore",
time: new Date().toISOString(),
paths: ["/var/lib/zerobyte/volumes/vol123/_data"],
hostname: "host",
},
]),
);
const restoreMock = vi.fn(() => Promise.resolve(createPendingRestoreStart()));
vi.spyOn(agentManager, "startRestore").mockImplementation(restoreMock);
await expect(
withContext({ organizationId, userId: session.user.id }, () =>
repositoriesService.restoreSnapshot(repository.shortId, "snapshot-restore", {
targetAgentId: otherAgentId,
}),
),
).rejects.toThrow("Restore target agent not found");
expect(restoreMock).not.toHaveBeenCalled();
});
test("uses controller-local restore fallback when local agent supervision is disabled", async () => {
config.flags.enableLocalAgent = false;
const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario();
const resticRestoreMock = vi.spyOn(restic, "restore").mockReturnValue(
Effect.succeed({
message_type: "summary" as const,
files_skipped: 0,
files_restored: 1,
}),
);
const targetPath = await fs.mkdtemp(nodePath.join(process.cwd(), "restore-target-"));
try {
await withContext({ organizationId, userId }, () =>
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", {
targetPath,
}),
);
} finally {
await fs.rm(targetPath, { recursive: true, force: true });
}
expect(restoreMock).not.toHaveBeenCalled();
await waitForExpect(() => {
expect(resticRestoreMock).toHaveBeenCalledWith(
expect.objectContaining({ backend: "local" }),
"snapshot-restore",
targetPath,
expect.objectContaining({
organizationId,
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
signal: expect.any(AbortSignal),
}),
);
});
});
test("rejects original-location restore for snapshots with non-posix source paths", async () => {
@ -504,23 +920,30 @@ describe("repositoriesService.restoreSnapshot", () => {
try {
await withContext({ organizationId, userId }, () =>
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", { targetPath }),
repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", {
targetPath,
}),
);
} finally {
await fs.rm(targetPath, { recursive: true, force: true });
}
expect(restoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backend: "local",
}),
"snapshot-restore",
targetPath,
expect.objectContaining({
organizationId,
basePath: "/",
}),
);
await waitForExpect(() => {
expect(restoreMock).toHaveBeenCalledWith(
"local",
expect.objectContaining({
payload: expect.objectContaining({
snapshotId: "snapshot-restore",
target: targetPath,
repositoryConfig: expect.objectContaining({ backend: "local" }),
options: expect.objectContaining({
organizationId,
basePath: "/",
}),
}),
}),
);
});
});
});
@ -531,9 +954,14 @@ describe("repositoriesService.getRetentionCategories", () => {
test("recomputes retention categories after repository cache invalidation", async () => {
const organizationId = session.organizationId;
const schedule = await createTestBackupSchedule({ organizationId, retentionPolicy: { keepLast: 1 } });
const schedule = await createTestBackupSchedule({
organizationId,
retentionPolicy: { keepLast: 1 },
});
const repository = await db.query.repositoriesTable.findFirst({ where: { id: schedule.repositoryId } });
const repository = await db.query.repositoriesTable.findFirst({
where: { id: schedule.repositoryId },
});
expect(repository).toBeTruthy();
if (!repository) {
@ -569,8 +997,8 @@ describe("repositoriesService.getRetentionCategories", () => {
});
const forgetSpy = vi.spyOn(restic, "forget");
forgetSpy.mockResolvedValueOnce(buildForgetResponse(oldSnapshotId));
forgetSpy.mockResolvedValueOnce(buildForgetResponse(newSnapshotId));
forgetSpy.mockReturnValueOnce(Effect.succeed(buildForgetResponse(oldSnapshotId)));
forgetSpy.mockReturnValueOnce(Effect.succeed(buildForgetResponse(newSnapshotId)));
const firstCategories = await withContext({ organizationId, userId: session.user.id }, () =>
repositoriesService.getRetentionCategories(repository.shortId, schedule.shortId),
@ -606,8 +1034,8 @@ describe("repositoriesService.deleteSnapshot", () => {
snapshots_count: 1,
};
vi.spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true });
const statsSpy = vi.spyOn(restic, "stats").mockResolvedValue(expectedStats);
vi.spyOn(restic, "deleteSnapshot").mockReturnValue(Effect.succeed({ success: true }));
const statsSpy = vi.spyOn(restic, "stats").mockReturnValue(Effect.succeed(expectedStats));
await withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>
repositoriesService.deleteSnapshot(repository.shortId, "snap-1"),
@ -617,7 +1045,9 @@ describe("repositoriesService.deleteSnapshot", () => {
expect(statsSpy).toHaveBeenCalledTimes(1);
});
const updatedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } });
const updatedRepository = await db.query.repositoriesTable.findFirst({
where: { id: repository.id },
});
expect(updatedRepository?.stats).toEqual(expectedStats);
expect(typeof updatedRepository?.statsUpdatedAt).toBe("number");
});
@ -625,9 +1055,9 @@ describe("repositoriesService.deleteSnapshot", () => {
test("should throw original error when restic deleteSnapshot fails", async () => {
const repository = await createTestRepository(session.organizationId);
vi.spyOn(restic, "deleteSnapshot").mockImplementation(async () => {
throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden");
});
vi.spyOn(restic, "deleteSnapshot").mockImplementation(() =>
Effect.fail(new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden")),
);
await expect(
withContext({ organizationId: session.organizationId, userId: session.user.id }, () =>

View file

@ -3,7 +3,7 @@ import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "@zero
import { z } from "zod";
import { getOrganizationId } from "~/server/core/request-context";
import { restic } from "~/server/core/restic";
import { toMessage } from "~/server/utils/errors";
import { runEffectPromise, toMessage } from "~/server/utils/errors";
import { safeJsonParse } from "@zerobyte/core/utils";
import { logger } from "@zerobyte/core/node";
import { db } from "~/server/db/db";
@ -17,7 +17,7 @@ class AbortError extends Error {
const runUnlockStep = async (config: RepositoryConfig, signal?: AbortSignal) => {
const orgId = getOrganizationId();
const result = await restic.unlock(config, { signal, organizationId: orgId }).then(
const result = await runEffectPromise(restic.unlock(config, { signal, organizationId: orgId })).then(
(result) => ({ success: true, message: result.message, error: null }),
(error) => ({ success: false, message: null, error: toMessage(error) }),
);
@ -32,7 +32,9 @@ const runUnlockStep = async (config: RepositoryConfig, signal?: AbortSignal) =>
const runCheckStep = async (config: RepositoryConfig, signal: AbortSignal) => {
const orgId = getOrganizationId();
const result = await restic.check(config, { readData: false, signal, organizationId: orgId }).then(
const result = await runEffectPromise(
restic.check(config, { readData: false, signal, organizationId: orgId }),
).then(
(result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
);
@ -47,7 +49,7 @@ const runCheckStep = async (config: RepositoryConfig, signal: AbortSignal) => {
const runRepairIndexStep = async (config: RepositoryConfig, signal: AbortSignal) => {
const orgId = getOrganizationId();
const result = await restic.repairIndex(config, { signal, organizationId: orgId }).then(
const result = await runEffectPromise(restic.repairIndex(config, { signal, organizationId: orgId })).then(
(result) => ({ success: true, output: result.output, error: null }),
(error) => ({ success: false, output: null, error: toMessage(error) }),
);

View file

@ -182,7 +182,10 @@ export const repositoriesController = new Hono()
const offset = Math.max(0, query.offset ?? 0);
const limit = Math.min(1000, Math.max(1, query.limit ?? 500));
const result = await repositoriesService.listSnapshotFiles(shortId, snapshotId, decodedPath, { offset, limit });
const result = await repositoriesService.listSnapshotFiles(shortId, snapshotId, decodedPath, {
offset,
limit,
});
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
@ -235,7 +238,7 @@ export const repositoriesController = new Hono()
const { snapshotId, ...options } = c.req.valid("json");
const result = await repositoriesService.restoreSnapshot(shortId, snapshotId, options);
return c.json<RestoreSnapshotDto>(result, 200);
return c.json<RestoreSnapshotDto>(result, 202);
})
.post("/:shortId/doctor", startDoctorDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));

View file

@ -343,14 +343,13 @@ export const restoreSnapshotBody = z.object({
excludeXattr: z.array(z.string()).optional(),
delete: z.boolean().optional(),
targetPath: z.string().optional(),
targetAgentId: z.string().optional(),
overwrite: overwriteModeSchema.optional(),
});
const restoreSnapshotResponse = z.object({
success: z.boolean(),
message: z.string(),
filesRestored: z.number(),
filesSkipped: z.number(),
restoreId: z.string(),
status: z.literal("started"),
});
export type RestoreSnapshotDto = z.infer<typeof restoreSnapshotResponse>;
@ -360,8 +359,8 @@ export const restoreSnapshotDto = describeRoute({
tags: ["Repositories"],
operationId: "restoreSnapshot",
responses: {
200: {
description: "Snapshot restored successfully",
202: {
description: "Snapshot restore started",
content: {
"application/json": {
schema: resolver(restoreSnapshotResponse),

View file

@ -6,6 +6,7 @@ import {
type CompressionMode,
type OverwriteMode,
type RepositoryConfig,
type ResticDumpStream,
type ResticStatsDto,
repositoryConfigSchema,
} from "@zerobyte/core/restic";
@ -18,9 +19,9 @@ import { logger } from "@zerobyte/core/node";
import { parseRetentionCategories, type RetentionCategory } from "~/server/utils/retention-categories";
import { repoMutex } from "../../core/repository-mutex";
import { db } from "../../db/db";
import { repositoriesTable, type Repository } from "../../db/schema";
import { repositoriesTable, type Repository, type RepositoryInsert } from "../../db/schema";
import { cache, cacheKeys } from "../../utils/cache";
import { toMessage } from "../../utils/errors";
import { runEffectPromise, toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id";
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "@zerobyte/core/restic/server";
import { restic, resticDeps } from "../../core/restic";
@ -29,11 +30,23 @@ import type { DumpPathKind, UpdateRepositoryBody } from "./repositories.dto";
import { findCommonAncestor } from "@zerobyte/core/utils";
import { prepareSnapshotDump } from "./helpers/dump";
import { executeDoctor } from "./helpers/doctor";
import { restoreExecutor } from "./restore-executor";
import type { ShortId } from "~/server/utils/branded";
import { decryptRepositoryConfig, encryptRepositoryConfig } from "./repository-config-secrets";
import { getScheduleByIdOrShortId } from "../backups/helpers/backup-schedule-lookups";
import type { RestoreExecutionProgress, RestoreExecutionResult } from "../agents/agents-manager";
import { agentsService } from "../agents/agents.service";
import { LOCAL_AGENT_ID } from "../agents/constants";
import { taskStore } from "../tasks/tasks.store";
import type { ParsedTask, TaskInput } from "../tasks/tasks.schemas";
import { Effect } from "effect";
const runningDoctors = new Map<string, AbortController>();
const lsLimiters = new Map<string, Effect.Semaphore>();
const RESTORE_TASK_RESOURCE_TYPE = "repository";
type RestoreTaskInput = Extract<TaskInput, { kind: "restore" }>;
type RestoreTask = ParsedTask & { kind: "restore"; input: RestoreTaskInput };
const emptyRepositoryStats: ResticStatsDto = {
total_size: 0,
@ -54,6 +67,184 @@ const getBlockedRestoreTargets = () => {
].filter((e) => e !== undefined);
};
const assertAllowedControllerLocalRestoreTarget = (target: string) => {
const resolvedTarget = nodePath.resolve(target);
for (const blockedTarget of getBlockedRestoreTargets()) {
if (isPathWithin(blockedTarget, resolvedTarget)) {
throw new BadRequestError(
"Restore target path is not allowed. Restoring to this path could overwrite critical system files or application data.",
);
}
}
};
const isRestoreTask = (task: ParsedTask): task is RestoreTask =>
task.kind === "restore" && task.input.kind === "restore";
const asRestoreTask = (task: ParsedTask, restoreId: string, eventName: string) => {
if (!isRestoreTask(task)) {
logger.warn(`Received ${eventName} for non-restore task ${restoreId}`);
return null;
}
return task;
};
const updateActiveRestoreTask = (restoreId: string, eventName: string, update: () => ParsedTask) => {
try {
return asRestoreTask(update(), restoreId, eventName);
} catch (error) {
logger.warn(`Received ${eventName} for inactive restore ${restoreId}: ${toMessage(error)}`);
return null;
}
};
const getLsLimiter = (repositoryId: string) => {
let limiter = lsLimiters.get(repositoryId);
if (!limiter) {
limiter = Effect.runSync(Effect.makeSemaphore(2));
lsLimiters.set(repositoryId, limiter);
}
return limiter;
};
const findActiveRestoreTask = (
organizationId: string,
repositoryShortId: string,
snapshotId: string,
): RestoreTask | null => {
return (
taskStore
.listActiveByResource({
organizationId,
kind: "restore",
resourceType: RESTORE_TASK_RESOURCE_TYPE,
resourceId: repositoryShortId,
})
.find((task): task is RestoreTask => isRestoreTask(task) && task.input.snapshotId === snapshotId) ?? null
);
};
const emitRestoreStarted = (task: RestoreTask) => {
serverEvents.emit("restore:started", {
restoreId: task.id,
organizationId: task.organizationId,
repositoryId: task.input.repositoryId,
snapshotId: task.input.snapshotId,
});
};
const emitRestoreProgress = (task: RestoreTask, progress: RestoreExecutionProgress) => {
serverEvents.emit("restore:progress", {
restoreId: task.id,
organizationId: task.organizationId,
repositoryId: task.input.repositoryId,
snapshotId: task.input.snapshotId,
...progress,
});
};
const emitRestoreCompleted = (
task: RestoreTask,
payload: {
status: "success" | "error" | "cancelled";
error?: string;
filesRestored?: number;
filesSkipped?: number;
},
) => {
serverEvents.emit("restore:completed", {
restoreId: task.id,
organizationId: task.organizationId,
repositoryId: task.input.repositoryId,
snapshotId: task.input.snapshotId,
...payload,
});
};
const markRestoreStarted = (restoreId: string) => {
const task = updateActiveRestoreTask(restoreId, "restore.started", () => taskStore.markRunning(restoreId));
if (!task) return;
emitRestoreStarted(task);
};
const updateRestoreProgress = (restoreId: string, progress: RestoreExecutionProgress) => {
const task = updateActiveRestoreTask(restoreId, "restore.progress", () =>
taskStore.updateProgress(restoreId, { kind: "restore", progress }),
);
if (!task) return;
emitRestoreProgress(task, progress);
};
const completeRestoreTask = (
restoreId: string,
result: Extract<RestoreExecutionResult, { status: "completed" }>["result"],
) => {
const task = updateActiveRestoreTask(restoreId, "restore.completed", () =>
taskStore.complete(restoreId, { kind: "restore", result }),
);
if (!task) return;
emitRestoreCompleted(task, {
status: "success",
filesRestored: result.files_restored,
filesSkipped: result.files_skipped,
});
};
const failRestoreTask = (restoreId: string, error: string) => {
const task = updateActiveRestoreTask(restoreId, "restore.failed", () => taskStore.fail(restoreId, error));
if (!task) return;
emitRestoreCompleted(task, { status: "error", error });
};
const cancelRestoreTask = (restoreId: string, message?: string) => {
const task = updateActiveRestoreTask(restoreId, "restore.cancelled", () =>
taskStore.cancel(restoreId, message ?? null),
);
if (!task) return;
emitRestoreCompleted(task, { status: "cancelled", error: task.cancellationRequested ? undefined : message });
};
const finishRestoreExecution = async (restoreId: string, resultPromise: Promise<RestoreExecutionResult>) => {
try {
const result = await resultPromise;
switch (result.status) {
case "completed":
completeRestoreTask(restoreId, result.result);
return;
case "failed":
failRestoreTask(restoreId, result.error);
return;
case "cancelled":
cancelRestoreTask(restoreId, result.message);
return;
case "unavailable":
failRestoreTask(restoreId, result.error.message);
return;
}
} catch (error) {
failRestoreTask(restoreId, toMessage(error));
}
};
const assertAllowedRestoreAgent = async (agentId: string, organizationId: string) => {
if (agentId === LOCAL_AGENT_ID) {
return;
}
const agent = await agentsService.getAgent(agentId);
if (!agent || agent.organizationId !== organizationId) {
throw new NotFoundError("Restore target agent not found");
}
};
const findRepository = async (shortId: ShortId) => {
const organizationId = getOrganizationId();
return await db.query.repositoriesTable.findFirst({
@ -100,16 +291,18 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
let error: string | null = null;
if (config.isExistingRepository) {
const result = await restic
.snapshots(encryptedConfig, { organizationId })
const result = await runEffectPromise(restic.snapshots(encryptedConfig, { organizationId }))
.then(() => ({ error: null }))
.catch((error) => ({ error }));
error = result.error;
} else {
const initResult = await restic.init(encryptedConfig, organizationId, {
timeoutMs: appConfig.serverIdleTimeout * 1000,
});
const initResult = await runEffectPromise(
restic.init(encryptedConfig, {
organizationId,
timeoutMs: appConfig.serverIdleTimeout * 1000,
}),
);
error = initResult.error;
}
@ -143,7 +336,9 @@ const getRepository = async (shortId: ShortId) => {
const runAndStoreRepositoryStats = async (repository: Repository): Promise<ResticStatsDto> => {
const releaseLock = await repoMutex.acquireShared(repository.id, "stats");
try {
const stats = await restic.stats(repository.config, { organizationId: repository.organizationId });
const stats = await runEffectPromise(
restic.stats(repository.config, { organizationId: repository.organizationId }),
);
await db
.update(repositoriesTable)
@ -219,7 +414,7 @@ const listSnapshots = async (shortId: ShortId, backupId?: ShortId) => {
}
const cacheKey = cacheKeys.repository.snapshots(repository.id, backupId);
const cached = cache.get<Awaited<ReturnType<typeof restic.snapshots>>>(cacheKey);
const cached = cache.get<Effect.Effect.Success<ReturnType<typeof restic.snapshots>>>(cacheKey);
if (cached) {
return cached;
}
@ -229,9 +424,11 @@ const listSnapshots = async (shortId: ShortId, backupId?: ShortId) => {
let snapshots = [];
if (backupId) {
snapshots = await restic.snapshots(repository.config, { tags: [backupId], organizationId });
snapshots = await runEffectPromise(
restic.snapshots(repository.config, { tags: [backupId], organizationId }),
);
} else {
snapshots = await restic.snapshots(repository.config, { organizationId });
snapshots = await runEffectPromise(restic.snapshots(repository.config, { organizationId }));
}
cache.set(cacheKey, snapshots);
@ -260,7 +457,13 @@ const listSnapshotFiles = async (
const cacheKey = cacheKeys.repository.ls(repository.id, snapshotId, path, offset, limit);
type LsResult = {
snapshot: { id: string; short_id: string; time: string; hostname: string; paths: string[] } | null;
snapshot: {
id: string;
short_id: string;
time: string;
hostname: string;
paths: string[];
} | null;
nodes: { name: string; type: string; path: string; size?: number; mode?: number }[];
pagination: { offset: number; limit: number; total: number; hasMore: boolean };
};
@ -276,34 +479,43 @@ const listSnapshotFiles = async (
};
}
const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`);
const limiter = getLsLimiter(repository.id);
await runEffectPromise(limiter.take(1));
try {
const result = await restic.ls(repository.config, snapshotId, organizationId, path, { offset, limit });
const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`);
try {
const result = await runEffectPromise(
restic.ls(repository.config, snapshotId, path, { organizationId, offset, limit }),
);
if (!result.snapshot) {
throw new NotFoundError("Snapshot not found or empty");
if (!result.snapshot) {
throw new NotFoundError("Snapshot not found or empty");
}
const response = {
snapshot: {
id: result.snapshot.id,
short_id: result.snapshot.short_id,
time: result.snapshot.time,
hostname: result.snapshot.hostname,
paths: result.snapshot.paths,
},
files: result.nodes,
offset: result.pagination.offset,
limit: result.pagination.limit,
total: result.pagination.total,
hasMore: result.pagination.hasMore,
};
cache.set(cacheKey, result);
return response;
} finally {
releaseLock();
}
const response = {
snapshot: {
id: result.snapshot.id,
short_id: result.snapshot.short_id,
time: result.snapshot.time,
hostname: result.snapshot.hostname,
paths: result.snapshot.paths,
},
files: result.nodes,
offset: result.pagination.offset,
limit: result.pagination.limit,
total: result.pagination.total,
hasMore: result.pagination.hasMore,
};
cache.set(cacheKey, result);
return response;
} finally {
releaseLock();
await runEffectPromise(limiter.release(1));
}
};
@ -317,6 +529,7 @@ const restoreSnapshot = async (
excludeXattr?: string[];
delete?: boolean;
targetPath?: string;
targetAgentId?: string;
overwrite?: OverwriteMode;
},
) => {
@ -327,75 +540,71 @@ const restoreSnapshot = async (
throw new NotFoundError("Repository not found");
}
const target = options?.targetPath || "/";
const resolvedTarget = nodePath.resolve(target);
const blockedTargets = getBlockedRestoreTargets();
const { targetAgentId, targetPath, ...restoreExecutionOptions } = options ?? {};
const target = targetPath || "/";
for (const blockedTarget of blockedTargets) {
if (isPathWithin(blockedTarget, resolvedTarget)) {
throw new BadRequestError(
"Restore target path is not allowed. Restoring to this path could overwrite critical system files or application data.",
);
}
}
const snapshot = await getSnapshotDetails(repository.shortId, snapshotId);
const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/"));
const { paths } = await getSnapshotDetails(repository.shortId, snapshotId);
const hasNonPosixSnapshotPaths = paths.some((path) => !path.startsWith("/"));
if (hasNonPosixSnapshotPaths && !options?.targetPath) {
if (hasNonPosixSnapshotPaths && !targetPath) {
throw new BadRequestError(
"Original location restore is unavailable for this snapshot. Restore it to a custom location instead.",
);
}
const basePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(paths);
const basePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths);
const executionAgentId = targetAgentId ?? LOCAL_AGENT_ID;
const useControllerLocalRestoreFallback = executionAgentId === LOCAL_AGENT_ID && !appConfig.flags.enableLocalAgent;
await assertAllowedRestoreAgent(executionAgentId, organizationId);
const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
if (!useControllerLocalRestoreFallback && repository.type === "local" && executionAgentId !== LOCAL_AGENT_ID) {
throw new BadRequestError(
"Local repository restores must run on the agent that can access the repository path.",
);
}
if (executionAgentId === LOCAL_AGENT_ID) {
assertAllowedControllerLocalRestoreTarget(target);
}
const activeRestore = findActiveRestoreTask(organizationId, repository.shortId, snapshotId);
if (activeRestore) {
throw new ConflictError("A restore is already running for this snapshot");
}
const task = taskStore.create({
organizationId,
resourceType: RESTORE_TASK_RESOURCE_TYPE,
resourceId: repository.shortId,
targetAgentId: useControllerLocalRestoreFallback ? null : executionAgentId,
input: { kind: "restore", repositoryId: repository.shortId, snapshotId, target },
});
const restoreId = task.id;
try {
serverEvents.emit("restore:started", {
const repositoryConfig = await decryptRepositoryConfig(repository.config);
const execution = restoreExecutor.start({
restoreId,
organizationId,
repositoryId: repository.shortId,
repositoryId: repository.id,
repositoryShortId: repository.shortId,
repositoryConfig,
snapshotId,
});
const result = await restic.restore(repository.config, snapshotId, target, {
basePath,
...options,
organizationId,
onProgress: (progress) => {
serverEvents.emit("restore:progress", {
organizationId,
repositoryId: repository.shortId,
snapshotId,
...progress,
});
target,
executionAgentId,
options: {
basePath,
...restoreExecutionOptions,
},
onStarted: () => markRestoreStarted(restoreId),
onProgress: (progress) => updateRestoreProgress(restoreId, progress),
});
serverEvents.emit("restore:completed", {
organizationId,
repositoryId: repository.shortId,
snapshotId,
status: "success",
});
void finishRestoreExecution(restoreId, execution.result);
return {
success: true,
message: "Snapshot restored successfully",
filesRestored: result.files_restored,
filesSkipped: result.files_skipped,
};
return { restoreId, status: "started" as const };
} catch (error) {
serverEvents.emit("restore:completed", {
organizationId,
repositoryId: repository.shortId,
snapshotId,
status: "error",
error: toMessage(error),
});
failRestoreTask(restoreId, toMessage(error));
throw error;
} finally {
releaseLock();
}
};
@ -408,11 +617,15 @@ const dumpSnapshot = async (shortId: ShortId, snapshotId: string, path?: string,
}
const releaseLock = await repoMutex.acquireShared(repository.id, `dump:${snapshotId}`);
let dumpStream: Awaited<ReturnType<typeof restic.dump>> | undefined = undefined;
let dumpStream: ResticDumpStream | null = null;
try {
const snapshot = await getSnapshotDetails(repository.shortId, snapshotId);
const preparedDump = prepareSnapshotDump({ snapshotId, snapshotPaths: snapshot.paths, requestedPath: path });
const preparedDump = prepareSnapshotDump({
snapshotId,
snapshotPaths: snapshot.paths,
requestedPath: path,
});
const dumpOptions: Parameters<typeof restic.dump>[2] = {
organizationId,
path: preparedDump.path,
@ -436,7 +649,7 @@ const dumpSnapshot = async (shortId: ShortId, snapshotId: string, path?: string,
}
}
dumpStream = await restic.dump(repository.config, preparedDump.snapshotRef, dumpOptions);
dumpStream = await runEffectPromise(restic.dump(repository.config, preparedDump.snapshotRef, dumpOptions));
serverEvents.emit("dump:started", {
organizationId,
@ -468,12 +681,12 @@ const getSnapshotDetails = async (shortId: ShortId, snapshotId: string) => {
}
const cacheKey = cacheKeys.repository.snapshots(repository.id);
let snapshots = cache.get<Awaited<ReturnType<typeof restic.snapshots>>>(cacheKey);
let snapshots = cache.get<Effect.Effect.Success<ReturnType<typeof restic.snapshots>>>(cacheKey);
if (!snapshots) {
const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`);
try {
snapshots = await restic.snapshots(repository.config, { organizationId });
snapshots = await runEffectPromise(restic.snapshots(repository.config, { organizationId }));
cache.set(cacheKey, snapshots);
} finally {
releaseLock();
@ -501,7 +714,7 @@ const checkHealth = async (shortId: ShortId) => {
const releaseLock = await repoMutex.acquireExclusive(repository.id, "check");
try {
const { hasErrors, error } = await restic.check(repository.config, { organizationId });
const { hasErrors, error } = await runEffectPromise(restic.check(repository.config, { organizationId }));
await db
.update(repositoriesTable)
@ -599,7 +812,7 @@ const deleteSnapshot = async (shortId: ShortId, snapshotId: string) => {
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
try {
await restic.deleteSnapshot(repository.config, snapshotId, organizationId);
await runEffectPromise(restic.deleteSnapshot(repository.config, snapshotId, { organizationId }));
cache.delByPrefix(cacheKeys.repository.all(repository.id));
void runAndStoreRepositoryStats(repository).catch((error) => {
logger.error(
@ -622,7 +835,7 @@ const deleteSnapshots = async (shortId: ShortId, snapshotIds: string[]) => {
let shouldRefreshStats = false;
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`);
try {
await restic.deleteSnapshots(repository.config, snapshotIds, organizationId);
await runEffectPromise(restic.deleteSnapshots(repository.config, snapshotIds, { organizationId }));
cache.delByPrefix(cacheKeys.repository.all(repository.id));
shouldRefreshStats = true;
} finally {
@ -654,7 +867,7 @@ const tagSnapshots = async (
const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`);
try {
await restic.tagSnapshots(repository.config, snapshotIds, tags, organizationId);
await runEffectPromise(restic.tagSnapshots(repository.config, snapshotIds, tags, { organizationId }));
cache.delByPrefix(cacheKeys.repository.all(repository.id));
} finally {
releaseLock();
@ -673,7 +886,7 @@ const refreshSnapshots = async (shortId: ShortId) => {
const releaseLock = await repoMutex.acquireShared(repository.id, "refresh");
try {
const snapshots = await restic.snapshots(repository.config, { organizationId });
const snapshots = await runEffectPromise(restic.snapshots(repository.config, { organizationId }));
const cacheKey = cacheKeys.repository.snapshots(repository.id);
cache.set(cacheKey, snapshots);
@ -726,23 +939,22 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
const configChanged = updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig);
const encryptedConfig = updates.config ? await encryptRepositoryConfig(parsedConfig) : existingConfig;
const updatedAt = Date.now();
const updatePayload = {
const updatePayload: Partial<RepositoryInsert> = {
name: newName,
compressionMode: updates.compressionMode ?? existing.compressionMode,
updatedAt,
config: encryptedConfig,
...(configChanged
? {
status: "unknown" as const,
lastChecked: null,
lastError: null,
doctorResult: null,
stats: null,
statsUpdatedAt: null,
}
: {}),
};
if (configChanged) {
updatePayload.status = "unknown";
updatePayload.lastChecked = null;
updatePayload.lastError = null;
updatePayload.doctorResult = null;
updatePayload.stats = null;
updatePayload.statsUpdatedAt = null;
}
const [updated] = await db
.update(repositoriesTable)
.set(updatePayload)
@ -772,7 +984,7 @@ const unlockRepository = async (shortId: ShortId) => {
const releaseLock = await repoMutex.acquireExclusive(repository.id, "unlock");
try {
const result = await restic.unlock(repository.config, { organizationId });
const result = await runEffectPromise(restic.unlock(repository.config, { organizationId }));
return result;
} finally {
releaseLock();
@ -804,7 +1016,14 @@ const execResticCommand = async (
addCommonArgs(resticArgs, env, repository.config);
try {
const result = await safeSpawn({ command: "restic", args: resticArgs, env, signal, onStdout, onStderr });
const result = await safeSpawn({
command: "restic",
args: resticArgs,
env,
signal,
onStdout,
onStderr,
});
return { exitCode: result.exitCode };
} finally {
await cleanupTemporaryKeys(env, resticDeps);
@ -835,11 +1054,13 @@ const getRetentionCategories = async (repositoryId: ShortId, scheduleId?: ShortI
return new Map<string, RetentionCategory[]>();
}
const dryRunResults = await restic.forget(repository.config, schedule.retentionPolicy, {
tag: scheduleId,
organizationId: getOrganizationId(),
dryRun: true,
});
const dryRunResults = await runEffectPromise(
restic.forget(repository.config, schedule.retentionPolicy, {
tag: scheduleId,
organizationId: getOrganizationId(),
dryRun: true,
}),
);
if (!dryRunResults.data) {
return new Map<string, RetentionCategory[]>();

View file

@ -0,0 +1,145 @@
import type { RepositoryConfig } from "@zerobyte/core/restic";
import type { RestoreRunPayload } from "@zerobyte/contracts/agent-protocol";
import { config as appConfig } from "~/server/core/config";
import { repoMutex } from "../../core/repository-mutex";
import { restic, resticDeps } from "../../core/restic";
import { runEffectPromise, toMessage } from "../../utils/errors";
import { agentManager, type RestoreExecutionProgress, type RestoreExecutionResult } from "../agents/agents-manager";
import { LOCAL_AGENT_ID } from "../agents/constants";
type RestoreExecutionOptions = Omit<Parameters<typeof restic.restore>[3], "organizationId" | "signal" | "onProgress">;
type RestoreExecutionRequest = {
restoreId: string;
organizationId: string;
repositoryId: string;
repositoryShortId: string;
repositoryConfig: RepositoryConfig;
snapshotId: string;
target: string;
executionAgentId: string;
options: RestoreExecutionOptions;
onStarted: () => void;
onProgress: (progress: RestoreExecutionProgress) => void;
};
type RestoreExecutionHandle = {
result: Promise<RestoreExecutionResult>;
};
const shouldRunInController = (agentId: string) => agentId === LOCAL_AGENT_ID && !appConfig.flags.enableLocalAgent;
const createRestoreRunPayload = async (request: RestoreExecutionRequest): Promise<RestoreRunPayload> => {
const encryptedResticPassword = await resticDeps.getOrganizationResticPassword(request.organizationId);
const resticPassword = await resticDeps.resolveSecret(encryptedResticPassword);
return {
restoreId: request.restoreId,
organizationId: request.organizationId,
repositoryId: request.repositoryShortId,
snapshotId: request.snapshotId,
target: request.target,
repositoryConfig: request.repositoryConfig,
runtime: { password: resticPassword },
options: {
...request.options,
organizationId: request.organizationId,
},
};
};
const executeControllerRestore = async (
request: RestoreExecutionRequest,
signal: AbortSignal,
): Promise<RestoreExecutionResult> => {
if (signal.aborted) {
return { status: "cancelled", message: "Restore was cancelled" };
}
request.onStarted();
try {
const result = await runEffectPromise(
restic.restore(request.repositoryConfig, request.snapshotId, request.target, {
...request.options,
organizationId: request.organizationId,
signal,
onProgress: request.onProgress,
}),
);
return { status: "completed", result };
} catch (error) {
if (signal.aborted) {
return { status: "cancelled", message: "Restore was cancelled" };
}
return { status: "failed", error: toMessage(error) };
}
};
const executeAgentRestore = async (
request: RestoreExecutionRequest,
signal: AbortSignal,
): Promise<RestoreExecutionResult> => {
if (signal.aborted) {
return { status: "cancelled", message: "Restore was cancelled" };
}
try {
const payload = await createRestoreRunPayload(request);
const started = await agentManager.startRestore(request.executionAgentId, {
payload,
signal,
onStarted: request.onStarted,
onProgress: request.onProgress,
});
if (started.status === "unavailable") {
return started;
}
return await started.result;
} catch (error) {
if (signal.aborted) {
return { status: "cancelled", message: "Restore was cancelled" };
}
return { status: "failed", error: toMessage(error) };
}
};
const executeRestoreWithRepositoryLock = async (
request: RestoreExecutionRequest,
signal: AbortSignal,
): Promise<RestoreExecutionResult> => {
let releaseLock: (() => void) | null = null;
try {
releaseLock = await repoMutex.acquireShared(request.repositoryId, `restore:${request.restoreId}`, signal);
if (shouldRunInController(request.executionAgentId)) {
return await executeControllerRestore(request, signal);
}
return await executeAgentRestore(request, signal);
} catch (error) {
if (signal.aborted) {
return { status: "cancelled", message: "Restore was cancelled" };
}
return { status: "failed", error: toMessage(error) };
} finally {
releaseLock?.();
}
};
export const restoreExecutor = {
start: (request: RestoreExecutionRequest): RestoreExecutionHandle => {
const abortController = new AbortController();
return {
result: executeRestoreWithRepositoryLock(request, abortController.signal),
};
},
};

View file

@ -0,0 +1,251 @@
import { beforeEach, expect, test } from "vitest";
import { db } from "~/server/db/db";
import { tasksTable } from "~/server/db/schema";
import { ensureTestOrganization, TEST_ORG_ID } from "~/test/helpers/organization";
import { generateBackupOutput } from "~/test/helpers/restic";
import { taskStore } from "../tasks.store";
import type { TaskInput, TaskProgress, TaskResult } from "../tasks.schemas";
type BackupTaskInput = Extract<TaskInput, { kind: "backup" }>;
type BackupTaskProgress = Extract<TaskProgress, { kind: "backup" }>;
type BackupTaskResult = Extract<TaskResult, { kind: "backup" }>;
const backupInput = (scheduleId = 1): BackupTaskInput => ({
kind: "backup",
scheduleId,
scheduleShortId: `schedule-${scheduleId}`,
manual: false,
});
const backupProgress = (percentDone = 0.5): BackupTaskProgress => ({
kind: "backup",
progress: {
message_type: "status",
seconds_elapsed: 1,
seconds_remaining: 1,
percent_done: percentDone,
total_files: 10,
files_done: 5,
total_bytes: 100,
bytes_done: 50,
current_files: [],
},
});
const backupResult = (): BackupTaskResult => ({
kind: "backup",
exitCode: 0,
result: JSON.parse(generateBackupOutput()),
warningDetails: null,
});
const createBackupTask = (overrides: Partial<Parameters<typeof taskStore.create>[0]> = {}) =>
taskStore.create({
organizationId: TEST_ORG_ID,
resourceType: "backup_schedule",
resourceId: "1",
targetAgentId: "local",
input: backupInput(),
...overrides,
});
const createRestoreTask = (overrides: Partial<Parameters<typeof taskStore.create>[0]> = {}) =>
taskStore.create({
organizationId: TEST_ORG_ID,
resourceType: "repository",
resourceId: "repo-short",
targetAgentId: "local",
input: { kind: "restore", repositoryId: "repo-short", snapshotId: "snapshot-1", target: "/tmp/restore" },
...overrides,
});
beforeEach(async () => {
await ensureTestOrganization();
await db.delete(tasksTable);
});
test("creates queued backup tasks with parsed input and durable metadata only", () => {
const task = createBackupTask({
id: "task-create",
input: { ...backupInput(12), manual: true },
resourceId: "12",
});
expect(task).toMatchObject({
id: "task-create",
organizationId: TEST_ORG_ID,
kind: "backup",
status: "queued",
resourceType: "backup_schedule",
resourceId: "12",
targetAgentId: "local",
input: {
kind: "backup",
scheduleId: 12,
scheduleShortId: "schedule-12",
manual: true,
},
progress: null,
result: null,
error: null,
cancellationRequested: false,
startedAt: null,
finishedAt: null,
});
expect(Object.keys(task.input)).toEqual(["kind", "scheduleId", "scheduleShortId", "manual"]);
});
test("moves an active task through running, progress, cancellation request, and success", () => {
const task = createBackupTask({ id: "task-success" });
const running = taskStore.markRunning(task.id);
expect(running.status).toBe("running");
expect(running.startedAt).toEqual(expect.any(Number));
const progressed = taskStore.updateProgress(task.id, backupProgress(0.7));
expect(progressed.progress?.progress.percent_done).toBe(0.7);
const cancelling = taskStore.requestCancel(task.id);
expect(cancelling.status).toBe("cancelling");
expect(cancelling.cancellationRequested).toBe(true);
const completed = taskStore.complete(task.id, backupResult());
expect(completed.status).toBe("succeeded");
expect(completed.result?.kind).toBe("backup");
if (completed.result?.kind !== "backup") {
throw new Error("Expected backup result");
}
expect(completed.result?.result?.snapshot_id).toBe("abcd1234");
expect(completed.finishedAt).toEqual(expect.any(Number));
expect(completed.cancellationRequested).toBe(true);
});
test("records failed and cancelled terminal task states", () => {
const failedTask = createBackupTask({ id: "task-failed", resourceId: "failed" });
const failed = taskStore.fail(failedTask.id, "restic failed");
expect(failed.status).toBe("failed");
expect(failed.error).toBe("restic failed");
expect(failed.finishedAt).toEqual(expect.any(Number));
const cancelledTask = createBackupTask({ id: "task-cancelled", resourceId: "cancelled" });
taskStore.requestCancel(cancelledTask.id);
const cancelled = taskStore.cancel(cancelledTask.id, "Backup was stopped by the user");
expect(cancelled.status).toBe("cancelled");
expect(cancelled.error).toBe("Backup was stopped by the user");
expect(cancelled.cancellationRequested).toBe(true);
});
test("moves restore tasks through progress and success", () => {
const task = createRestoreTask({ id: "restore-task" });
const running = taskStore.markRunning(task.id);
expect(running.kind).toBe("restore");
expect(running.input).toMatchObject({ kind: "restore", snapshotId: "snapshot-1" });
const progressed = taskStore.updateProgress(task.id, {
kind: "restore",
progress: {
message_type: "status",
seconds_elapsed: 2,
percent_done: 0.25,
total_files: 4,
files_restored: 1,
total_bytes: 400,
bytes_restored: 100,
},
});
expect(progressed.progress?.progress.percent_done).toBe(0.25);
const completed = taskStore.complete(task.id, {
kind: "restore",
result: {
message_type: "summary",
total_files: 4,
files_restored: 4,
files_skipped: 0,
},
});
expect(completed.status).toBe("succeeded");
expect(completed.result?.kind).toBe("restore");
if (completed.result?.kind !== "restore") {
throw new Error("Expected restore result");
}
expect(completed.result?.result.files_restored).toBe(4);
});
test("finds the newest active task for a resource and marks only matching active tasks stale", async () => {
createBackupTask({ id: "task-a-old", resourceId: "shared" });
const newest = createBackupTask({ id: "task-z-new", resourceId: "shared" });
const otherResource = createBackupTask({ id: "task-other", resourceId: "other" });
const terminal = taskStore.complete(
createBackupTask({ id: "task-terminal", resourceId: "shared" }).id,
backupResult(),
);
const active = taskStore.findActiveByResource({
organizationId: TEST_ORG_ID,
kind: "backup",
resourceType: "backup_schedule",
resourceId: "shared",
});
expect(active?.id).toBe(newest.id);
const staleTasks = taskStore.markActiveStale({
organizationId: TEST_ORG_ID,
kind: "backup",
resourceType: "backup_schedule",
resourceId: "shared",
error: "process restarted",
});
expect(staleTasks.map((task) => task.id).sort()).toEqual(["task-a-old", "task-z-new"]);
const other = await db.query.tasksTable.findFirst({ where: { id: otherResource.id } });
const completed = await db.query.tasksTable.findFirst({ where: { id: terminal.id } });
expect(other?.status).toBe("queued");
expect(completed?.status).toBe("succeeded");
});
test("parses task JSON on reads and rejects invalid persisted shapes", () => {
db.insert(tasksTable)
.values({
id: "task-invalid-json",
organizationId: TEST_ORG_ID,
kind: "backup",
status: "queued",
resourceType: "backup_schedule",
resourceId: "invalid",
input: {
kind: "backup",
scheduleId: "not-a-number",
scheduleShortId: "schedule-invalid",
manual: false,
},
cancellationRequested: false,
createdAt: Date.now(),
updatedAt: Date.now(),
})
.run();
expect(() =>
taskStore.findActiveByResource({
organizationId: TEST_ORG_ID,
kind: "backup",
resourceType: "backup_schedule",
resourceId: "invalid",
}),
).toThrow();
});
test("terminal updates do not mutate unrelated task rows", async () => {
const task = createBackupTask({ id: "task-target", resourceId: "target" });
const unrelated = createBackupTask({ id: "task-unrelated", resourceId: "unrelated" });
taskStore.complete(task.id, backupResult());
const targetRow = await db.query.tasksTable.findFirst({ where: { id: task.id } });
const unrelatedRow = await db.query.tasksTable.findFirst({ where: { id: unrelated.id } });
expect(targetRow?.status).toBe("succeeded");
expect(unrelatedRow?.status).toBe("queued");
expect(unrelatedRow?.finishedAt).toBeNull();
});

View file

@ -0,0 +1,106 @@
import {
resticBackupOutputSchema,
resticBackupProgressSchema,
resticRestoreOutputSchema,
restoreProgressSchema,
} from "@zerobyte/core/restic";
import { z } from "zod";
export const taskStatuses = ["queued", "running", "cancelling", "cancelled", "succeeded", "failed", "stale"] as const;
export const activeTaskStatuses = ["queued", "running", "cancelling"] as const;
export const taskStatusSchema = z.enum(taskStatuses);
export const activeTaskStatusSchema = z.enum(activeTaskStatuses);
export const taskKindSchema = z.enum(["backup", "restore"]);
export const taskInputSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("backup"),
scheduleId: z.number(),
scheduleShortId: z.string(),
manual: z.boolean(),
}),
z.object({
kind: z.literal("restore"),
repositoryId: z.string(),
snapshotId: z.string(),
target: z.string(),
}),
]);
export const taskProgressSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("backup"),
progress: resticBackupProgressSchema,
}),
z.object({
kind: z.literal("restore"),
progress: restoreProgressSchema,
}),
]);
export const taskResultSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("backup"),
exitCode: z.number(),
result: resticBackupOutputSchema.nullable(),
warningDetails: z.string().nullable(),
}),
z.object({
kind: z.literal("restore"),
result: resticRestoreOutputSchema,
}),
]);
export const taskSchema = z
.object({
id: z.string(),
organizationId: z.string(),
kind: taskKindSchema,
status: taskStatusSchema,
resourceType: z.string(),
resourceId: z.string(),
targetAgentId: z.string().nullable(),
input: taskInputSchema,
progress: taskProgressSchema.nullable(),
result: taskResultSchema.nullable(),
error: z.string().nullable(),
cancellationRequested: z.boolean(),
createdAt: z.number(),
startedAt: z.number().nullable(),
updatedAt: z.number(),
finishedAt: z.number().nullable(),
})
.superRefine((task, ctx) => {
if (task.kind !== task.input.kind) {
ctx.addIssue({
code: "custom",
path: ["input", "kind"],
message: "Task input kind must match task kind",
});
}
if (task.progress && task.kind !== task.progress.kind) {
ctx.addIssue({
code: "custom",
path: ["progress", "kind"],
message: "Task progress kind must match task kind",
});
}
if (task.result && task.kind !== task.result.kind) {
ctx.addIssue({
code: "custom",
path: ["result", "kind"],
message: "Task result kind must match task kind",
});
}
});
export type TaskStatus = z.infer<typeof taskStatusSchema>;
export type ActiveTaskStatus = z.infer<typeof activeTaskStatusSchema>;
export type TaskKind = z.infer<typeof taskKindSchema>;
export type TaskInput = z.infer<typeof taskInputSchema>;
export type TaskProgress = z.infer<typeof taskProgressSchema>;
export type TaskResult = z.infer<typeof taskResultSchema>;
export type ParsedTask = z.infer<typeof taskSchema>;

View file

@ -0,0 +1,216 @@
import { and, desc, eq, inArray, type SQL } from "drizzle-orm";
import { db } from "~/server/db/db";
import { tasksTable } from "~/server/db/schema";
import {
activeTaskStatuses,
taskInputSchema,
taskProgressSchema,
taskResultSchema,
taskSchema,
type ParsedTask,
type TaskInput,
type TaskKind,
type TaskProgress,
type TaskResult,
} from "./tasks.schemas";
type TaskResource = {
organizationId: string;
kind: TaskKind;
resourceType: string;
resourceId: string;
};
type CreateTaskParams = {
id?: string;
organizationId: string;
resourceType: string;
resourceId: string;
targetAgentId?: string | null;
input: TaskInput;
};
type MarkActiveStaleParams = Partial<TaskResource> & { error?: string };
const parseTask = (row: unknown): ParsedTask => taskSchema.parse(row);
const activeStatusCondition = () => inArray(tasksTable.status, activeTaskStatuses);
const byIdCondition = (id: string) => eq(tasksTable.id, id);
const buildActiveConditions = (params: Partial<TaskResource> = {}) => {
const conditions: SQL[] = [activeStatusCondition()];
if (params.organizationId) conditions.push(eq(tasksTable.organizationId, params.organizationId));
if (params.kind) conditions.push(eq(tasksTable.kind, params.kind));
if (params.resourceType) conditions.push(eq(tasksTable.resourceType, params.resourceType));
if (params.resourceId) conditions.push(eq(tasksTable.resourceId, params.resourceId));
return conditions;
};
const getUpdatedTask = (row: unknown, taskId: string, operation: string) => {
if (!row) {
throw new Error(`Task ${taskId} was not ${operation}`);
}
return parseTask(row);
};
export const taskStore = {
create: (params: CreateTaskParams): ParsedTask => {
const input = taskInputSchema.parse(params.input);
const now = Date.now();
const row = db
.insert(tasksTable)
.values({
id: params.id ?? Bun.randomUUIDv7(),
organizationId: params.organizationId,
kind: input.kind,
status: "queued",
resourceType: params.resourceType,
resourceId: params.resourceId,
targetAgentId: params.targetAgentId ?? null,
input,
progress: null,
result: null,
error: null,
cancellationRequested: false,
createdAt: now,
updatedAt: now,
})
.returning()
.get();
return parseTask(row);
},
markRunning: (taskId: string): ParsedTask => {
const now = Date.now();
const row = db
.update(tasksTable)
.set({ status: "running", startedAt: now, updatedAt: now })
.where(and(byIdCondition(taskId), activeStatusCondition()))
.returning()
.get();
return getUpdatedTask(row, taskId, "marked running");
},
updateProgress: (taskId: string, progress: TaskProgress): ParsedTask => {
const parsedProgress = taskProgressSchema.parse(progress);
const row = db
.update(tasksTable)
.set({ progress: parsedProgress, updatedAt: Date.now() })
.where(and(byIdCondition(taskId), activeStatusCondition()))
.returning()
.get();
return getUpdatedTask(row, taskId, "updated with progress");
},
requestCancel: (taskId: string): ParsedTask => {
const row = db
.update(tasksTable)
.set({ status: "cancelling", cancellationRequested: true, updatedAt: Date.now() })
.where(and(byIdCondition(taskId), activeStatusCondition()))
.returning()
.get();
return getUpdatedTask(row, taskId, "marked cancelling");
},
complete: (taskId: string, result: TaskResult): ParsedTask => {
const parsedResult = taskResultSchema.parse(result);
const now = Date.now();
const row = db
.update(tasksTable)
.set({
status: "succeeded",
result: parsedResult,
error: null,
updatedAt: now,
finishedAt: now,
})
.where(and(byIdCondition(taskId), activeStatusCondition()))
.returning()
.get();
return getUpdatedTask(row, taskId, "completed");
},
fail: (taskId: string, error: string): ParsedTask => {
const now = Date.now();
const row = db
.update(tasksTable)
.set({
status: "failed",
error,
updatedAt: now,
finishedAt: now,
})
.where(and(byIdCondition(taskId), activeStatusCondition()))
.returning()
.get();
return getUpdatedTask(row, taskId, "failed");
},
cancel: (taskId: string, error: string | null = null): ParsedTask => {
const now = Date.now();
const row = db
.update(tasksTable)
.set({
status: "cancelled",
error,
updatedAt: now,
finishedAt: now,
})
.where(and(byIdCondition(taskId), activeStatusCondition()))
.returning()
.get();
return getUpdatedTask(row, taskId, "cancelled");
},
findActiveByResource: (params: TaskResource): ParsedTask | null => {
const rows = db
.select()
.from(tasksTable)
.where(and(...buildActiveConditions(params)))
.orderBy(desc(tasksTable.createdAt), desc(tasksTable.id))
.limit(1)
.all();
const [row] = rows;
return row ? parseTask(row) : null;
},
listActiveByResource: (params: TaskResource): ParsedTask[] => {
const rows = db
.select()
.from(tasksTable)
.where(and(...buildActiveConditions(params)))
.orderBy(desc(tasksTable.createdAt), desc(tasksTable.id))
.all();
return rows.map(parseTask);
},
markActiveStale: (params: MarkActiveStaleParams = {}): ParsedTask[] => {
const now = Date.now();
const rows = db
.update(tasksTable)
.set({
status: "stale",
error: params.error ?? "Task was interrupted before it completed",
updatedAt: now,
finishedAt: now,
})
.where(and(...buildActiveConditions(params)))
.returning()
.all();
return rows.map(parseTask);
},
};

View file

@ -152,11 +152,38 @@ describe("volumeService.mountVolume", () => {
});
});
describe("volumeService.unmountVolume", () => {
test("persists the unmounted status for normal unmount requests", async () => {
const { organizationId, user } = await createTestSession();
const volume = await createTestVolume({ organizationId, status: "mounted", agentId: "agent-1" });
agentManagerMock.runVolumeCommand.mockResolvedValueOnce({
name: "volume.unmount",
result: { status: "unmounted" },
});
await withContext({ organizationId, userId: user.id }, async () => {
const result = await volumeService.unmountVolume(volume.shortId);
expect(result.status).toBe("unmounted");
expect(agentManagerMock.runVolumeCommand).toHaveBeenCalledWith(
volume.agentId,
expect.objectContaining({ name: "volume.unmount", volume: expect.objectContaining({ id: volume.id }) }),
);
});
const updatedVolume = await db.query.volumesTable.findFirst({ where: { id: volume.id } });
expect(updatedVolume?.status).toBe("unmounted");
});
});
describe("volumeService.ensureHealthyVolume", () => {
test("returns ready when the mounted volume passes its health check", async () => {
const { organizationId, user } = await createTestSession();
const volume = await createTestVolume({ organizationId, status: "mounted", agentId: "agent-1" });
agentManagerMock.runVolumeCommand.mockResolvedValue({ name: "volume.checkHealth", result: { status: "mounted" } });
agentManagerMock.runVolumeCommand.mockResolvedValue({
name: "volume.checkHealth",
result: { status: "mounted" },
});
await withContext({ organizationId, userId: user.id }, async () => {
const result = await volumeService.ensureHealthyVolume(volume.shortId);
@ -169,14 +196,22 @@ describe("volumeService.ensureHealthyVolume", () => {
expect(agentManagerMock.runVolumeCommand).toHaveBeenCalledOnce();
expect(agentManagerMock.runVolumeCommand).toHaveBeenCalledWith(
volume.agentId,
expect.objectContaining({ name: "volume.checkHealth", volume: expect.objectContaining({ id: volume.id }) }),
expect.objectContaining({
name: "volume.checkHealth",
volume: expect.objectContaining({ id: volume.id }),
}),
);
});
});
test("auto-remounts when the mounted volume fails its health check", async () => {
const { organizationId, user } = await createTestSession();
const volume = await createTestVolume({ organizationId, status: "mounted", autoRemount: true, agentId: "agent-1" });
const volume = await createTestVolume({
organizationId,
status: "mounted",
autoRemount: true,
agentId: "agent-1",
});
agentManagerMock.runVolumeCommand
.mockResolvedValueOnce({ name: "volume.checkHealth", result: { status: "error", error: "stale mount" } })
.mockResolvedValueOnce({ name: "volume.unmount", result: { status: "unmounted" } })

View file

@ -174,7 +174,7 @@ const mountVolume = async (shortId: ShortId) => {
return { error, status };
};
const unmountVolume = async (shortId: ShortId) => {
const unmountVolume = async (shortId: ShortId, options?: { persistStatus?: boolean }) => {
const organizationId = getOrganizationId();
const volume = await findVolume(shortId);
@ -184,13 +184,15 @@ const unmountVolume = async (shortId: ShortId) => {
const { status, error } = await runVolumeBackendCommand(volume, "volume.unmount");
await db
.update(volumesTable)
.set({ status })
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
if (options?.persistStatus !== false) {
await db
.update(volumesTable)
.set({ status })
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
if (status === "unmounted") {
serverEvents.emit("volume:unmounted", { organizationId, volumeName: volume.name });
if (status === "unmounted") {
serverEvents.emit("volume:unmounted", { organizationId, volumeName: volume.name });
}
}
return { error, status };

View file

@ -1,7 +1,8 @@
import { HttpError } from "http-errors-enhanced";
import { sanitizeSensitiveData } from "@zerobyte/core/node";
import { ResticError } from "@zerobyte/core/restic";
import { isResticError } from "@zerobyte/core/restic";
import { toErrorDetails as getErrorDetails, toMessage as getMessage } from "@zerobyte/core/utils";
import { Cause, Effect, Exit, Option } from "effect";
const formatAllowedHostsMessage = (message: string) => {
const referencesAllowedHosts = /\ballowed\s+hosts?\b|\ballowedHosts\b/i.test(message);
@ -30,7 +31,7 @@ export const handleServiceError = (error: unknown) => {
return { message: sanitizeSensitiveData(error.message), status: error.statusCode };
}
if (error instanceof ResticError) {
if (isResticError(error)) {
return {
message: sanitizeSensitiveData(error.summary),
details: error.details ? sanitizeSensitiveData(error.details) : undefined,
@ -48,3 +49,13 @@ export const toMessage = (err: unknown): string => {
export const toErrorDetails = (err: unknown): string => {
return sanitizeSensitiveData(getErrorDetails(err));
};
export const runEffectPromise = <A, E>(effect: Effect.Effect<A, E, never>) => {
return Effect.runPromiseExit(effect).then((exit) => {
if (Exit.isSuccess(exit)) {
return exit.value;
}
throw Option.getOrUndefined(Cause.failureOption(exit.cause)) ?? Cause.squash(exit.cause);
});
};

View file

@ -138,17 +138,19 @@ async function verifySnapshot(
fixtureRootPath: string,
scenario: IntegrationScenario,
) {
const snapshots = await resticClient.snapshots(repositoryConfig, {
organizationId,
tags: [uniqueBackupTag],
});
const snapshots = await Effect.runPromise(
resticClient.snapshots(repositoryConfig, {
organizationId,
tags: [uniqueBackupTag],
}),
);
const snapshot = snapshots.find((candidate) => candidate.id === snapshotId || candidate.short_id === snapshotId);
if (!snapshot) {
throw new Error(`Unable to find snapshot ${snapshotId} by integration tag ${uniqueBackupTag}`);
}
const res = await resticClient.ls(repositoryConfig, snapshotId, organizationId, undefined, {});
const res = await Effect.runPromise(resticClient.ls(repositoryConfig, snapshotId, undefined, { organizationId }));
await verifySnapshotEntries(fixtureRootPath, res.nodes, scenario.expectedEntries);
}
@ -163,12 +165,14 @@ async function restoreSnapshot(
restoreOptions: IntegrationScenario["restore"],
) {
await fs.mkdir(restoreTarget, { recursive: true });
await resticClient.restore(repositoryConfig, snapshotId, restoreTarget, {
organizationId,
basePath: fixtureRootPath,
excludeXattr: restoreOptions?.excludeXattr,
overwrite: restoreOptions?.overwrite,
});
await Effect.runPromise(
resticClient.restore(repositoryConfig, snapshotId, restoreTarget, {
organizationId,
basePath: fixtureRootPath,
excludeXattr: restoreOptions?.excludeXattr,
overwrite: restoreOptions?.overwrite,
}),
);
}
async function cleanupScenario(backend: VolumeBackend, restoreTarget: string, scenarioWorkspace: string) {
@ -226,7 +230,7 @@ async function runScenario(scenario: IntegrationScenario, runId: string): Promis
await runStage(stages, "init-repository", async () => {
if (scenario.repository.isExistingRepository) return;
const initResult = await resticClient.init(scenario.repository, organizationId, undefined);
const initResult = await Effect.runPromise(resticClient.init(scenario.repository, { organizationId }));
if (!initResult.success) {
throw new Error(initResult.error ?? "restic init failed");
}

View file

@ -4,18 +4,12 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_HOST="192.168.2.41"
TARGET="root@$TARGET_HOST"
FIXTURE_UID="1000"
FIXTURE_GID="1000"
ARTIFACTS_DIR="$SCRIPT_DIR/artifacts/$TARGET_HOST"
KEY_PATH="$ARTIFACTS_DIR/zerobyte-sftp-ed25519"
KNOWN_HOSTS_PATH="$ARTIFACTS_DIR/known_hosts"
CONFIG_PATH="$ARTIFACTS_DIR/config.generated.json"
SMB_PASSWORD_FILE="$ARTIFACTS_DIR/smb-password.txt"
SFTP_PASSWORD_FILE="$ARTIFACTS_DIR/sftp-password.txt"
WEBDAV_PASSWORD_FILE="$ARTIFACTS_DIR/webdav-password.txt"
RESTIC_PASSWORD_FILE="$ARTIFACTS_DIR/restic-password.txt"
read_or_create_secret() {
local file_path="$1"
@ -32,31 +26,13 @@ read_or_create_secret() {
mkdir -p "$ARTIFACTS_DIR"
chmod 700 "$ARTIFACTS_DIR"
SMB_PASSWORD="$(read_or_create_secret "$SMB_PASSWORD_FILE")"
SFTP_PASSWORD="$(read_or_create_secret "$SFTP_PASSWORD_FILE")"
WEBDAV_PASSWORD="$(read_or_create_secret "$WEBDAV_PASSWORD_FILE")"
RESTIC_PASSWORD="$(read_or_create_secret "$RESTIC_PASSWORD_FILE")"
if [[ ! -f "$KEY_PATH" || ! -f "$KEY_PATH.pub" ]]; then
ssh-keygen -q -t ed25519 -N "" -C "zerobyte-backend-integration@$TARGET_HOST" -f "$KEY_PATH"
chmod 600 "$KEY_PATH"
fi
PUBLIC_KEY_BASE64="$(base64 <"$KEY_PATH.pub" | tr -d '\n')"
ssh "$TARGET" bash -s -- "$FIXTURE_UID" "$FIXTURE_GID" "$SMB_PASSWORD" "$SFTP_PASSWORD" "$WEBDAV_PASSWORD" "$RESTIC_PASSWORD" "$PUBLIC_KEY_BASE64" <<'REMOTE'
ssh "$TARGET" bash -s -- "$SFTP_PASSWORD" <<'REMOTE'
set -euo pipefail
fixture_uid="$1"
fixture_gid="$2"
smb_password="$3"
sftp_password="$4"
webdav_password="$5"
restic_password="$6"
public_key="$(printf '%s' "$7" | base64 -d)"
repo_path="/srv/zerobyte-backend-integration/restic-repo"
repo_password_fingerprint_path="$repo_path/.zerobyte-password-sha256"
repo_password_fingerprint="$(printf '%s' "$restic_password" | sha256sum | cut -d' ' -f1)"
sftp_password="$1"
legacy_sshd_dir="/etc/ssh/zerobyte-backend-integration-legacy"
export DEBIAN_FRONTEND=noninteractive
@ -65,99 +41,25 @@ write_file() {
cat >"$file_path"
}
initialize_restic_repo() {
local password_file
rm -rf "$repo_path"
install -d -o zerobyte-sftp -g zerobyte-sftp -m 0700 "$repo_path"
password_file="$(mktemp)"
printf '%s\n' "$restic_password" >"$password_file"
chown zerobyte-sftp:zerobyte-sftp "$password_file"
chmod 0600 "$password_file"
su -s /bin/sh -c "restic init --repo '$repo_path' --password-file '$password_file'" zerobyte-sftp
rm -f "$password_file"
printf '%s\n' "$repo_password_fingerprint" >"$repo_password_fingerprint_path"
chmod 0600 "$repo_password_fingerprint_path"
}
apt-get update
apt-get install -y apache2 apache2-utils nfs-kernel-server openssh-server restic rpcbind samba
apt-get install -y openssh-server
id -u zerobyte-sftp >/dev/null 2>&1 || useradd --create-home --home-dir /home/zerobyte-sftp --shell /bin/bash zerobyte-sftp
id -u zerobyte-smb >/dev/null 2>&1 || useradd --create-home --home-dir /home/zerobyte-smb --shell /bin/bash zerobyte-smb
install -d -m 0755 /srv/zerobyte-backend-integration/fixtures/case-a/docs
printf 'hello from zerobyte integration\n' >/srv/zerobyte-backend-integration/fixtures/case-a/hello.txt
printf 'fixture documentation\n' >/srv/zerobyte-backend-integration/fixtures/case-a/docs/readme.md
chown -R "$fixture_uid:$fixture_gid" /srv/zerobyte-backend-integration/fixtures
find /srv/zerobyte-backend-integration/fixtures -type d -exec chmod 0755 {} +
find /srv/zerobyte-backend-integration/fixtures -type f -exec chmod 0644 {} +
install -d -o zerobyte-sftp -g zerobyte-sftp -m 0700 /home/zerobyte-sftp
install -d -o zerobyte-sftp -g zerobyte-sftp -m 0700 /home/zerobyte-sftp/.ssh
printf '%s\n' "$public_key" >/home/zerobyte-sftp/.ssh/authorized_keys
chown zerobyte-sftp:zerobyte-sftp /home/zerobyte-sftp/.ssh/authorized_keys
chmod 0600 /home/zerobyte-sftp/.ssh/authorized_keys
printf '%s\n%s\n' "$smb_password" "$smb_password" | smbpasswd -a -s zerobyte-smb >/dev/null
smbpasswd -e zerobyte-smb >/dev/null
printf 'zerobyte-sftp:%s\n' "$sftp_password" | chpasswd
passwd -u zerobyte-sftp >/dev/null 2>&1 || true
htpasswd -bc /etc/apache2/zerobyte-backend-integration.htpasswd zerobyte-webdav "$webdav_password" >/dev/null
if [[ ! -f "$repo_path/config" ]]; then
initialize_restic_repo
elif [[ ! -f "$repo_password_fingerprint_path" ]] || [[ "$(cat "$repo_password_fingerprint_path")" != "$repo_password_fingerprint" ]]; then
initialize_restic_repo
install -d -m 0700 "$legacy_sshd_dir"
if [[ ! -f "$legacy_sshd_dir/ssh_host_rsa_key" ]]; then
ssh-keygen -q -t rsa -b 2048 -N "" -f "$legacy_sshd_dir/ssh_host_rsa_key"
fi
write_file /etc/exports <<'EOF'
/srv/zerobyte-backend-integration/fixtures *(ro,sync,no_subtree_check,insecure)
EOF
exportfs -ra
systemctl unmask rpcbind rpcbind.socket >/dev/null 2>&1
systemctl start rpcbind.socket
systemctl start rpcbind
systemctl start proc-fs-nfsd.mount
systemctl restart nfs-kernel-server
write_file /etc/samba/smb.conf <<'EOF'
[zerobyte-backend-integration]
path = /srv/zerobyte-backend-integration/fixtures
browseable = yes
read only = yes
guest ok = no
valid users = zerobyte-smb
EOF
install -d -o www-data -g www-data -m 0755 /var/lib/dav
a2enmod dav dav_fs auth_basic >/dev/null
printf 'ServerName localhost\n' >/etc/apache2/conf-available/zerobyte-backend-integration-servername.conf
a2enconf zerobyte-backend-integration-servername >/dev/null
write_file /etc/apache2/sites-available/zerobyte-backend-integration-dav.conf <<'EOF'
Alias /zerobyte-backend-integration /srv/zerobyte-backend-integration/fixtures
DAVLockDB /var/lib/dav/lockdb
<Location /zerobyte-backend-integration>
DAV On
AuthType Basic
AuthName "Zerobyte Backend Integration WebDAV"
AuthUserFile /etc/apache2/zerobyte-backend-integration.htpasswd
Require valid-user
</Location>
<Directory /srv/zerobyte-backend-integration/fixtures>
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
EOF
a2ensite zerobyte-backend-integration-dav >/dev/null
apache2ctl configtest
install -d -m 0755 /etc/ssh/sshd_config.d
write_file /etc/ssh/sshd_config.d/zerobyte-backend-integration.conf <<'EOF'
Match User zerobyte-sftp
@ -170,20 +72,63 @@ Match User zerobyte-sftp
EOF
sshd -t
systemctl restart apache2
systemctl restart smbd
write_file "$legacy_sshd_dir/sshd_config" <<EOF
Port 2222
ListenAddress 0.0.0.0
PidFile /run/zerobyte-backend-integration-legacy-sshd.pid
HostKey $legacy_sshd_dir/ssh_host_rsa_key
HostKeyAlgorithms ssh-rsa
PasswordAuthentication yes
PubkeyAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PermitTTY no
X11Forwarding no
AllowTcpForwarding no
Subsystem sftp internal-sftp
Match User zerobyte-sftp
ForceCommand internal-sftp
EOF
sshd -t -f "$legacy_sshd_dir/sshd_config"
write_file /etc/systemd/system/zerobyte-backend-integration-legacy-sshd.service <<EOF
[Unit]
Description=Zerobyte Backend Integration Legacy SFTP
After=network.target
[Service]
Type=simple
ExecStart=/usr/sbin/sshd -D -f $legacy_sshd_dir/sshd_config
ExecReload=/bin/kill -HUP \$MAINPID
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now zerobyte-backend-integration-legacy-sshd.service
systemctl restart ssh
systemctl restart zerobyte-backend-integration-legacy-sshd.service
systemctl is-active --quiet zerobyte-backend-integration-legacy-sshd.service
for _ in 1 2 3 4 5; do
ss -ltn | grep -q ':2222' && break
sleep 1
done
ss -ltn | grep -q ':2222'
REMOTE
ssh-keyscan "$TARGET_HOST" >"$KNOWN_HOSTS_PATH" 2>/dev/null
if ! ssh-keyscan -T 5 -p 2222 "$TARGET_HOST" >>"$KNOWN_HOSTS_PATH" 2>/dev/null; then
echo "Failed to scan legacy SFTP host key from $TARGET_HOST:2222" >&2
echo "Check the target service with:" >&2
echo " ssh $TARGET systemctl status zerobyte-backend-integration-legacy-sshd.service" >&2
exit 1
fi
INTEGRATION_HOST="$TARGET_HOST" \
FIXTURE_UID="$FIXTURE_UID" \
FIXTURE_GID="$FIXTURE_GID" \
SMB_PASSWORD="$SMB_PASSWORD" \
WEBDAV_PASSWORD="$WEBDAV_PASSWORD" \
RESTIC_PASSWORD="$RESTIC_PASSWORD" \
SFTP_KEY_PATH="$KEY_PATH" \
INTEGRATION_HOST="$TARGET_HOST" \
SFTP_PASSWORD="$SFTP_PASSWORD" \
KNOWN_HOSTS_PATH="$KNOWN_HOSTS_PATH" \
CONFIG_PATH="$CONFIG_PATH" \
bun run "$SCRIPT_DIR/write-generated-config.ts"

View file

@ -4,35 +4,14 @@ function getRequiredEnv(name: string) {
return process.env[name]!;
}
function getRequiredNumberEnv(name: string) {
return Number.parseInt(getRequiredEnv(name), 10);
}
const host = getRequiredEnv("INTEGRATION_HOST");
const fixtureUid = getRequiredNumberEnv("FIXTURE_UID");
const fixtureGid = getRequiredNumberEnv("FIXTURE_GID");
const smbPassword = getRequiredEnv("SMB_PASSWORD");
const webdavPassword = getRequiredEnv("WEBDAV_PASSWORD");
const resticPassword = getRequiredEnv("RESTIC_PASSWORD");
const privateKey = fs.readFileSync(getRequiredEnv("SFTP_KEY_PATH"), "utf8");
const sftpPassword = getRequiredEnv("SFTP_PASSWORD");
const knownHosts = fs.readFileSync(getRequiredEnv("KNOWN_HOSTS_PATH"), "utf8");
const configPath = getRequiredEnv("CONFIG_PATH");
const fileText = "hello from zerobyte integration\n";
const readmeText = "fixture documentation\n";
const nfsEntries = [
{ path: "hello.txt", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: fileText },
{ path: "docs", type: "directory", uid: fixtureUid, gid: fixtureGid, mode: "0755" },
{ path: "docs/readme.md", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: readmeText },
];
const smbEntries = [
{ path: "hello.txt", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: fileText },
{ path: "docs", type: "directory", uid: fixtureUid, gid: fixtureGid, mode: "0755" },
{ path: "docs/readme.md", type: "file", uid: fixtureUid, gid: fixtureGid, mode: "0644", text: readmeText },
];
const contentOnlyEntries = [
{ path: "hello.txt", type: "file", text: fileText },
{ path: "docs", type: "directory" },
@ -43,94 +22,23 @@ const config = {
version: 1,
scenarios: [
{
id: "nfs-local-repo",
volume: {
backend: "nfs",
server: host,
exportPath: "/srv/zerobyte-backend-integration/fixtures",
port: 2049,
version: "4.1",
readOnly: true,
},
repository: { backend: "local", path: "repo-nfs" },
fixtureRoot: "case-a",
expectedEntries: nfsEntries,
},
{
id: "smb-local-repo",
volume: {
backend: "smb",
server: host,
share: "zerobyte-backend-integration",
username: "zerobyte-smb",
password: smbPassword,
mapToContainerUidGid: false,
vers: "3.0",
port: 445,
readOnly: true,
},
repository: { backend: "local", path: "repo-smb" },
fixtureRoot: "case-a",
expectedEntries: smbEntries,
},
{
id: "sftp-local-repo",
id: "sftp-legacy-rsa-hostkey-local-repo",
volume: {
backend: "sftp",
host,
port: 22,
port: 2222,
username: "zerobyte-sftp",
privateKey,
password: sftpPassword,
path: "/srv/zerobyte-backend-integration/fixtures",
readOnly: true,
skipHostKeyCheck: false,
knownHosts,
allowLegacySshRsa: true,
},
repository: { backend: "local", path: "repo-sftp-volume" },
repository: { backend: "local", path: "repo-sftp-legacy-rsa-hostkey-volume" },
fixtureRoot: "case-a",
expectedEntries: contentOnlyEntries,
},
{
id: "webdav-local-repo",
volume: {
backend: "webdav",
server: host,
path: "/zerobyte-backend-integration",
username: "zerobyte-webdav",
password: webdavPassword,
port: 80,
readOnly: true,
ssl: false,
},
repository: { backend: "local", path: "repo-webdav" },
fixtureRoot: "case-a",
expectedEntries: contentOnlyEntries,
},
{
id: "nfs-sftp-repository",
volume: {
backend: "nfs",
server: host,
exportPath: "/srv/zerobyte-backend-integration/fixtures",
port: 2049,
version: "4.1",
readOnly: true,
},
repository: {
backend: "sftp",
host,
port: 22,
user: "zerobyte-sftp",
path: "/srv/zerobyte-backend-integration/restic-repo",
privateKey,
skipHostKeyCheck: false,
knownHosts,
isExistingRepository: true,
customPassword: resticPassword,
},
fixtureRoot: "case-a",
expectedEntries: nfsEntries,
},
],
};

View file

@ -1,7 +1,7 @@
import { vi } from "vitest";
import { fromAny, fromPartial } from "@total-typescript/shoehorn";
import type { SafeSpawnParams } from "@zerobyte/core/node";
import type { BackupExecutionResult } from "~/server/modules/agents/agents-manager";
import type { BackupExecutionProgress, BackupExecutionResult } from "~/server/modules/agents/agents-manager";
export const createAgentBackupMocks = (
resticBackupMock: (params: SafeSpawnParams) => Promise<{
@ -14,7 +14,15 @@ export const createAgentBackupMocks = (
const runningBackups = new Map<number, { resolve: (result: BackupExecutionResult) => void; cancelled: boolean }>();
const runBackupMock = vi.fn(
async (_agentId: string, request: { scheduleId: number; payload: { jobId: string }; signal: AbortSignal }) => {
async (
_agentId: string,
request: {
scheduleId: number;
payload: { jobId: string };
signal: AbortSignal;
onProgress: (progress: BackupExecutionProgress) => void;
},
) => {
return new Promise<BackupExecutionResult>((resolve) => {
runningBackups.set(request.scheduleId, { resolve, cancelled: false });

2
app/test/integration/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
artifacts/*
!artifacts/.gitignore

View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -0,0 +1,18 @@
ARG BASE_IMAGE=zerobyte-integration-runtime-base:latest
FROM ${BASE_IMAGE}
WORKDIR /app
ENV NODE_ENV=test
COPY ./package.json ./bun.lock ./
COPY ./packages/core/package.json ./packages/core/package.json
COPY ./packages/contracts/package.json ./packages/contracts/package.json
COPY ./apps/agent/package.json ./apps/agent/package.json
COPY ./apps/docs/package.json ./apps/docs/package.json
RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile
COPY . .
CMD ["sh", "app/test/integration/infra/entrypoint.sh"]

View file

@ -0,0 +1,128 @@
services:
rustfs:
image: rustfs/rustfs:latest
command: ["/data"]
environment:
RUSTFS_ADDRESS: ":9000"
RUSTFS_ACCESS_KEY: "rustfsadmin"
RUSTFS_SECRET_KEY: "rustfsadmin"
RUSTFS_CONSOLE_ENABLE: "false"
volumes:
- rustfs-data:/data
rustfs-setup:
image: minio/mc:latest
depends_on:
- rustfs
entrypoint: ["/bin/sh", "-c"]
command:
- |
until mc alias set rustfs http://rustfs:9000 rustfsadmin rustfsadmin --api S3v4 --path on; do
echo "Waiting for RustFS..."
sleep 1
done
mc mb --ignore-existing --region us-east-1 rustfs/zerobyte-integration
sftp:
build:
context: ./sftp
environment:
SFTP_USER: "zerobyte-sftp"
SFTP_PASSWORD: "zerobyte-sftp-password"
SFTP_PUBLIC_KEY_PATH: "/run/zerobyte/sftp/id_ed25519.pub"
volumes:
- ../artifacts/sftp/id_ed25519.pub:/run/zerobyte/sftp/id_ed25519.pub:ro
- sftp-data:/srv/zerobyte-integration
healthcheck:
test: ["CMD", "ssh-keyscan", "-T", "2", "localhost"]
interval: 1s
timeout: 5s
retries: 30
webdav:
build:
context: ./webdav
environment:
WEBDAV_USER: "zerobyte-webdav"
WEBDAV_PASSWORD: "zerobyte-webdav-password"
healthcheck:
test:
[
"CMD",
"curl",
"-fsS",
"-u",
"zerobyte-webdav:zerobyte-webdav-password",
"http://localhost/zerobyte-integration/case-a/hello.txt",
]
interval: 1s
timeout: 5s
retries: 30
smb:
build:
context: ./smb
environment:
SMB_USER: "zerobyte-smb"
SMB_PASSWORD: "zerobyte-smb-password"
SMB_SHARE: "zerobyte-integration"
healthcheck:
test:
[
"CMD",
"smbclient",
"//localhost/zerobyte-integration",
"-U",
"zerobyte-smb%zerobyte-smb-password",
"-c",
"get case-a/hello.txt /tmp/zerobyte-smb-healthcheck.txt",
]
interval: 1s
timeout: 5s
retries: 30
nfs:
build:
context: ./nfs
privileged: true
volumes:
- nfs-data:/srv/zerobyte-integration
healthcheck:
test: ["CMD", "sh", "-c", "exportfs -v | grep -q /srv/zerobyte-integration/fixtures"]
interval: 1s
timeout: 5s
retries: 30
integration:
build:
context: ../../../..
dockerfile: app/test/integration/infra/Dockerfile
args:
BASE_IMAGE: zerobyte-integration-runtime-base:latest
depends_on:
rustfs-setup:
condition: service_completed_successfully
sftp:
condition: service_healthy
webdav:
condition: service_healthy
smb:
condition: service_healthy
nfs:
condition: service_healthy
cap_add:
- SYS_ADMIN
devices:
- /dev/fuse:/dev/fuse
volumes:
- ../artifacts:/app/app/test/integration/artifacts
- ../artifacts/sftp/id_ed25519:/run/zerobyte/sftp/id_ed25519:ro
environment:
LOG_LEVEL: "error"
SFTP_PRIVATE_KEY_PATH: "/run/zerobyte/sftp/id_ed25519"
SKIP_VOLUME_MOUNT_INTEGRATION_TESTS: "${SKIP_VOLUME_MOUNT_INTEGRATION_TESTS:-false}"
volumes:
rustfs-data:
sftp-data:
nfs-data:

View file

@ -0,0 +1,9 @@
#!/usr/bin/env sh
set -eu
if [ -w /etc/fuse.conf ] && ! grep -q '^user_allow_other$' /etc/fuse.conf; then
printf '\nuser_allow_other\n' >>/etc/fuse.conf
fi
bun app/test/integration/src/write-rclone-config.ts
exec bunx --bun vitest run --config app/test/integration/vitest.config.ts

View file

@ -0,0 +1,10 @@
FROM alpine:3.21
RUN apk add --no-cache nfs-utils
COPY entrypoint.sh /usr/local/bin/zerobyte-nfs-entrypoint
RUN chmod +x /usr/local/bin/zerobyte-nfs-entrypoint
EXPOSE 2049
ENTRYPOINT ["/usr/local/bin/zerobyte-nfs-entrypoint"]

View file

@ -0,0 +1,30 @@
#!/bin/sh
set -eu
FIXTURE_ROOT="/srv/zerobyte-integration/fixtures"
install -d -m 0755 "$FIXTURE_ROOT/case-a/docs"
printf 'hello from zerobyte integration\n' >"$FIXTURE_ROOT/case-a/hello.txt"
printf 'fixture documentation\n' >"$FIXTURE_ROOT/case-a/docs/readme.md"
find "$FIXTURE_ROOT" -type d -exec chmod 0755 {} +
find "$FIXTURE_ROOT" -type f -exec chmod 0644 {} +
mkdir -p /run/rpc_pipefs /proc/fs/nfsd
mountpoint -q /proc/fs/nfsd || mount -t nfsd nfsd /proc/fs/nfsd
mountpoint -q /run/rpc_pipefs || mount -t rpc_pipefs rpc_pipefs /run/rpc_pipefs
printf '1\n' >/proc/fs/nfsd/nfsv4gracetime
printf '1\n' >/proc/fs/nfsd/nfsv4leasetime
cat >/etc/exports <<EOF
$FIXTURE_ROOT *(ro,sync,no_subtree_check,insecure,fsid=0)
EOF
rpcbind -w
rpc.mountd --no-udp --foreground &
exportfs -ra
rpc.nfsd -V 4 -V 4.1 8
trap 'exportfs -ua; rpc.nfsd 0' INT TERM
while kill -0 "$(pidof rpc.mountd)" 2>/dev/null; do
sleep 1
done

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