Compare commits
5 commits
main
...
04-16-feat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82c3eafc1c | ||
|
|
35a52554de | ||
|
|
188888ff9a | ||
|
|
a5d0cc986c | ||
|
|
0ce3f79ff1 |
268 changed files with 4375 additions and 24724 deletions
|
|
@ -3,7 +3,6 @@
|
||||||
!bun.lock
|
!bun.lock
|
||||||
!package.json
|
!package.json
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!bunfig.toml
|
|
||||||
|
|
||||||
!**/package.json
|
!**/package.json
|
||||||
!**/bun.lock
|
!**/bun.lock
|
||||||
|
|
@ -24,4 +23,3 @@
|
||||||
node_modules/**
|
node_modules/**
|
||||||
dist/**
|
dist/**
|
||||||
.output/**
|
.output/**
|
||||||
app/test/integration/artifacts/**
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ RESTIC_CACHE_DIR=./data/restic/cache
|
||||||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
||||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
||||||
APP_SECRET=<openssl rand -hex 32>
|
APP_SECRET=<openssl rand -hex 32>
|
||||||
BASE_URL=http://*.localhost
|
BASE_URL=http://localhost:300
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=false
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ runs:
|
||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||||
name: Install Bun
|
name: Install Bun
|
||||||
with:
|
with:
|
||||||
bun-version: "1.3.14"
|
bun-version: "1.3.13"
|
||||||
|
|
||||||
- uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1
|
- uses: voidzero-dev/setup-vp@8ecb39174989ce55af90f45cf55b02738599831d # v1
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
|
||||||
8
.github/renovate.json
vendored
8
.github/renovate.json
vendored
|
|
@ -2,14 +2,8 @@
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
|
"extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
|
||||||
"enabledManagers": ["bun", "github-actions"],
|
"enabledManagers": ["bun", "github-actions"],
|
||||||
"minimumReleaseAge": "3 days",
|
"minimumReleaseAge": "1 day",
|
||||||
"rangeStrategy": "bump",
|
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
|
||||||
"matchManagers": ["github-actions"],
|
|
||||||
"matchUpdateTypes": ["digest", "pinDigest"],
|
|
||||||
"minimumReleaseAge": null
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"matchManagers": ["bun"],
|
"matchManagers": ["bun"],
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
|
|
||||||
3
.github/workflows/docs-deploy.yml
vendored
3
.github/workflows/docs-deploy.yml
vendored
|
|
@ -13,7 +13,6 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy docs
|
name: Deploy docs
|
||||||
if: github.event.repository.default_branch == github.ref_name
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
|
@ -28,7 +27,7 @@ jobs:
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
||||||
- name: Build and deploy docs
|
- name: Build and deploy docs
|
||||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4
|
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3
|
||||||
with:
|
with:
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
|
|
||||||
4
.github/workflows/e2e.yml
vendored
4
.github/workflows/e2e.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
- name: Cache Playwright Browsers
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||||
|
|
@ -96,7 +96,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
docker exec zerobyte /bin/ash -c "ls -la /test-data" || true
|
docker exec zerobyte /bin/ash -c "ls -la /test-data" || true
|
||||||
|
|
||||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
|
|
|
||||||
35
.github/workflows/integration.yml
vendored
35
.github/workflows/integration.yml
vendored
|
|
@ -1,35 +0,0 @@
|
||||||
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
|
|
||||||
10
.github/workflows/nightly.yml
vendored
10
.github/workflows/nightly.yml
vendored
|
|
@ -20,20 +20,20 @@ jobs:
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||||
with:
|
with:
|
||||||
driver: cloud
|
driver: cloud
|
||||||
endpoint: "meienberger/runtipi-builder"
|
endpoint: "meienberger/runtipi-builder"
|
||||||
install: true
|
install: true
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
|
|
@ -41,14 +41,14 @@ jobs:
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
|
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=nightly
|
type=raw,value=nightly
|
||||||
|
|
||||||
- name: Push images to GitHub Container Registry
|
- name: Push images to GitHub Container Registry
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: production
|
target: production
|
||||||
|
|
|
||||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
|
|
@ -43,13 +43,10 @@ jobs:
|
||||||
e2e-tests:
|
e2e-tests:
|
||||||
uses: ./.github/workflows/e2e.yml
|
uses: ./.github/workflows/e2e.yml
|
||||||
|
|
||||||
integration-tests:
|
|
||||||
uses: ./.github/workflows/integration.yml
|
|
||||||
|
|
||||||
build-images:
|
build-images:
|
||||||
environment: release
|
environment: release
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
needs: [determine-release-type, checks, e2e-tests, integration-tests]
|
needs: [determine-release-type, checks, e2e-tests]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -63,20 +60,20 @@ jobs:
|
||||||
ref: ${{ github.ref }}
|
ref: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||||
with:
|
with:
|
||||||
driver: cloud
|
driver: cloud
|
||||||
endpoint: "meienberger/runtipi-builder"
|
endpoint: "meienberger/runtipi-builder"
|
||||||
install: true
|
install: true
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
|
|
@ -84,7 +81,7 @@ jobs:
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
|
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
images: ghcr.io/${{ github.repository_owner }}/zerobyte
|
||||||
tags: |
|
tags: |
|
||||||
|
|
@ -96,7 +93,7 @@ jobs:
|
||||||
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
|
||||||
|
|
||||||
- name: Push images to GitHub Container Registry
|
- name: Push images to GitHub Container Registry
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
|
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: production
|
target: production
|
||||||
|
|
@ -128,6 +125,7 @@ jobs:
|
||||||
name: ${{ needs.determine-release-type.outputs.tagname }}
|
name: ${{ needs.determine-release-type.outputs.tagname }}
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
files: cli/runtipi-cli-*
|
||||||
|
|
||||||
request-docs-version-update:
|
request-docs-version-update:
|
||||||
uses: ./.github/workflows/request-docs-version-update.yml
|
uses: ./.github/workflows/request-docs-version-update.yml
|
||||||
|
|
|
||||||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -36,6 +36,6 @@ jobs:
|
||||||
retention-days: 5
|
retention-days: 5
|
||||||
|
|
||||||
- name: Upload to code-scanning
|
- name: Upload to code-scanning
|
||||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -15,7 +15,7 @@ notes.md
|
||||||
smb-password.txt
|
smb-password.txt
|
||||||
cache.db
|
cache.db
|
||||||
|
|
||||||
/data
|
data/
|
||||||
|
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
@ -34,11 +34,8 @@ node_modules/
|
||||||
|
|
||||||
playwright/.auth
|
playwright/.auth
|
||||||
playwright/temp
|
playwright/temp
|
||||||
playwright/data
|
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
.deepsec/
|
|
||||||
.codex/
|
|
||||||
|
|
||||||
# OpenAPI error logs
|
# OpenAPI error logs
|
||||||
openapi-ts-error-*.log
|
openapi-ts-error-*.log
|
||||||
|
|
|
||||||
17
AGENTS.md
17
AGENTS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
- Never create migration files manually. Always use the provided command to generate migrations
|
- 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
|
- 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 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 dev server is running at http://localhost:3000. Username is `admin` and password is `password`
|
||||||
- The repo is https://github.com/nicotsx/zerobyte
|
- 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`
|
- 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,21 +10,6 @@
|
||||||
|
|
||||||
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).
|
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
|
### Type Checking
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
17
Dockerfile
17
Dockerfile
|
|
@ -1,8 +1,8 @@
|
||||||
FROM oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0 AS base
|
FROM oven/bun:1.3.13-alpine@sha256:4de475389889577f346c636f956b42a5c31501b654664e9ae5726f94d7bb5349 AS base
|
||||||
|
|
||||||
ARG RESTIC_VERSION="0.18.1"
|
ARG RESTIC_VERSION="0.18.1"
|
||||||
ARG RCLONE_VERSION="1.74.2"
|
ARG RCLONE_VERSION="1.74.0"
|
||||||
ARG SHOUTRRR_VERSION="0.15.1"
|
ARG SHOUTRRR_VERSION="0.14.3"
|
||||||
|
|
||||||
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
||||||
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
|
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
|
||||||
|
|
@ -10,7 +10,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
||||||
|
|
||||||
RUN apk update --no-cache && \
|
RUN apk update --no-cache && \
|
||||||
apk upgrade --no-cache && \
|
apk upgrade --no-cache && \
|
||||||
apk add --no-cache acl attr cifs-utils davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
|
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
|
||||||
|
|
||||||
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
ENTRYPOINT ["/sbin/tini", "-s", "--"]
|
||||||
|
|
||||||
|
|
@ -44,15 +44,6 @@ RUN bzip2 -d restic.bz2 && chmod +x restic
|
||||||
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
|
RUN mv rclone-v*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
|
||||||
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
|
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
|
# DEVELOPMENT
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
|
|
|
||||||
110
README.md
110
README.md
|
|
@ -20,13 +20,13 @@
|
||||||
[](https://discord.gg/XjgVyXrcEH)
|
[](https://discord.gg/XjgVyXrcEH)
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedback. Please open issues for bugs or feature requests.
|
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Please open issues for bugs or feature requests
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Introduction
|
## Intro
|
||||||
|
|
||||||
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
|
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
|
||||||
|
|
||||||
|
|
@ -38,10 +38,10 @@ It contains up-to-date setup guides, configuration reference, and usage document
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- **Automated backups** with encryption, compression, and retention policies, powered by Restic
|
- **Automated backups** with encryption, compression and retention policies powered by Restic
|
||||||
- **Flexible scheduling** for automated backup jobs with fine-grained retention policies
|
- **Flexible scheduling** For automated backup jobs with fine-grained retention policies
|
||||||
- **End-to-end encryption** will ensure your data is always protected
|
- **End-to-end encryption** ensuring your data is always protected
|
||||||
- **Multi-protocol support** for backup from NFS, SMB, WebDAV, SFTP, or local directories
|
- **Multi-protocol support**: Backup from NFS, SMB, WebDAV, SFTP, or local directories
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -50,7 +50,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
image: ghcr.io/nicotsx/zerobyte:v0.36
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -69,10 +69,10 @@ services:
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding). If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
|
> It is highly discouraged to run Zerobyte on a server that is accessible from the internet (VPS or home server with port forwarding) If you do, make sure to change the port mapping to "127.0.0.1:4096:4096" and use a secure tunnel (SSH tunnel, Cloudflare Tunnel, etc.) with authentication.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Do not try to point `/var/lib/zerobyte` to a network share. You will face permission issues and strong performance degradation.
|
> Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **TrueNAS Users:** The host path `/var/lib` is ephemeral on TrueNAS and will be reset during system upgrades. Instead of using `/var/lib/zerobyte:/var/lib/zerobyte`, create a dedicated ZFS dataset (e.g., `tank/docker/zerobyte`) and mount it instead:
|
> **TrueNAS Users:** The host path `/var/lib` is ephemeral on TrueNAS and will be reset during system upgrades. Instead of using `/var/lib/zerobyte:/var/lib/zerobyte`, create a dedicated ZFS dataset (e.g., `tank/docker/zerobyte`) and mount it instead:
|
||||||
|
|
@ -97,26 +97,26 @@ Once the container is running, you can access the web interface at `http://<your
|
||||||
|
|
||||||
Zerobyte can be customized using environment variables. Below are the available options:
|
Zerobyte can be customized using environment variables. Below are the available options:
|
||||||
|
|
||||||
### Environment variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| :------------------------ | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
|
| :-------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- | :--------------------- |
|
||||||
| `BASE_URL` | **Required.** The base URL of your Zerobyte instance (e.g., `https://zerobyte.example.com`). See [Authentication](#authentication) below. | (none) |
|
| `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` | **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) |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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) |
|
| `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_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` |
|
| `WEBHOOK_TIMEOUT` | Timeout for backup webhook requests in seconds. | `60` |
|
||||||
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
|
| `LOG_LEVEL` | Logging verbosity. Options: `debug`, `info`, `warn`, `error`. | `info` |
|
||||||
| `SERVER_IDLE_TIMEOUT` | Idle timeout for the server in seconds. | `60` |
|
| `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` |
|
| `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) |
|
| `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) |
|
||||||
|
|
||||||
### Webhook and notification network policy
|
### Webhook and Notification Network Policy
|
||||||
|
|
||||||
Backup webhooks and outbound notification destinations that can target arbitrary network hosts are restricted by `WEBHOOK_ALLOWED_ORIGINS`.
|
Backup webhooks and outbound notification destinations that can target arbitrary network hosts are restricted by `WEBHOOK_ALLOWED_ORIGINS`.
|
||||||
|
|
||||||
|
|
@ -136,7 +136,7 @@ Backup webhooks do not follow redirects. Add the final destination origin to `WE
|
||||||
|
|
||||||
Webhook headers are stored as plain text and must use one `Key: Value` header per line. `WEBHOOK_TIMEOUT` controls backup pre/post webhook request timeouts; notification delivery uses the underlying provider sender behavior.
|
Webhook headers are stored as plain text and must use one `Key: Value` header per line. `WEBHOOK_TIMEOUT` controls backup pre/post webhook request timeouts; notification delivery uses the underlying provider sender behavior.
|
||||||
|
|
||||||
### Provisioned resources
|
### Provisioned Resources
|
||||||
|
|
||||||
Zerobyte can sync operator-managed repositories and volumes from a JSON file at startup. This is useful when you want credentials or connection details to live in deployment-time configuration instead of being entered through the UI.
|
Zerobyte can sync operator-managed repositories and volumes from a JSON file at startup. This is useful when you want credentials or connection details to live in deployment-time configuration instead of being entered through the UI.
|
||||||
|
|
||||||
|
|
@ -149,14 +149,14 @@ The complete provisioning documentation is available at [zerobyte.app/docs/guide
|
||||||
|
|
||||||
See `examples/provisioned-resources/README.md` for a full example.
|
See `examples/provisioned-resources/README.md` for a full example.
|
||||||
|
|
||||||
### Simplified setup (no remote mounts)
|
### Simplified setup (No remote mounts)
|
||||||
|
|
||||||
If you only need to back up locally-mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
|
If you only need to back up locally mounted folders and don't require remote share mounting capabilities, you can remove the `SYS_ADMIN` capability and FUSE device from your `docker-compose.yml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
image: ghcr.io/nicotsx/zerobyte:v0.36
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -174,8 +174,8 @@ services:
|
||||||
**Trade-offs:**
|
**Trade-offs:**
|
||||||
|
|
||||||
- ✅ Improved security by reducing container capabilities
|
- ✅ Improved security by reducing container capabilities
|
||||||
- ✅ Support for local directories as backup sources
|
- ✅ Support for local directories
|
||||||
- ✅ Support all repository types, local and remote (S3, GCS, Azure, rclone)
|
- ✅ Keep support all repository types (local, S3, GCS, Azure, rclone)
|
||||||
- ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte
|
- ❌ Cannot mount NFS, SMB, WebDAV, or SFTP shares directly from Zerobyte
|
||||||
|
|
||||||
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
|
If you need remote mount capabilities, keep the original configuration with `cap_add: SYS_ADMIN` and `devices: /dev/fuse:/dev/fuse`.
|
||||||
|
|
@ -190,12 +190,12 @@ Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, SFTP, and
|
||||||
|
|
||||||
To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings.
|
To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings.
|
||||||
|
|
||||||
If you want to backup a local directory on the same host where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
|
If you want to track a local directory on the same server where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
|
||||||
|
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
image: ghcr.io/nicotsx/zerobyte:v0.36
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -223,17 +223,17 @@ docker compose up -d
|
||||||
|
|
||||||
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
|
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Creating a repository
|
## Creating a repository
|
||||||
|
|
||||||
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
|
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
|
||||||
|
|
||||||
- **Local directories**: Store backups on local disk subfolders in `/var/lib/zerobyte/repositories/` or any other (mounted) path
|
- **Local directories** - Store backups on local disk subfolder of `/var/lib/zerobyte/repositories/` or any other (mounted) path
|
||||||
- **S3-compatible storage**: Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
|
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
|
||||||
- **Google Cloud Storage**: Google's cloud storage service
|
- **Google Cloud Storage** - Google's cloud storage service
|
||||||
- **Azure Blob Storage**: Microsoft Azure storage
|
- **Azure Blob Storage** - Microsoft Azure storage
|
||||||
- **rclone remotes**: 40+ cloud storage providers via rclone (see below)
|
- **rclone remotes** - 40+ cloud storage providers via rclone (see below)
|
||||||
|
|
||||||
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
|
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
|
||||||
|
|
||||||
|
|
@ -270,7 +270,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
|
||||||
```diff
|
```diff
|
||||||
services:
|
services:
|
||||||
zerobyte:
|
zerobyte:
|
||||||
image: ghcr.io/nicotsx/zerobyte:v0.38
|
image: ghcr.io/nicotsx/zerobyte:v0.36
|
||||||
container_name: zerobyte
|
container_name: zerobyte
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
cap_add:
|
cap_add:
|
||||||
|
|
@ -324,30 +324,24 @@ When creating a backup job, you can specify the following settings:
|
||||||
After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule.
|
After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule.
|
||||||
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Restoring data
|
## Restoring data
|
||||||
|
|
||||||
Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
|
Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
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:
|
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
|
### Cookie Security
|
||||||
|
|
||||||
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.
|
- **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.
|
||||||
|
- **Domain / HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
|
||||||
|
|
||||||
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.
|
### Reverse Proxy Setup
|
||||||
|
|
||||||
### 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.
|
|
||||||
- **Domain/HTTPS access**: Set `BASE_URL=https://zerobyte.example.com`. Cookies will automatically use `Secure: true` for protected sessions.
|
|
||||||
|
|
||||||
### Reverse proxy setup
|
|
||||||
|
|
||||||
If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
|
If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
|
||||||
|
|
||||||
|
|
@ -355,7 +349,7 @@ If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
|
||||||
2. The app will automatically enable secure cookies based on the `https://` prefix
|
2. The app will automatically enable secure cookies based on the `https://` prefix
|
||||||
3. Ensure your proxy passes the `X-Forwarded-Proto` header
|
3. Ensure your proxy passes the `X-Forwarded-Proto` header
|
||||||
|
|
||||||
### Important notes
|
### Important Notes
|
||||||
|
|
||||||
- The `BASE_URL` must start with `https://` for secure cookies to be enabled
|
- The `BASE_URL` must start with `https://` for secure cookies to be enabled
|
||||||
- Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically
|
- Local IP addresses (e.g., `http://192.168.x.x`) are **not** treated as secure contexts by browsers, so secure cookies are disabled automatically
|
||||||
|
|
@ -366,7 +360,7 @@ If you're running Zerobyte behind a reverse proxy (Nginx, Traefik, Caddy, etc.):
|
||||||
|
|
||||||
For troubleshooting common issues, please refer to the [TROUBLESHOOTING.md](TROUBLESHOOTING.md) file.
|
For troubleshooting common issues, please refer to the [TROUBLESHOOTING.md](TROUBLESHOOTING.md) file.
|
||||||
|
|
||||||
## Third-party software
|
## Third-Party Software
|
||||||
|
|
||||||
This project includes the following third-party software components:
|
This project includes the following third-party software components:
|
||||||
|
|
||||||
|
|
@ -404,10 +398,10 @@ RESTIC_PASS_FILE=./data/restic.pass
|
||||||
RESTIC_CACHE_DIR=./data/restic/cache
|
RESTIC_CACHE_DIR=./data/restic/cache
|
||||||
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
ZEROBYTE_REPOSITORIES_DIR=./data/repositories
|
||||||
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
ZEROBYTE_VOLUMES_DIR=./data/volumes
|
||||||
BASE_URL=https://*.localhost
|
BASE_URL=http://localhost:4096
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable.
|
- Remote mount backends (NFS/SMB/WebDAV/SFTP) rely on Linux mount tooling and `CAP_SYS_ADMIN`; on macOS they are expected to be unavailable.
|
||||||
- To actually run backups/repository checks, install `restic` on your machine (e.g., via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.
|
- To actually run backups/repository checks, install `restic` on your machine (e.g. via Homebrew). If `restic` is not installed, the app still starts but backup operations will fail with a clear error.
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.security) {
|
if (opts.security) {
|
||||||
await setAuthParams(opts);
|
await setAuthParams({
|
||||||
|
...opts,
|
||||||
|
security: opts.security,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.requestValidator) {
|
if (opts.requestValidator) {
|
||||||
|
|
|
||||||
|
|
@ -119,12 +119,14 @@ const checkForExistence = (
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function setAuthParams(
|
export const setAuthParams = async ({
|
||||||
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
|
security,
|
||||||
|
...options
|
||||||
|
}: Pick<Required<RequestOptions>, 'security'> &
|
||||||
|
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||||
headers: Headers;
|
headers: Headers;
|
||||||
},
|
}) => {
|
||||||
): Promise<void> {
|
for (const auth of security) {
|
||||||
for (const auth of options.security ?? []) {
|
|
||||||
if (checkForExistence(options, auth.name)) {
|
if (checkForExistence(options, auth.name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +155,7 @@ export async function setAuthParams(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||||
getUrl({
|
getUrl({
|
||||||
|
|
|
||||||
|
|
@ -337,7 +337,14 @@ export type ListVolumesResponses = {
|
||||||
200: Array<{
|
200: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -352,7 +359,6 @@ export type ListVolumesResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -386,15 +392,7 @@ export type ListVolumesResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
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;
|
autoRemount: boolean;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
@ -418,7 +416,6 @@ export type CreateVolumeData = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -452,7 +449,6 @@ export type CreateVolumeData = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|
@ -467,7 +463,14 @@ export type CreateVolumeResponses = {
|
||||||
201: {
|
201: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -482,7 +485,6 @@ export type CreateVolumeResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -516,15 +518,7 @@ export type CreateVolumeResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
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;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -547,7 +541,6 @@ export type TestConnectionData = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -581,7 +574,6 @@ export type TestConnectionData = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|
@ -645,7 +637,14 @@ export type GetVolumeResponses = {
|
||||||
volume: {
|
volume: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -660,7 +659,6 @@ export type GetVolumeResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -694,21 +692,13 @@ export type GetVolumeResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
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;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
statfs: {
|
statfs: {
|
||||||
total?: number;
|
total: number;
|
||||||
used?: number;
|
used: number;
|
||||||
free?: number;
|
free: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -733,7 +723,6 @@ export type UpdateVolumeData = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -767,7 +756,6 @@ export type UpdateVolumeData = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
path: {
|
path: {
|
||||||
|
|
@ -791,7 +779,14 @@ export type UpdateVolumeResponses = {
|
||||||
200: {
|
200: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -806,7 +801,6 @@ export type UpdateVolumeResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -840,15 +834,7 @@ export type UpdateVolumeResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
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;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -869,8 +855,8 @@ export type MountVolumeResponses = {
|
||||||
* Volume mounted successfully
|
* Volume mounted successfully
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
error?: string;
|
error?: string;
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -890,8 +876,8 @@ export type UnmountVolumeResponses = {
|
||||||
* Volume unmounted successfully
|
* Volume unmounted successfully
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
error?: string;
|
error?: string;
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -918,8 +904,8 @@ export type HealthCheckVolumeResponses = {
|
||||||
* Volume health check result
|
* Volume health check result
|
||||||
*/
|
*/
|
||||||
200: {
|
200: {
|
||||||
status: 'mounted' | 'unmounted' | 'error';
|
|
||||||
error?: string;
|
error?: string;
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -946,7 +932,7 @@ export type ListFilesResponses = {
|
||||||
files: Array<{
|
files: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: 'directory' | 'file';
|
type: 'file' | 'directory';
|
||||||
size?: number;
|
size?: number;
|
||||||
modifiedAt?: number;
|
modifiedAt?: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
@ -980,8 +966,8 @@ export type BrowseFilesystemResponses = {
|
||||||
directories: Array<{
|
directories: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: 'directory';
|
type: 'file' | 'directory';
|
||||||
size?: unknown;
|
size?: number;
|
||||||
modifiedAt?: number;
|
modifiedAt?: number;
|
||||||
}>;
|
}>;
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -1150,7 +1136,6 @@ export type ListRepositoriesResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -1334,7 +1319,6 @@ export type CreateRepositoryData = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -1573,7 +1557,6 @@ export type GetRepositoryResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -1757,7 +1740,6 @@ export type UpdateRepositoryData = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -1949,7 +1931,6 @@ export type UpdateRepositoryResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -2267,7 +2248,6 @@ export type RestoreSnapshotData = {
|
||||||
excludeXattr?: Array<string>;
|
excludeXattr?: Array<string>;
|
||||||
delete?: boolean;
|
delete?: boolean;
|
||||||
targetPath?: string;
|
targetPath?: string;
|
||||||
targetAgentId?: string;
|
|
||||||
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
|
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
|
||||||
};
|
};
|
||||||
path: {
|
path: {
|
||||||
|
|
@ -2279,11 +2259,13 @@ export type RestoreSnapshotData = {
|
||||||
|
|
||||||
export type RestoreSnapshotResponses = {
|
export type RestoreSnapshotResponses = {
|
||||||
/**
|
/**
|
||||||
* Snapshot restore started
|
* Snapshot restored successfully
|
||||||
*/
|
*/
|
||||||
202: {
|
200: {
|
||||||
restoreId: string;
|
success: boolean;
|
||||||
status: 'started';
|
message: string;
|
||||||
|
filesRestored: number;
|
||||||
|
filesSkipped: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2475,7 +2457,14 @@ export type ListBackupSchedulesResponses = {
|
||||||
volume: {
|
volume: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -2490,7 +2479,6 @@ export type ListBackupSchedulesResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -2524,15 +2512,7 @@ export type ListBackupSchedulesResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
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;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
repository: {
|
repository: {
|
||||||
|
|
@ -2684,7 +2664,6 @@ export type ListBackupSchedulesResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -2896,7 +2875,14 @@ export type GetBackupScheduleResponses = {
|
||||||
volume: {
|
volume: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -2911,7 +2897,6 @@ export type GetBackupScheduleResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -2945,15 +2930,7 @@ export type GetBackupScheduleResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
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;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
repository: {
|
repository: {
|
||||||
|
|
@ -3105,7 +3082,6 @@ export type GetBackupScheduleResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -3298,7 +3274,14 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
volume: {
|
volume: {
|
||||||
id: number;
|
id: number;
|
||||||
shortId: string;
|
shortId: string;
|
||||||
|
provisioningId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'directory' | 'webdav' | 'rclone' | 'sftp';
|
||||||
|
status: 'mounted' | 'unmounted' | 'error';
|
||||||
|
lastError: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
lastHealthCheck: number;
|
||||||
config: {
|
config: {
|
||||||
backend: 'nfs';
|
backend: 'nfs';
|
||||||
server: string;
|
server: string;
|
||||||
|
|
@ -3313,7 +3296,6 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
guest?: boolean;
|
guest?: boolean;
|
||||||
mapToContainerUidGid?: boolean;
|
|
||||||
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
vers?: '1.0' | '2.0' | '2.1' | '3.0' | 'auto';
|
||||||
domain?: string;
|
domain?: string;
|
||||||
port?: string | number;
|
port?: string | number;
|
||||||
|
|
@ -3347,15 +3329,7 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
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;
|
autoRemount: boolean;
|
||||||
};
|
};
|
||||||
repository: {
|
repository: {
|
||||||
|
|
@ -3507,7 +3481,6 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -3982,7 +3955,6 @@ export type GetScheduleMirrorsResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
@ -4195,7 +4167,6 @@ export type UpdateScheduleMirrorsResponses = {
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
skipHostKeyCheck?: boolean;
|
skipHostKeyCheck?: boolean;
|
||||||
knownHosts?: string;
|
knownHosts?: string;
|
||||||
allowLegacySshRsa?: boolean;
|
|
||||||
isExistingRepository?: boolean;
|
isExistingRepository?: boolean;
|
||||||
customPassword?: string;
|
customPassword?: string;
|
||||||
cacert?: string;
|
cacert?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useHotkeySequence } from "@tanstack/react-hotkeys";
|
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||||
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getDevPanelOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { DevPanel } from "./dev-panel";
|
import { DevPanel } from "./dev-panel";
|
||||||
|
|
||||||
|
|
@ -10,12 +10,7 @@ export function DevPanelListener() {
|
||||||
...getDevPanelOptions(),
|
...getDevPanelOptions(),
|
||||||
});
|
});
|
||||||
|
|
||||||
useHotkeySequence(["D", "E", "V"], () => setIsOpen(true), {
|
useHotkey("Mod+Shift+D", () => setIsOpen(true), { enabled: !!devPanelStatus?.enabled, preventDefault: true });
|
||||||
enabled: !!devPanelStatus?.enabled,
|
|
||||||
preventDefault: true,
|
|
||||||
ignoreInputs: true,
|
|
||||||
timeout: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!devPanelStatus?.enabled) {
|
if (!devPanelStatus?.enabled) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,29 @@ 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 () => {
|
test("shows the query root contents when display and query roots differ", async () => {
|
||||||
mockListSnapshotFiles();
|
mockListSnapshotFiles();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-b
|
||||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
|
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||||
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
||||||
function createPathPrefixFns(basePath: string) {
|
function createPathPrefixFns(basePath: string) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -83,6 +84,16 @@ 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,
|
pathTransform: displayPathFns,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { OTPInput, OTPInputContext } from "input-otp";
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
|
import { MinusIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
|
|
||||||
|
|
@ -54,8 +55,12 @@ function InputOTPSlot({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"hr">) {
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
return <hr data-slot="input-otp-separator" {...props} />;
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useTheme } from "~/client/components/theme-provider";
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
const { theme } = useTheme();
|
const { theme = "dark" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Cloud, Folder, Server } from "lucide-react";
|
import { Cloud, Folder, Server } from "lucide-react";
|
||||||
import type { BackendType } from "@zerobyte/contracts/volumes";
|
import type { BackendType } from "~/schemas/volumes";
|
||||||
|
|
||||||
type VolumeIconProps = {
|
type VolumeIconProps = {
|
||||||
backend: BackendType;
|
backend: BackendType;
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
inferAdditionalFields,
|
inferAdditionalFields,
|
||||||
} from "better-auth/client/plugins";
|
} from "better-auth/client/plugins";
|
||||||
import { ssoClient } from "@better-auth/sso/client";
|
import { ssoClient } from "@better-auth/sso/client";
|
||||||
import { passkeyClient } from "@better-auth/passkey/client";
|
|
||||||
import type { auth } from "~/server/lib/auth";
|
import type { auth } from "~/server/lib/auth";
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
|
|
@ -18,6 +17,5 @@ export const authClient = createAuthClient({
|
||||||
organizationClient(),
|
organizationClient(),
|
||||||
ssoClient(),
|
ssoClient(),
|
||||||
twoFactorClient(),
|
twoFactorClient(),
|
||||||
passkeyClient(),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +1,16 @@
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { HttpResponse, http, server } from "~/test/msw/server";
|
import { HttpResponse, http, server } from "~/test/msw/server";
|
||||||
import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils";
|
import { cleanup, render, screen } 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) => {
|
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useNavigate: (() => mockNavigate) as typeof actual.useNavigate,
|
useNavigate: (() => vi.fn(async () => {})) 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";
|
import { LoginPage } from "../login";
|
||||||
const inviteOnlyMessage =
|
const inviteOnlyMessage =
|
||||||
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
||||||
|
|
@ -62,12 +29,6 @@ const mockSsoProvidersRequest = (
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mockGetLoginOptions.mockClear();
|
|
||||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false });
|
|
||||||
mockNavigate.mockClear();
|
|
||||||
mockPasskeySignIn.mockClear();
|
|
||||||
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -120,14 +81,6 @@ describe("LoginPage", () => {
|
||||||
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
|
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 () => {
|
test("does not show error message for invalid error codes", async () => {
|
||||||
mockSsoProvidersRequest();
|
mockSsoProvidersRequest();
|
||||||
|
|
||||||
|
|
@ -137,141 +90,11 @@ describe("LoginPage", () => {
|
||||||
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
|
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders available SSO providers from the alternative sign-in section", async () => {
|
test("renders available SSO providers from the real SSO section", async () => {
|
||||||
mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]);
|
mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]);
|
||||||
|
|
||||||
render(<LoginPage />, { withSuspense: true });
|
render(<LoginPage />, { withSuspense: true });
|
||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy();
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,11 @@ import { Button } from "~/client/components/ui/button";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { parseError } from "~/client/lib/errors";
|
|
||||||
import {
|
|
||||||
RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE,
|
|
||||||
RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME,
|
|
||||||
} from "~/lib/recovery-key-skip";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
|
export function DownloadRecoveryKeyPage() {
|
||||||
"Downloading the recovery key requires a local credential password. Ask an operator to run `docker exec -it zerobyte bun run cli reset-password` for your user, then sign in with that password and try again.";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
hasCredentialPassword: boolean;
|
|
||||||
userId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [blockedMessage, setBlockedMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const downloadResticPassword = useMutation({
|
const downloadResticPassword = useMutation({
|
||||||
...downloadResticPasswordMutation(),
|
...downloadResticPasswordMutation(),
|
||||||
|
|
@ -39,16 +25,13 @@ export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.success("Recovery key downloaded successfully!");
|
toast.success("Recovery key downloaded successfully!");
|
||||||
setBlockedMessage(null);
|
|
||||||
void navigate({ to: "/volumes", replace: true });
|
void navigate({ to: "/volumes", replace: true });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = parseError(error)?.message;
|
toast.error("Failed to download recovery key", { description: error.message });
|
||||||
setBlockedMessage(message?.includes("credential password") ? message : null);
|
|
||||||
toast.error("Failed to download recovery key", { description: message });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -60,15 +43,11 @@ export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBlockedMessage(null);
|
downloadResticPassword.mutate({
|
||||||
downloadResticPassword.mutate({ body: { password } });
|
body: {
|
||||||
};
|
password,
|
||||||
|
},
|
||||||
const handleSkip = () => {
|
});
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
document.cookie = `${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME}=${userId}; path=/; max-age=${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE}`;
|
|
||||||
void navigate({ to: "/volumes", replace: true });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -80,56 +59,30 @@ export function DownloadRecoveryKeyPage({ hasCredentialPassword, userId }: Props
|
||||||
<AlertTriangle className="size-5" />
|
<AlertTriangle className="size-5" />
|
||||||
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
<AlertTitle>Important: Save This File Securely</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Your Restic password is essential for recovering your backup data. If you previously downloaded this
|
Your Restic password is essential for recovering your backup data. If you lose access to this server without
|
||||||
file, replace that saved copy with the new download. If you lose access to this server without this
|
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
|
||||||
file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{(!hasCredentialPassword || blockedMessage) && (
|
<div className="space-y-2">
|
||||||
<Alert variant="warning">
|
<Label htmlFor="password">Confirm Your Password</Label>
|
||||||
<AlertTriangle className="size-5" />
|
<Input
|
||||||
<AlertTitle>Local password required</AlertTitle>
|
id="password"
|
||||||
<AlertDescription>
|
type="password"
|
||||||
{blockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
|
value={password}
|
||||||
</AlertDescription>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
</Alert>
|
placeholder="Enter your password"
|
||||||
)}
|
required
|
||||||
|
disabled={downloadResticPassword.isPending}
|
||||||
{hasCredentialPassword && (
|
/>
|
||||||
<div className="space-y-2">
|
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>
|
||||||
<Label htmlFor="password">Confirm Your Password</Label>
|
</div>
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="Enter your password"
|
|
||||||
required
|
|
||||||
disabled={downloadResticPassword.isPending}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Enter your account password to download the recovery key
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{hasCredentialPassword && (
|
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
||||||
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
|
<Download size={16} className="mr-2" />
|
||||||
<Download size={16} className="mr-2" />
|
Download Recovery Key
|
||||||
Download Recovery Key
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleSkip}
|
|
||||||
disabled={downloadResticPassword.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AuthLayout } from "~/client/components/auth-layout";
|
import { AuthLayout } from "~/client/components/auth-layout";
|
||||||
|
|
@ -10,14 +10,12 @@ import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/clie
|
||||||
import { Label } from "~/client/components/ui/label";
|
import { Label } from "~/client/components/ui/label";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { logger } from "~/client/lib/logger";
|
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 { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
|
||||||
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
|
|
||||||
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
import { ResetPasswordDialog } from "../components/reset-password-dialog";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { normalizeUsername } from "~/lib/username";
|
import { normalizeUsername } from "~/lib/username";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { AlternativeSignInSection } from "../components/alternative-sign-in-section";
|
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
|
|
@ -34,23 +32,6 @@ type LoginPageProps = {
|
||||||
error?: string;
|
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(";")
|
|
||||||
.some((cookie) => cookie.trim() === `${RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME}=${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoginPage({ error }: LoginPageProps = {}) {
|
export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||||
|
|
@ -62,52 +43,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
const errorCode = decodeLoginError(error);
|
const errorCode = decodeLoginError(error);
|
||||||
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
|
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>({
|
const form = useForm<LoginFormValues>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|
@ -141,7 +76,12 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await navigateAfterLogin();
|
const d = await authClient.getSession();
|
||||||
|
if (data.user && !d.data?.user.hasDownloadedResticPassword) {
|
||||||
|
void navigate({ to: "/download-recovery-key" });
|
||||||
|
} else {
|
||||||
|
void navigate({ to: "/volumes" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerify2FA = async () => {
|
const handleVerify2FA = async () => {
|
||||||
|
|
@ -173,11 +113,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
if (data) {
|
if (data) {
|
||||||
toast.success("Login successful");
|
toast.success("Login successful");
|
||||||
const session = await authClient.getSession();
|
const session = await authClient.getSession();
|
||||||
if (
|
if (session.data?.user && !session.data.user.hasDownloadedResticPassword) {
|
||||||
session.data?.user &&
|
|
||||||
!session.data.user.hasDownloadedResticPassword &&
|
|
||||||
!hasSkippedRecoveryKeyDownload(session.data.user.id)
|
|
||||||
) {
|
|
||||||
void navigate({ to: "/download-recovery-key" });
|
void navigate({ to: "/download-recovery-key" });
|
||||||
} else {
|
} else {
|
||||||
void navigate({ to: "/volumes" });
|
void navigate({ to: "/volumes" });
|
||||||
|
|
@ -194,10 +130,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
|
|
||||||
if (requires2FA) {
|
if (requires2FA) {
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
|
||||||
title="Two-Factor Authentication"
|
|
||||||
description="Enter the 6-digit code from your authenticator app"
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="space-y-4 flex flex-col items-center">
|
<div className="space-y-4 flex flex-col items-center">
|
||||||
<Label htmlFor="totp-code">Authentication code</Label>
|
<Label htmlFor="totp-code">Authentication code</Label>
|
||||||
|
|
@ -228,7 +161,6 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="trust-device"
|
id="trust-device"
|
||||||
aria-label="Trust this device for 30 days"
|
|
||||||
checked={trustDevice}
|
checked={trustDevice}
|
||||||
onChange={(e) => setTrustDevice(e.target.checked)}
|
onChange={(e) => setTrustDevice(e.target.checked)}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
|
|
@ -267,11 +199,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
|
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div
|
<div className={cn("rounded-md border border-destructive/50 p-3 text-sm", { hidden: !errorDescription })}>
|
||||||
className={cn("rounded-md border border-destructive/50 p-3 text-sm", {
|
|
||||||
hidden: !errorDescription,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{errorDescription}
|
{errorDescription}
|
||||||
</div>
|
</div>
|
||||||
<FormField
|
<FormField
|
||||||
|
|
@ -281,13 +209,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} />
|
||||||
{...field}
|
|
||||||
type="text"
|
|
||||||
placeholder="admin"
|
|
||||||
disabled={isLoggingIn}
|
|
||||||
autoComplete="username webauthn"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -309,12 +231,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} type="password" disabled={isLoggingIn} />
|
||||||
{...field}
|
|
||||||
type="password"
|
|
||||||
disabled={isLoggingIn}
|
|
||||||
autoComplete="current-password webauthn"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -326,7 +243,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<AlternativeSignInSection onPasskeySignIn={navigateAfterLogin} />
|
<SsoLoginSection />
|
||||||
|
|
||||||
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,9 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
|
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
|
||||||
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(initialSnapshotId);
|
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(
|
||||||
|
initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id,
|
||||||
|
);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -77,10 +79,7 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
isLoading,
|
isLoading,
|
||||||
failureReason,
|
failureReason,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
...listSnapshotsOptions({
|
...listSnapshotsOptions({ path: { shortId: schedule.repository.shortId }, query: { backupId: schedule.shortId } }),
|
||||||
path: { shortId: schedule.repository.shortId },
|
|
||||||
query: { backupId: schedule.shortId },
|
|
||||||
}),
|
|
||||||
initialData: loaderData.snapshots,
|
initialData: loaderData.snapshots,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -169,8 +168,6 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
oneFileSystem: schedule.oneFileSystem,
|
oneFileSystem: schedule.oneFileSystem,
|
||||||
customResticParams: schedule.customResticParams || [],
|
customResticParams: schedule.customResticParams || [],
|
||||||
backupWebhooks: schedule.backupWebhooks,
|
backupWebhooks: schedule.backupWebhooks,
|
||||||
maxRetries: schedule.maxRetries,
|
|
||||||
retryDelay: schedule.retryDelay,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -256,8 +253,8 @@ export function ScheduleDetailsPage(props: Props) {
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the snapshot and all its data
|
This action cannot be undone. This will permanently delete the snapshot and all its data from the
|
||||||
from the repository.
|
repository.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -92,13 +92,7 @@ const defaultValuesForType = (repoBase: string) => ({
|
||||||
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
azure: { backend: "azure" as const, compressionMode: "auto" as const },
|
||||||
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
|
||||||
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
rest: { backend: "rest" as const, compressionMode: "auto" as const },
|
||||||
sftp: {
|
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22, skipHostKeyCheck: false },
|
||||||
backend: "sftp" as const,
|
|
||||||
compressionMode: "auto" as const,
|
|
||||||
port: 22,
|
|
||||||
skipHostKeyCheck: false,
|
|
||||||
allowLegacySshRsa: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CreateRepositoryForm = ({
|
export const CreateRepositoryForm = ({
|
||||||
|
|
@ -257,9 +251,7 @@ export const CreateRepositoryForm = ({
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<FormLabel>Import existing repository</FormLabel>
|
<FormLabel>Import existing repository</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
|
||||||
Check this if the repository already exists at the specified location
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -289,8 +281,8 @@ export const CreateRepositoryForm = ({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your
|
Choose whether to use Zerobyte's recovery key (which you downloaded when creating your account) or enter
|
||||||
account) or enter a custom password for the existing repository.
|
a custom password for the existing repository.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ export const AdvancedForm = ({ form }: Props) => {
|
||||||
const cacert = useWatch({ control: form.control, name: "cacert" });
|
const cacert = useWatch({ control: form.control, name: "cacert" });
|
||||||
const uploadLimitEnabled = useWatch({ control: form.control, name: "uploadLimit.enabled" });
|
const uploadLimitEnabled = useWatch({ control: form.control, name: "uploadLimit.enabled" });
|
||||||
const downloadLimitEnabled = useWatch({ control: form.control, name: "downloadLimit.enabled" });
|
const downloadLimitEnabled = useWatch({ control: form.control, name: "downloadLimit.enabled" });
|
||||||
const backend = useWatch({ control: form.control, name: "backend" });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible>
|
<Collapsible>
|
||||||
|
|
@ -45,9 +44,7 @@ export const AdvancedForm = ({ form }: Props) => {
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<FormLabel>Enable upload speed limit</FormLabel>
|
<FormLabel>Enable upload speed limit</FormLabel>
|
||||||
<FormDescription className="text-xs">
|
<FormDescription className="text-xs">Limit upload speed to the repository</FormDescription>
|
||||||
Limit upload speed to the repository
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -69,9 +66,7 @@ export const AdvancedForm = ({ form }: Props) => {
|
||||||
placeholder="10"
|
placeholder="10"
|
||||||
className="pr-12"
|
className="pr-12"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) =>
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
|
||||||
field.onChange(parseFloat(e.target.value) || 1)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
<div className="h-4 w-px bg-border" />
|
<div className="h-4 w-px bg-border" />
|
||||||
|
|
@ -125,9 +120,7 @@ export const AdvancedForm = ({ form }: Props) => {
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<FormLabel>Enable download speed limit</FormLabel>
|
<FormLabel>Enable download speed limit</FormLabel>
|
||||||
<FormDescription className="text-xs">
|
<FormDescription className="text-xs">Limit download speed from the repository</FormDescription>
|
||||||
Limit download speed from the repository
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -151,9 +144,7 @@ export const AdvancedForm = ({ form }: Props) => {
|
||||||
disabled={!downloadLimitEnabled}
|
disabled={!downloadLimitEnabled}
|
||||||
className="pr-12"
|
className="pr-12"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) =>
|
onChange={(e) => field.onChange(parseFloat(e.target.value) || 1)}
|
||||||
field.onChange(parseFloat(e.target.value) || 1)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
<div className="h-4 w-px bg-border" />
|
<div className="h-4 w-px bg-border" />
|
||||||
|
|
@ -217,8 +208,8 @@ export const AdvancedForm = ({ form }: Props) => {
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className={cn({ hidden: !cacert })}>
|
<TooltipContent className={cn({ hidden: !cacert })}>
|
||||||
<p className="max-w-xs">
|
<p className="max-w-xs">
|
||||||
This option is disabled because a CA certificate is provided. Remove the CA
|
This option is disabled because a CA certificate is provided. Remove the CA certificate to skip
|
||||||
certificate to skip TLS validation instead.
|
TLS validation instead.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
@ -226,8 +217,8 @@ export const AdvancedForm = ({ form }: Props) => {
|
||||||
<div className="space-y-1 leading-none">
|
<div className="space-y-1 leading-none">
|
||||||
<FormLabel>Skip TLS certificate verification</FormLabel>
|
<FormLabel>Skip TLS certificate verification</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Disable TLS certificate verification for HTTPS connections with self-signed
|
Disable TLS certificate verification for HTTPS connections with self-signed certificates. This is
|
||||||
certificates. This is insecure and should only be used for testing.
|
insecure and should only be used for testing.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -244,9 +235,7 @@ export const AdvancedForm = ({ form }: Props) => {
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={
|
placeholder={"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"}
|
||||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
|
||||||
}
|
|
||||||
rows={6}
|
rows={6}
|
||||||
disabled={insecureTls}
|
disabled={insecureTls}
|
||||||
{...field}
|
{...field}
|
||||||
|
|
@ -255,8 +244,8 @@ export const AdvancedForm = ({ form }: Props) => {
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className={cn({ hidden: !insecureTls })}>
|
<TooltipContent className={cn({ hidden: !insecureTls })}>
|
||||||
<p className="max-w-xs">
|
<p className="max-w-xs">
|
||||||
CA certificate is disabled because TLS validation is being skipped. Uncheck
|
CA certificate is disabled because TLS validation is being skipped. Uncheck "Skip TLS Certificate
|
||||||
"Skip TLS Certificate Verification" to provide a custom CA certificate.
|
Verification" to provide a custom CA certificate.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
@ -277,26 +266,6 @@ export const AdvancedForm = ({ form }: Props) => {
|
||||||
</FormItem>
|
</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>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,322 +0,0 @@
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,11 @@
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import {
|
import { Download, Fingerprint, KeyRound, User, X, Settings as SettingsIcon, Building2 } from "lucide-react";
|
||||||
AlertTriangle,
|
|
||||||
Download,
|
|
||||||
Fingerprint,
|
|
||||||
KeyRound,
|
|
||||||
User,
|
|
||||||
X,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
Building2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
|
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -39,18 +29,12 @@ import {
|
||||||
useTimeFormat,
|
useTimeFormat,
|
||||||
} from "~/client/lib/datetime";
|
} from "~/client/lib/datetime";
|
||||||
import { logger } from "~/client/lib/logger";
|
import { logger } from "~/client/lib/logger";
|
||||||
import { parseError } from "~/client/lib/errors";
|
|
||||||
import { type AppContext } from "~/context";
|
import { type AppContext } from "~/context";
|
||||||
import { TwoFactorSection } from "../components/two-factor-section";
|
import { TwoFactorSection } from "../components/two-factor-section";
|
||||||
import { PasskeysSection } from "../components/passkeys-section";
|
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
|
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
|
||||||
import { OrgMembersSection } from "../components/org-members-section";
|
import { OrgMembersSection } from "../components/org-members-section";
|
||||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||||
import { cn } from "~/client/lib/utils";
|
|
||||||
|
|
||||||
const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
|
|
||||||
"Downloading the recovery key requires a local credential password. Ask an operator to run `docker exec -it zerobyte bun run cli reset-password` for your user, then sign in with that password and try again.";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appContext: AppContext;
|
appContext: AppContext;
|
||||||
|
|
@ -65,7 +49,6 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
|
||||||
const [downloadPassword, setDownloadPassword] = useState("");
|
const [downloadPassword, setDownloadPassword] = useState("");
|
||||||
const [downloadBlockedMessage, setDownloadBlockedMessage] = useState<string | null>(null);
|
|
||||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||||
const { dateFormat, timeFormat } = useRootLoaderData();
|
const { dateFormat, timeFormat } = useRootLoaderData();
|
||||||
|
|
||||||
|
|
@ -76,7 +59,6 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
const { activeMember, activeOrganization } = useOrganizationContext();
|
const { activeMember, activeOrganization } = useOrganizationContext();
|
||||||
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
|
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
|
||||||
const { formatDateTime } = useTimeFormat();
|
const { formatDateTime } = useTimeFormat();
|
||||||
const hasCredentialPassword = appContext.user?.hasCredentialPassword !== false;
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
|
|
@ -103,18 +85,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.success("Restic password file downloaded successfully");
|
toast.success("Restic password file downloaded successfully");
|
||||||
setDownloadDialogOpen(false);
|
setDownloadDialogOpen(false);
|
||||||
setDownloadPassword("");
|
setDownloadPassword("");
|
||||||
setDownloadBlockedMessage(null);
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = parseError(error)?.message;
|
|
||||||
setDownloadBlockedMessage(message?.includes("credential password") ? message : null);
|
|
||||||
toast.error("Failed to download Restic password", {
|
toast.error("Failed to download Restic password", {
|
||||||
description: message,
|
description: error.message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -166,7 +145,6 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloadBlockedMessage(null);
|
|
||||||
downloadResticPassword.mutate({
|
downloadResticPassword.mutate({
|
||||||
body: {
|
body: {
|
||||||
password: downloadPassword,
|
password: downloadPassword,
|
||||||
|
|
@ -235,22 +213,11 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<CardContent className="p-6 space-y-4">
|
<CardContent className="p-6 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Username</Label>
|
<Label htmlFor="username">Username</Label>
|
||||||
<Input
|
<Input id="username" value={appContext.user?.username} disabled className="max-w-md" />
|
||||||
id="username"
|
|
||||||
value={appContext.user?.username}
|
|
||||||
disabled
|
|
||||||
className="max-w-md"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input id="email" type="email" value={appContext.user?.email} disabled className="max-w-md" />
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={appContext.user?.email}
|
|
||||||
disabled
|
|
||||||
className="max-w-md"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
|
|
@ -270,9 +237,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<Label htmlFor="date-format">Date format</Label>
|
<Label htmlFor="date-format">Date format</Label>
|
||||||
<Select
|
<Select
|
||||||
value={dateFormat}
|
value={dateFormat}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => void handleDateFormatChange(value as DateFormatPreference)}
|
||||||
void handleDateFormatChange(value as DateFormatPreference)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger id="date-format">
|
<SelectTrigger id="date-format">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -290,9 +255,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<Label htmlFor="time-format">Time format</Label>
|
<Label htmlFor="time-format">Time format</Label>
|
||||||
<Select
|
<Select
|
||||||
value={timeFormat}
|
value={timeFormat}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => void handleTimeFormatChange(value as TimeFormatPreference)}
|
||||||
void handleTimeFormatChange(value as TimeFormatPreference)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger id="time-format">
|
<SelectTrigger id="time-format">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -307,26 +270,18 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Preview: {formatDateTime(new Date())}</p>
|
||||||
Preview: {formatDateTime(new Date())}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<div
|
<div className="border-t border-border/50 bg-card-header p-6">
|
||||||
className={cn("border-t border-border/50 bg-card-header p-6", {
|
|
||||||
hidden: !hasCredentialPassword,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<KeyRound className="size-5" />
|
<KeyRound className="size-5" />
|
||||||
Change Password
|
Change Password
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5">
|
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
|
||||||
Update your password to keep your account secure
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<CardContent className={cn("p-6", { hidden: !hasCredentialPassword })}>
|
<CardContent className="p-6">
|
||||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="current-password">Current Password</Label>
|
<Label htmlFor="current-password">Current Password</Label>
|
||||||
|
|
@ -350,9 +305,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p>
|
||||||
Must be at least 8 characters long
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||||
|
|
@ -378,15 +331,13 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<Download className="size-5" />
|
<Download className="size-5" />
|
||||||
Backup Recovery Key
|
Backup Recovery Key
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5">
|
<CardDescription className="mt-1.5">Download your recovery key for Restic backups</CardDescription>
|
||||||
Download your recovery key for Restic backups
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6 space-y-4">
|
<CardContent className="p-6 space-y-4">
|
||||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||||
This file contains the encryption password used by Restic to secure your backups.
|
This file contains the encryption password used by Restic to secure your backups. Store it in a safe
|
||||||
Store it in a safe place (like a password manager or encrypted storage). If you lose
|
place (like a password manager or encrypted storage). If you lose access to this server, you'll need
|
||||||
access to this server, you'll need this file to recover your backup data.
|
this file to recover your backup data.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
|
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
|
||||||
|
|
@ -401,26 +352,11 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Download Recovery Key</DialogTitle>
|
<DialogTitle>Download Recovery Key</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{!hasCredentialPassword
|
For security reasons, please enter your account password to download the recovery key file.
|
||||||
? "A local credential password is required before this recovery key can be downloaded."
|
|
||||||
: "For security reasons, please enter your account password to download the recovery key file."}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<Alert
|
<div className="space-y-2">
|
||||||
variant="warning"
|
|
||||||
className={cn({
|
|
||||||
hidden: hasCredentialPassword && !downloadBlockedMessage,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="size-5" />
|
|
||||||
<AlertTitle>Local password required</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{downloadBlockedMessage ??
|
|
||||||
RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<div className={cn("space-y-2", { hidden: !hasCredentialPassword })}>
|
|
||||||
<Label htmlFor="download-password">Your Password</Label>
|
<Label htmlFor="download-password">Your Password</Label>
|
||||||
<Input
|
<Input
|
||||||
id="download-password"
|
id="download-password"
|
||||||
|
|
@ -444,11 +380,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<X className="h-4 w-4 mr-2" />
|
<X className="h-4 w-4 mr-2" />
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" loading={downloadResticPassword.isPending}>
|
||||||
type="submit"
|
|
||||||
loading={downloadResticPassword.isPending}
|
|
||||||
className={cn({ hidden: !hasCredentialPassword })}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -459,8 +391,6 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
|
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
|
||||||
|
|
||||||
<PasskeysSection />
|
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -472,9 +402,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<Fingerprint className="size-5" />
|
<Fingerprint className="size-5" />
|
||||||
Organization Details
|
Organization Details
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5">
|
<CardDescription className="mt-1.5">Reference details for the active organization</CardDescription>
|
||||||
Reference details for the active organization
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6 space-y-2">
|
<CardContent className="p-6 space-y-2">
|
||||||
<Label htmlFor="organization-id">Organization ID</Label>
|
<Label htmlFor="organization-id">Organization ID</Label>
|
||||||
|
|
@ -493,9 +421,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<Building2 className="size-5" />
|
<Building2 className="size-5" />
|
||||||
Members
|
Members
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5">
|
<CardDescription className="mt-1.5">Manage organization members and roles</CardDescription>
|
||||||
Manage organization members and roles
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<OrgMembersSection initialMembers={initialMembers} />
|
<OrgMembersSection initialMembers={initialMembers} />
|
||||||
|
|
@ -508,15 +434,10 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
|
||||||
<SettingsIcon className="size-5" />
|
<SettingsIcon className="size-5" />
|
||||||
Single Sign-On
|
Single Sign-On
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1.5">
|
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
|
||||||
Configure OIDC provider settings
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<SsoSettingsSection
|
<SsoSettingsSection initialSettings={initialSsoSettings} initialOrigin={initialOrigin} />
|
||||||
initialSettings={initialSsoSettings}
|
|
||||||
initialOrigin={initialOrigin}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
58
app/client/modules/sso/components/sso-login-section.tsx
Normal file
58
app/client/modules/sso/components/sso-login-section.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
smbConfigSchema,
|
smbConfigSchema,
|
||||||
volumeConfigSchema,
|
volumeConfigSchema,
|
||||||
webdavConfigSchema,
|
webdavConfigSchema,
|
||||||
} from "@zerobyte/contracts/volumes";
|
} from "~/schemas/volumes";
|
||||||
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
|
|
@ -66,10 +66,10 @@ type Props = {
|
||||||
const defaultValuesForType = {
|
const defaultValuesForType = {
|
||||||
directory: { backend: "directory" as const, path: "/" },
|
directory: { backend: "directory" as const, path: "/" },
|
||||||
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
||||||
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const, mapToContainerUidGid: false },
|
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
||||||
webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" },
|
webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" },
|
||||||
rclone: { backend: "rclone" as const, path: "/" },
|
rclone: { backend: "rclone" as const, path: "/" },
|
||||||
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false, allowLegacySshRsa: false },
|
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
||||||
|
|
@ -230,10 +230,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
<SelectItem
|
<SelectItem disabled={!capabilities.rclone || !capabilities.sysAdmin} value="rclone">
|
||||||
disabled={!capabilities.rclone || !capabilities.sysAdmin}
|
|
||||||
value="rclone"
|
|
||||||
>
|
|
||||||
rclone
|
rclone
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -241,11 +238,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||||
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
|
<TooltipContent className={cn({ hidden: capabilities.sysAdmin })}>
|
||||||
<p>Remote mounts require SYS_ADMIN capability</p>
|
<p>Remote mounts require SYS_ADMIN capability</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
<TooltipContent
|
<TooltipContent className={cn({ hidden: !capabilities.sysAdmin || capabilities.rclone })}>
|
||||||
className={cn({
|
|
||||||
hidden: !capabilities.sysAdmin || capabilities.rclone,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<p>Setup rclone to use this backend</p>
|
<p>Setup rclone to use this backend</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,6 @@ export const NFSForm = ({ form }: Props) => {
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label="Mount volume as read-only"
|
|
||||||
checked={field.value ?? false}
|
checked={field.value ?? false}
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300"
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,6 @@ export const RcloneForm = ({ form }: Props) => {
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label="Mount volume as read-only"
|
|
||||||
checked={field.value ?? false}
|
checked={field.value ?? false}
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { Input } from "../../../../components/ui/input";
|
||||||
import { SecretInput } from "../../../../components/ui/secret-input";
|
import { SecretInput } from "../../../../components/ui/secret-input";
|
||||||
import { Textarea } from "../../../../components/ui/textarea";
|
import { Textarea } from "../../../../components/ui/textarea";
|
||||||
import { Switch } from "../../../../components/ui/switch";
|
import { Switch } from "../../../../components/ui/switch";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
form: UseFormReturn<FormValues>;
|
form: UseFormReturn<FormValues>;
|
||||||
|
|
@ -79,9 +78,7 @@ export const SFTPForm = ({ form }: Props) => {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
<SecretInput placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>Password for SFTP authentication (optional if using private key).</FormDescription>
|
||||||
Password for SFTP authentication (optional if using private key).
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -101,9 +98,7 @@ export const SFTPForm = ({ form }: Props) => {
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>SSH private key for authentication (optional if using password).</FormDescription>
|
||||||
SSH private key for authentication (optional if using password).
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -163,29 +158,6 @@ 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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,6 @@ export const SMBForm = ({ form }: Props) => {
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label="Connect as guest"
|
|
||||||
checked={field.value ?? false}
|
checked={field.value ?? false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(e.target.checked);
|
field.onChange(e.target.checked);
|
||||||
|
|
@ -169,33 +168,6 @@ export const SMBForm = ({ form }: Props) => {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="mapToContainerUidGid"
|
|
||||||
defaultValue={false}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Ownership Mapping</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">Map all files to container user/group</span>
|
|
||||||
</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.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="readOnly"
|
name="readOnly"
|
||||||
|
|
@ -207,7 +179,6 @@ export const SMBForm = ({ form }: Props) => {
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label="Mount volume as read-only"
|
|
||||||
checked={field.value ?? false}
|
checked={field.value ?? false}
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300"
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,6 @@ export const WebDAVForm = ({ form }: Props) => {
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label="Enable HTTPS for secure connections"
|
|
||||||
checked={field.value ?? false}
|
checked={field.value ?? false}
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300"
|
||||||
|
|
@ -129,7 +128,6 @@ export const WebDAVForm = ({ form }: Props) => {
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label="Mount volume as read-only"
|
|
||||||
checked={field.value ?? false}
|
checked={field.value ?? false}
|
||||||
onChange={(e) => field.onChange(e.target.checked)}
|
onChange={(e) => field.onChange(e.target.checked)}
|
||||||
className="rounded border-gray-300"
|
className="rounded border-gray-300"
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,7 @@ function ConfigRow({ icon, label, value, mono }: ConfigRowProps) {
|
||||||
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
<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-muted-foreground shrink-0">{icon}</span>
|
||||||
<span className="text-sm text-muted-foreground w-40 shrink-0">{label}</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 })}>
|
<span className={cn("text-sm break-all", { "font-mono bg-muted/50 px-2 py-0.5 rounded": mono })}>{value}</span>
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -45,19 +43,12 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
|
||||||
|
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
case "directory":
|
case "directory":
|
||||||
return (
|
return <ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />;
|
||||||
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Directory Path" value={config.path} mono />
|
|
||||||
);
|
|
||||||
case "nfs":
|
case "nfs":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
|
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Server" value={config.server} mono />
|
||||||
<ConfigRow
|
<ConfigRow icon={<FolderOpen className="h-4 w-4" />} label="Export Path" value={config.exportPath} mono />
|
||||||
icon={<FolderOpen className="h-4 w-4" />}
|
|
||||||
label="Export Path"
|
|
||||||
value={config.exportPath}
|
|
||||||
mono
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "smb":
|
case "smb":
|
||||||
|
|
@ -93,8 +84,6 @@ function BackendConfigRows({ volume }: { volume: Volume }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function DonutChart({ statfs }: { statfs: StatFs }) {
|
function DonutChart({ statfs }: { statfs: StatFs }) {
|
||||||
const { used = 0, total = 0 } = statfs;
|
|
||||||
|
|
||||||
const chartData = useMemo(
|
const chartData = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ name: "Used", value: statfs.used, fill: "var(--strong-accent)" },
|
{ name: "Used", value: statfs.used, fill: "var(--strong-accent)" },
|
||||||
|
|
@ -104,8 +93,8 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const usagePercentage = useMemo(() => {
|
const usagePercentage = useMemo(() => {
|
||||||
return Math.round((used / total) * 100);
|
return Math.round((statfs.used / statfs.total) * 100);
|
||||||
}, [used, total]);
|
}, [statfs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={{}} className="mx-auto aspect-square max-h-[200px]">
|
<ChartContainer config={{}} className="mx-auto aspect-square max-h-[200px]">
|
||||||
|
|
@ -125,18 +114,10 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
|
||||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||||
return (
|
return (
|
||||||
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
||||||
<tspan
|
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-2xl font-bold">
|
||||||
x={viewBox.cx}
|
|
||||||
y={viewBox.cy}
|
|
||||||
className="fill-foreground text-2xl font-bold"
|
|
||||||
>
|
|
||||||
{usagePercentage}%
|
{usagePercentage}%
|
||||||
</tspan>
|
</tspan>
|
||||||
<tspan
|
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 20} className="fill-muted-foreground text-xs">
|
||||||
x={viewBox.cx}
|
|
||||||
y={(viewBox.cy || 0) + 20}
|
|
||||||
className="fill-muted-foreground text-xs"
|
|
||||||
>
|
|
||||||
Used
|
Used
|
||||||
</tspan>
|
</tspan>
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -151,9 +132,7 @@ function DonutChart({ statfs }: { statfs: StatFs }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
const { total = 0, used = 0, free = 0 } = statfs;
|
const hasStorage = statfs.total > 0;
|
||||||
|
|
||||||
const hasStorage = total > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="px-6 py-6 @container/inner">
|
<Card className="px-6 py-6 @container/inner">
|
||||||
|
|
@ -165,11 +144,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="space-y-0 divide-y divide-border/50">
|
<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="Name" value={volume.name} />
|
||||||
<ConfigRow
|
<ConfigRow icon={<HardDrive className="h-4 w-4" />} label="Backend" value={backendLabels[volume.type]} />
|
||||||
icon={<HardDrive className="h-4 w-4" />}
|
|
||||||
label="Backend"
|
|
||||||
value={backendLabels[volume.type]}
|
|
||||||
/>
|
|
||||||
<BackendConfigRows volume={volume} />
|
<BackendConfigRows volume={volume} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -187,21 +162,21 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">Total</span>
|
<span className="text-sm font-medium">Total</span>
|
||||||
</div>
|
</div>
|
||||||
<ByteSize bytes={total} className="font-mono text-sm" />
|
<ByteSize bytes={statfs.total} className="font-mono text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-3 w-3 rounded-full bg-strong-accent" />
|
<div className="h-3 w-3 rounded-full bg-strong-accent" />
|
||||||
<span className="text-sm font-medium">Used</span>
|
<span className="text-sm font-medium">Used</span>
|
||||||
</div>
|
</div>
|
||||||
<ByteSize bytes={used} className="font-mono text-sm" />
|
<ByteSize bytes={statfs.used} className="font-mono text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-3 w-3 rounded-full bg-primary" />
|
<div className="h-3 w-3 rounded-full bg-primary" />
|
||||||
<span className="text-sm font-medium">Free</span>
|
<span className="text-sm font-medium">Free</span>
|
||||||
</div>
|
</div>
|
||||||
<ByteSize bytes={free} className="font-mono text-sm" />
|
<ByteSize bytes={statfs.free} className="font-mono text-sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ type User = {
|
||||||
dateFormat: string;
|
dateFormat: string;
|
||||||
timeFormat: string;
|
timeFormat: string;
|
||||||
twoFactorEnabled?: boolean | null;
|
twoFactorEnabled?: boolean | null;
|
||||||
hasCredentialPassword?: boolean;
|
|
||||||
role?: string | null | undefined;
|
role?: string | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -15,5 +14,4 @@ export type AppContext = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
hasUsers: boolean;
|
hasUsers: boolean;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
hasSkippedRecoveryKeyDownload: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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
|
|
@ -1,28 +0,0 @@
|
||||||
CREATE TABLE `repository_lock_waiters` (
|
|
||||||
`id` text PRIMARY KEY,
|
|
||||||
`repository_id` text NOT NULL,
|
|
||||||
`type` text NOT NULL,
|
|
||||||
`operation` text NOT NULL,
|
|
||||||
`owner_id` text NOT NULL,
|
|
||||||
`requested_at` integer NOT NULL,
|
|
||||||
`expires_at` integer NOT NULL,
|
|
||||||
`heartbeat_at` integer NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `repository_locks` (
|
|
||||||
`id` text PRIMARY KEY,
|
|
||||||
`repository_id` text NOT NULL,
|
|
||||||
`type` text NOT NULL,
|
|
||||||
`operation` text NOT NULL,
|
|
||||||
`owner_id` text NOT NULL,
|
|
||||||
`acquired_at` integer NOT NULL,
|
|
||||||
`expires_at` integer NOT NULL,
|
|
||||||
`heartbeat_at` integer NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `repository_lock_waiters_repository_id_idx` ON `repository_lock_waiters` (`repository_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `repository_lock_waiters_expires_at_idx` ON `repository_lock_waiters` (`expires_at`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `repository_lock_waiters_owner_id_idx` ON `repository_lock_waiters` (`owner_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `repository_locks_repository_id_idx` ON `repository_locks` (`repository_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `repository_locks_expires_at_idx` ON `repository_locks` (`expires_at`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `repository_locks_owner_id_idx` ON `repository_locks` (`owner_id`);
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
||||||
ALTER TABLE `two_factor` ADD `verified` integer DEFAULT true NOT NULL;
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +0,0 @@
|
||||||
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
|
|
@ -1,2 +0,0 @@
|
||||||
export const RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME = "zerobyte_recovery_key_download_skipped";
|
|
||||||
export const RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_MAX_AGE = 60 * 60;
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
export const PASSKEY_LOGIN_FAILED_ERROR = "PASSKEY_LOGIN_FAILED";
|
|
||||||
|
|
||||||
export const LOGIN_ERROR_CODES = [
|
export const LOGIN_ERROR_CODES = [
|
||||||
"ACCOUNT_LINK_REQUIRED",
|
"ACCOUNT_LINK_REQUIRED",
|
||||||
"EMAIL_NOT_VERIFIED",
|
"EMAIL_NOT_VERIFIED",
|
||||||
"INVITE_REQUIRED",
|
"INVITE_REQUIRED",
|
||||||
"BANNED_USER",
|
"BANNED_USER",
|
||||||
"SSO_LOGIN_FAILED",
|
"SSO_LOGIN_FAILED",
|
||||||
PASSKEY_LOGIN_FAILED_ERROR,
|
|
||||||
"ERROR_INVALID_RP_ID",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number];
|
export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number];
|
||||||
|
|
@ -24,9 +20,5 @@ export function getLoginErrorDescription(errorCode: LoginErrorCode): string {
|
||||||
return "You have been banned from this application. Please contact support if you believe this is an error.";
|
return "You have been banned from this application. Please contact support if you believe this is an error.";
|
||||||
case "SSO_LOGIN_FAILED":
|
case "SSO_LOGIN_FAILED":
|
||||||
return "SSO authentication failed. Please try again.";
|
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";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { createMiddleware } from "@tanstack/react-start";
|
import { createMiddleware } from "@tanstack/react-start";
|
||||||
import { redirect } from "@tanstack/react-router";
|
import { redirect } from "@tanstack/react-router";
|
||||||
import { auth } from "~/server/lib/auth";
|
import { auth } from "~/server/lib/auth";
|
||||||
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||||
import { authService } from "~/server/modules/auth/auth.service";
|
import { authService } from "~/server/modules/auth/auth.service";
|
||||||
import { isAuthRoute } from "~/lib/auth-routes";
|
import { isAuthRoute } from "~/lib/auth-routes";
|
||||||
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
|
|
||||||
|
|
||||||
export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
|
export const authMiddleware = createMiddleware().server(async ({ next, request }) => {
|
||||||
const headers = getRequestHeaders();
|
const headers = getRequestHeaders();
|
||||||
|
|
@ -21,13 +20,7 @@ export const authMiddleware = createMiddleware().server(async ({ next, request }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
const hasSkippedRecoveryKeyDownload = getCookie(RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME) === session.user.id;
|
if (!session.user.hasDownloadedResticPassword && pathname !== "/download-recovery-key") {
|
||||||
|
|
||||||
if (
|
|
||||||
!session.user.hasDownloadedResticPassword &&
|
|
||||||
!hasSkippedRecoveryKeyDownload &&
|
|
||||||
pathname !== "/download-recovery-key"
|
|
||||||
) {
|
|
||||||
throw redirect({ to: "/download-recovery-key" });
|
throw redirect({ to: "/download-recovery-key" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,9 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
|
||||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
|
||||||
import { DownloadRecoveryKeyPage } from "~/client/modules/auth/routes/download-recovery-key";
|
import { DownloadRecoveryKeyPage } from "~/client/modules/auth/routes/download-recovery-key";
|
||||||
import { auth } from "~/server/lib/auth";
|
|
||||||
import { userHasCredentialPassword } from "~/server/modules/auth/helpers";
|
|
||||||
|
|
||||||
const getRecoveryKeyUserState = createServerFn({ method: "GET" }).handler(async () => {
|
|
||||||
const headers = getRequestHeaders();
|
|
||||||
const session = await auth.api.getSession({ headers });
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasCredentialPassword: session?.user ? await userHasCredentialPassword(session.user.id) : false,
|
|
||||||
userId: session?.user.id ?? null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/(auth)/download-recovery-key")({
|
export const Route = createFileRoute("/(auth)/download-recovery-key")({
|
||||||
component: RouteComponent,
|
component: DownloadRecoveryKeyPage,
|
||||||
errorComponent: () => <div>Failed to load recovery key</div>,
|
errorComponent: () => <div>Failed to load recovery key</div>,
|
||||||
loader: async () => getRecoveryKeyUserState(),
|
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "Zerobyte - Download Recovery Key" },
|
{ title: "Zerobyte - Download Recovery Key" },
|
||||||
|
|
@ -29,9 +14,3 @@ export const Route = createFileRoute("/(auth)/download-recovery-key")({
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
const { hasCredentialPassword, userId } = Route.useLoaderData();
|
|
||||||
|
|
||||||
return <DownloadRecoveryKeyPage hasCredentialPassword={hasCredentialPassword} userId={userId} />;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
import { getCookie, getRequestHeaders } from "@tanstack/react-start/server";
|
||||||
import { Layout } from "~/client/components/layout";
|
import { Layout } from "~/client/components/layout";
|
||||||
|
|
@ -7,27 +7,17 @@ import { authMiddleware } from "~/middleware/auth";
|
||||||
import { auth } from "~/server/lib/auth";
|
import { auth } from "~/server/lib/auth";
|
||||||
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
||||||
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
||||||
import { userHasCredentialPassword } from "~/server/modules/auth/helpers";
|
|
||||||
import { authService } from "~/server/modules/auth/auth.service";
|
import { authService } from "~/server/modules/auth/auth.service";
|
||||||
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
|
|
||||||
|
|
||||||
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
|
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
const headers = getRequestHeaders();
|
const headers = getRequestHeaders();
|
||||||
const session = await auth.api.getSession({ headers });
|
const session = await auth.api.getSession({ headers });
|
||||||
const hasUsers = await authService.hasUsers();
|
const hasUsers = await authService.hasUsers();
|
||||||
const hasCredentialPassword = session?.user ? await userHasCredentialPassword(session.user.id) : false;
|
|
||||||
|
|
||||||
const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME);
|
const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME);
|
||||||
const sidebarOpen = !sidebarCookie ? true : sidebarCookie === "true";
|
const sidebarOpen = !sidebarCookie ? true : sidebarCookie === "true";
|
||||||
const hasSkippedRecoveryKeyDownload =
|
|
||||||
!!session?.user && getCookie(RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME) === session.user.id;
|
|
||||||
|
|
||||||
return {
|
return { user: session?.user ?? null, hasUsers, sidebarOpen };
|
||||||
user: session?.user ? { ...session.user, hasCredentialPassword } : null,
|
|
||||||
hasUsers,
|
|
||||||
sidebarOpen,
|
|
||||||
hasSkippedRecoveryKeyDownload,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)")({
|
export const Route = createFileRoute("/(dashboard)")({
|
||||||
|
|
@ -49,14 +39,6 @@ export const Route = createFileRoute("/(dashboard)")({
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (
|
|
||||||
authContext.user &&
|
|
||||||
!authContext.user.hasDownloadedResticPassword &&
|
|
||||||
!authContext.hasSkippedRecoveryKeyDownload
|
|
||||||
) {
|
|
||||||
throw redirect({ to: "/download-recovery-key" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return authContext;
|
return authContext;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { z } from "zod";
|
||||||
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "@zerobyte/core/restic";
|
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "@zerobyte/core/restic";
|
||||||
|
|
||||||
const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
|
const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
|
||||||
const restoreEventStatusSchema = z.enum(["success", "error", "cancelled"]);
|
const restoreEventStatusSchema = z.enum(["success", "error"]);
|
||||||
|
|
||||||
const backupEventBaseSchema = z.object({
|
const backupEventBaseSchema = z.object({
|
||||||
scheduleId: z.string(),
|
scheduleId: z.string(),
|
||||||
|
|
@ -15,7 +15,6 @@ const organizationScopedSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoreEventBaseSchema = z.object({
|
const restoreEventBaseSchema = z.object({
|
||||||
restoreId: z.string(),
|
|
||||||
repositoryId: z.string(),
|
repositoryId: z.string(),
|
||||||
snapshotId: z.string(),
|
snapshotId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
@ -52,8 +51,6 @@ const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgress
|
||||||
const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
|
const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
|
||||||
status: restoreEventStatusSchema,
|
status: restoreEventStatusSchema,
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
filesRestored: z.number().optional(),
|
|
||||||
filesSkipped: z.number().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const serverBackupStartedEventSchema = organizationScopedSchema.extend(backupStartedEventSchema.shape);
|
const serverBackupStartedEventSchema = organizationScopedSchema.extend(backupStartedEventSchema.shape);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ export const smbConfigSchema = z.object({
|
||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
guest: z.boolean().optional(),
|
guest: z.boolean().optional(),
|
||||||
mapToContainerUidGid: z.boolean().default(false),
|
|
||||||
vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"),
|
vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"),
|
||||||
domain: z.string().optional(),
|
domain: z.string().optional(),
|
||||||
port: z
|
port: z
|
||||||
|
|
@ -85,10 +84,9 @@ export const sftpConfigSchema = z.object({
|
||||||
readOnly: z.boolean().optional(),
|
readOnly: z.boolean().optional(),
|
||||||
skipHostKeyCheck: z.boolean().default(false),
|
skipHostKeyCheck: z.boolean().default(false),
|
||||||
knownHosts: z.string().optional(),
|
knownHosts: z.string().optional(),
|
||||||
allowLegacySshRsa: z.boolean().default(false),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const volumeConfigSchema = z.discriminatedUnion("backend", [
|
const volumeConfigSchemaBase = z.discriminatedUnion("backend", [
|
||||||
nfsConfigSchema,
|
nfsConfigSchema,
|
||||||
smbConfigSchema,
|
smbConfigSchema,
|
||||||
webdavConfigSchema,
|
webdavConfigSchema,
|
||||||
|
|
@ -97,6 +95,8 @@ export const volumeConfigSchema = z.discriminatedUnion("backend", [
|
||||||
sftpConfigSchema,
|
sftpConfigSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const volumeConfigSchema = volumeConfigSchemaBase;
|
||||||
|
|
||||||
export type BackendConfig = z.infer<typeof volumeConfigSchema>;
|
export type BackendConfig = z.infer<typeof volumeConfigSchema>;
|
||||||
|
|
||||||
export const BACKEND_STATUS = {
|
export const BACKEND_STATUS = {
|
||||||
|
|
@ -106,78 +106,3 @@ export const BACKEND_STATUS = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type BackendStatus = keyof typeof BACKEND_STATUS;
|
export type BackendStatus = keyof typeof BACKEND_STATUS;
|
||||||
|
|
||||||
export const backendStatusSchema = z.enum(BACKEND_STATUS);
|
|
||||||
|
|
||||||
export const volumeSchema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
shortId: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
path: z.string().nullable().optional(),
|
|
||||||
config: volumeConfigSchema,
|
|
||||||
createdAt: z.number(),
|
|
||||||
updatedAt: z.number(),
|
|
||||||
lastHealthCheck: z.number(),
|
|
||||||
type: z.enum(BACKEND_TYPES),
|
|
||||||
status: backendStatusSchema,
|
|
||||||
lastError: z.string().nullable(),
|
|
||||||
provisioningId: z.string().nullable().optional(),
|
|
||||||
autoRemount: z.boolean(),
|
|
||||||
agentId: z.string(),
|
|
||||||
organizationId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Volume = z.infer<typeof volumeSchema>;
|
|
||||||
|
|
||||||
export const publicVolumeSchema = volumeSchema.omit({
|
|
||||||
agentId: true,
|
|
||||||
organizationId: true,
|
|
||||||
path: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type PublicVolume = z.infer<typeof publicVolumeSchema>;
|
|
||||||
|
|
||||||
export const volumeOperationResultSchema = z.object({
|
|
||||||
status: backendStatusSchema,
|
|
||||||
error: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type VolumeOperationResult = z.infer<typeof volumeOperationResultSchema>;
|
|
||||||
|
|
||||||
export const statfsSchema = z.object({
|
|
||||||
total: z.number().optional(),
|
|
||||||
used: z.number().optional(),
|
|
||||||
free: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fileEntrySchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
path: z.string(),
|
|
||||||
type: z.enum(["directory", "file"]),
|
|
||||||
size: z.number().optional(),
|
|
||||||
modifiedAt: z.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const directoryEntrySchema = fileEntrySchema.extend({
|
|
||||||
type: z.literal("directory"),
|
|
||||||
size: z.undefined().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const listVolumeFilesResponseSchema = z.object({
|
|
||||||
files: z.array(fileEntrySchema),
|
|
||||||
path: z.string(),
|
|
||||||
offset: z.number(),
|
|
||||||
limit: z.number(),
|
|
||||||
total: z.number(),
|
|
||||||
hasMore: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const testVolumeConnectionResponseSchema = z.object({
|
|
||||||
success: z.boolean(),
|
|
||||||
message: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const browseFilesystemResponseSchema = z.object({
|
|
||||||
directories: z.array(directoryEntrySchema),
|
|
||||||
path: z.string(),
|
|
||||||
});
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Scalar } from "@scalar/hono-api-reference";
|
import { Scalar } from "@scalar/hono-api-reference";
|
||||||
import type { Context, Next } from "hono";
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { bodyLimit } from "hono/body-limit";
|
import { bodyLimit } from "hono/body-limit";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
|
import { logger as honoLogger } from "hono/logger";
|
||||||
import { secureHeaders } from "hono/secure-headers";
|
import { secureHeaders } from "hono/secure-headers";
|
||||||
import { rateLimiter } from "hono-rate-limiter";
|
import { rateLimiter } from "hono-rate-limiter";
|
||||||
import { openAPIRouteHandler } from "hono-openapi";
|
import { openAPIRouteHandler } from "hono-openapi";
|
||||||
|
|
@ -21,20 +21,6 @@ import { config } from "./core/config";
|
||||||
import { auth } from "~/server/lib/auth";
|
import { auth } from "~/server/lib/auth";
|
||||||
import { db } from "./db/db";
|
import { db } from "./db/db";
|
||||||
|
|
||||||
const requestLogger = async (c: Context, next: Next) => {
|
|
||||||
const method = c.req.method;
|
|
||||||
const path = c.req.path;
|
|
||||||
const start = performance.now();
|
|
||||||
|
|
||||||
logger.debug(`<-- ${method} ${path}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await next();
|
|
||||||
} finally {
|
|
||||||
logger.debug(`--> ${method} ${path} ${c.res.status} ${Math.round(performance.now() - start)}ms`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generalDescriptor = (app: Hono) =>
|
const generalDescriptor = (app: Hono) =>
|
||||||
openAPIRouteHandler(app, {
|
openAPIRouteHandler(app, {
|
||||||
documentation: {
|
documentation: {
|
||||||
|
|
@ -63,7 +49,7 @@ export const createApp = () => {
|
||||||
|
|
||||||
if (config.environment === "production") {
|
if (config.environment === "production") {
|
||||||
app.use(secureHeaders());
|
app.use(secureHeaders());
|
||||||
app.use(requestLogger);
|
app.use(honoLogger());
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,6 @@
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { db } from "~/server/db/db";
|
|
||||||
import { repositoryLocksTable, repositoryLockWaitersTable } from "~/server/db/schema";
|
|
||||||
import { repoMutex } from "../repository-mutex";
|
import { repoMutex } from "../repository-mutex";
|
||||||
|
|
||||||
const acquireWithin = <T>(promise: Promise<T>, ms = 500) =>
|
|
||||||
Promise.race([
|
|
||||||
promise,
|
|
||||||
new Promise<never>((_, reject) => {
|
|
||||||
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
describe("RepositoryMutex", () => {
|
describe("RepositoryMutex", () => {
|
||||||
test("should prioritize waiting exclusive locks over new shared locks", async () => {
|
test("should prioritize waiting exclusive locks over new shared locks", async () => {
|
||||||
const repoId = "test-repo";
|
const repoId = "test-repo";
|
||||||
|
|
@ -134,37 +123,6 @@ describe("RepositoryMutex", () => {
|
||||||
expect(repoMutex.isLocked(repoId)).toBe(false);
|
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 () => {
|
test("should allow concurrent shared locks", async () => {
|
||||||
const repoId = "concurrent-shared";
|
const repoId = "concurrent-shared";
|
||||||
const release1 = await repoMutex.acquireShared(repoId, "op1");
|
const release1 = await repoMutex.acquireShared(repoId, "op1");
|
||||||
|
|
@ -369,43 +327,6 @@ describe("RepositoryMutex", () => {
|
||||||
expect(repoMutex.isLocked(repoB)).toBe(false);
|
expect(repoMutex.isLocked(repoB)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not leave a promoted shared lock behind if that waiter aborts before observing acquisition", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const repoId = "shared-abort-after-promotion";
|
|
||||||
const releaseExclusive = await repoMutex.acquireExclusive(repoId, "holder");
|
|
||||||
const firstSharedPromise = repoMutex.acquireShared(repoId, "shared-1");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const secondSharedPromise = repoMutex.acquireShared(repoId, "shared-2", controller.signal);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(140);
|
|
||||||
releaseExclusive();
|
|
||||||
|
|
||||||
// The first shared waiter wakes first and promotes both shared waiters.
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
|
||||||
const releaseShared1 = await firstSharedPromise;
|
|
||||||
|
|
||||||
controller.abort(new Error("abort after promotion"));
|
|
||||||
await expect(secondSharedPromise).rejects.toThrow("abort after promotion");
|
|
||||||
|
|
||||||
releaseShared1();
|
|
||||||
|
|
||||||
const remainingLocks = await db.query.repositoryLocksTable.findMany({
|
|
||||||
where: { repositoryId: { eq: repoId } },
|
|
||||||
orderBy: { operation: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(remainingLocks).toEqual([]);
|
|
||||||
} finally {
|
|
||||||
await db.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.repositoryId, repoId));
|
|
||||||
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should safely handle multiple calls to the release function", async () => {
|
test("should safely handle multiple calls to the release function", async () => {
|
||||||
const repoId = "idempotent-release";
|
const repoId = "idempotent-release";
|
||||||
|
|
||||||
|
|
@ -455,100 +376,4 @@ describe("RepositoryMutex", () => {
|
||||||
releaseExclusive();
|
releaseExclusive();
|
||||||
expect(repoMutex.isLocked(repoId)).toBe(false);
|
expect(repoMutex.isLocked(repoId)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should ignore and clean expired active lock rows during acquisition", async () => {
|
|
||||||
const repoId = "expired-active-lock";
|
|
||||||
const expiredLockId = "expired-active-lock-row";
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
await db.insert(repositoryLocksTable).values({
|
|
||||||
id: expiredLockId,
|
|
||||||
repositoryId: repoId,
|
|
||||||
type: "exclusive",
|
|
||||||
operation: "stale-check",
|
|
||||||
ownerId: "stale-owner",
|
|
||||||
acquiredAt: now - 10_000,
|
|
||||||
expiresAt: now - 1,
|
|
||||||
heartbeatAt: now - 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const releaseShared = await acquireWithin(repoMutex.acquireShared(repoId, "backup"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expiredLock = await db.query.repositoryLocksTable.findFirst({
|
|
||||||
where: { id: { eq: expiredLockId } },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(expiredLock).toBeUndefined();
|
|
||||||
expect(repoMutex.isLocked(repoId)).toBe(true);
|
|
||||||
} finally {
|
|
||||||
releaseShared();
|
|
||||||
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should ignore and clean expired waiters during acquisition", async () => {
|
|
||||||
const repoId = "expired-waiter";
|
|
||||||
const expiredWaiterId = "expired-waiter-row";
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
await db.insert(repositoryLockWaitersTable).values({
|
|
||||||
id: expiredWaiterId,
|
|
||||||
repositoryId: repoId,
|
|
||||||
type: "exclusive",
|
|
||||||
operation: "stale-exclusive",
|
|
||||||
ownerId: "stale-owner",
|
|
||||||
requestedAt: now - 10_000,
|
|
||||||
expiresAt: now - 1,
|
|
||||||
heartbeatAt: now - 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const releaseShared = await acquireWithin(repoMutex.acquireShared(repoId, "backup"));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expiredWaiter = await db.query.repositoryLockWaitersTable.findFirst({
|
|
||||||
where: { id: { eq: expiredWaiterId } },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(expiredWaiter).toBeUndefined();
|
|
||||||
expect(repoMutex.isLocked(repoId)).toBe(true);
|
|
||||||
} finally {
|
|
||||||
releaseShared();
|
|
||||||
await db.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.repositoryId, repoId));
|
|
||||||
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should release only the caller lock row", async () => {
|
|
||||||
const repoId = "release-own-row";
|
|
||||||
const foreignLockId = "foreign-release-own-row";
|
|
||||||
const releaseShared = await repoMutex.acquireShared(repoId, "owned-shared");
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.insert(repositoryLocksTable).values({
|
|
||||||
id: foreignLockId,
|
|
||||||
repositoryId: repoId,
|
|
||||||
type: "shared",
|
|
||||||
operation: "foreign-shared",
|
|
||||||
ownerId: "foreign-owner",
|
|
||||||
acquiredAt: now,
|
|
||||||
expiresAt: now + 60_000,
|
|
||||||
heartbeatAt: now,
|
|
||||||
});
|
|
||||||
|
|
||||||
releaseShared();
|
|
||||||
|
|
||||||
const remainingLocks = await db.query.repositoryLocksTable.findMany({
|
|
||||||
where: { repositoryId: { eq: repoId } },
|
|
||||||
orderBy: { operation: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(remainingLocks.map((lock) => lock.operation)).toEqual(["foreign-shared"]);
|
|
||||||
expect(repoMutex.isLocked(repoId)).toBe(true);
|
|
||||||
} finally {
|
|
||||||
releaseShared();
|
|
||||||
await db.delete(repositoryLocksTable).where(eq(repositoryLocksTable.repositoryId, repoId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,31 @@
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
import { prettifyError, z } from "zod";
|
import { prettifyError, z } from "zod";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { resolveResticHostname } from "../../../apps/agent/src/restic/hostname";
|
|
||||||
import { buildAllowedHosts } from "../lib/auth/base-url";
|
import { buildAllowedHosts } from "../lib/auth/base-url";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
|
|
||||||
const unquote = (str: string) => str.trim().replace(/^(['"])(.*)\1$/, "$2");
|
const unquote = (str: string) => str.trim().replace(/^(['"])(.*)\1$/, "$2");
|
||||||
|
const getResticHostname = () => {
|
||||||
|
try {
|
||||||
|
const mountinfo = readFileSync("/proc/self/mountinfo", "utf-8");
|
||||||
|
const hostnameLine = mountinfo.split("\n").find((line) => line.includes(" /etc/hostname "));
|
||||||
|
const hostname = os.hostname();
|
||||||
|
|
||||||
|
if (hostnameLine) {
|
||||||
|
const containerIdMatch = hostnameLine.match(/[0-9a-f]{64}/);
|
||||||
|
const containerId = containerIdMatch ? containerIdMatch[0] : null;
|
||||||
|
|
||||||
|
if (containerId?.startsWith(hostname)) {
|
||||||
|
return "zerobyte";
|
||||||
|
}
|
||||||
|
|
||||||
|
return hostname || "zerobyte";
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return "zerobyte";
|
||||||
|
};
|
||||||
|
|
||||||
const envSchema = z
|
const envSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|
@ -18,8 +38,6 @@ const envSchema = z
|
||||||
MIGRATIONS_PATH: z.string().optional(),
|
MIGRATIONS_PATH: z.string().optional(),
|
||||||
APP_VERSION: z.string().default("dev"),
|
APP_VERSION: z.string().default("dev"),
|
||||||
TRUSTED_ORIGINS: z.string().optional(),
|
TRUSTED_ORIGINS: z.string().optional(),
|
||||||
PORTLESS_URL: z.string().optional(),
|
|
||||||
PORTLESS_TAILSCALE_URL: z.string().optional(),
|
|
||||||
TRUST_PROXY: z.string().default("false"),
|
TRUST_PROXY: z.string().default("false"),
|
||||||
DISABLE_RATE_LIMITING: 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()),
|
APP_SECRET: z.preprocess((value) => (value === "" ? undefined : value), z.string().min(32).max(256).optional()),
|
||||||
|
|
@ -31,24 +49,10 @@ const envSchema = z
|
||||||
PROVISIONING_PATH: z.string().optional(),
|
PROVISIONING_PATH: z.string().optional(),
|
||||||
})
|
})
|
||||||
.transform((s, ctx) => {
|
.transform((s, ctx) => {
|
||||||
let baseUrl = unquote(s.BASE_URL);
|
const baseUrl = unquote(s.BASE_URL);
|
||||||
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean) ?? [];
|
const trustedOrigins = s.TRUSTED_ORIGINS?.split(",").map(unquote).filter(Boolean).concat(baseUrl) ?? [baseUrl];
|
||||||
|
|
||||||
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 webhookAllowedOrigins = s.WEBHOOK_ALLOWED_ORIGINS?.split(",").map(unquote).filter(Boolean) ?? [];
|
||||||
const authOrigins = [baseUrl, ...uniqueTrustedOrigins];
|
const authOrigins = [baseUrl, ...trustedOrigins];
|
||||||
const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins);
|
const { allowedHosts, invalidOrigins } = buildAllowedHosts(authOrigins);
|
||||||
let appSecret = s.APP_SECRET;
|
let appSecret = s.APP_SECRET;
|
||||||
|
|
||||||
|
|
@ -120,11 +124,11 @@ const envSchema = z
|
||||||
serverIp: s.SERVER_IP,
|
serverIp: s.SERVER_IP,
|
||||||
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
||||||
webhookTimeout: s.WEBHOOK_TIMEOUT,
|
webhookTimeout: s.WEBHOOK_TIMEOUT,
|
||||||
resticHostname: s.RESTIC_HOSTNAME || resolveResticHostname(),
|
resticHostname: s.RESTIC_HOSTNAME || getResticHostname(),
|
||||||
port: s.PORT,
|
port: s.PORT,
|
||||||
migrationsPath: s.MIGRATIONS_PATH,
|
migrationsPath: s.MIGRATIONS_PATH,
|
||||||
appVersion: s.APP_VERSION,
|
appVersion: s.APP_VERSION,
|
||||||
trustedOrigins: uniqueTrustedOrigins,
|
trustedOrigins: trustedOrigins,
|
||||||
trustProxy: s.TRUST_PROXY === "true",
|
trustProxy: s.TRUST_PROXY === "true",
|
||||||
appSecret: appSecret ?? "",
|
appSecret: appSecret ?? "",
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,11 @@ export const RESTIC_CACHE_DIR = process.env.RESTIC_CACHE_DIR || "/var/lib/zeroby
|
||||||
|
|
||||||
export const DATABASE_URL = process.env.ZEROBYTE_DATABASE_URL || "/var/lib/zerobyte/data/zerobyte.db";
|
export const DATABASE_URL = process.env.ZEROBYTE_DATABASE_URL || "/var/lib/zerobyte/data/zerobyte.db";
|
||||||
export const RESTIC_PASS_FILE = process.env.RESTIC_PASS_FILE || "/var/lib/zerobyte/data/restic.pass";
|
export const RESTIC_PASS_FILE = process.env.RESTIC_PASS_FILE || "/var/lib/zerobyte/data/restic.pass";
|
||||||
|
export const SSH_KEYS_DIR = "/var/lib/zerobyte/ssh";
|
||||||
|
|
||||||
export const RCLONE_CONFIG_DIR = process.env.RCLONE_CONFIG_DIR || "/root/.config/rclone";
|
export const RCLONE_CONFIG_DIR = process.env.RCLONE_CONFIG_DIR || "/root/.config/rclone";
|
||||||
export const RCLONE_CONFIG_FILE = path.join(RCLONE_CONFIG_DIR, "rclone.conf");
|
export const RCLONE_CONFIG_FILE = path.join(RCLONE_CONFIG_DIR, "rclone.conf");
|
||||||
export const RESTORE_BLOCKED_ROOTS = [REPOSITORY_BASE, RESTIC_CACHE_DIR, RCLONE_CONFIG_DIR, "/app"];
|
export const RESTORE_BLOCKED_ROOTS = [REPOSITORY_BASE, RESTIC_CACHE_DIR, SSH_KEYS_DIR, RCLONE_CONFIG_DIR, "/app"];
|
||||||
|
|
||||||
export const DEFAULT_EXCLUDES = [RESTIC_PASS_FILE, REPOSITORY_BASE];
|
export const DEFAULT_EXCLUDES = [RESTIC_PASS_FILE, REPOSITORY_BASE];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,4 @@
|
||||||
import { and, eq, lte } from "drizzle-orm";
|
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import { db } from "../db/db";
|
|
||||||
import {
|
|
||||||
repositoryLocksTable,
|
|
||||||
repositoryLockWaitersTable,
|
|
||||||
type RepositoryLock,
|
|
||||||
type RepositoryLockWaiter,
|
|
||||||
} from "../db/schema";
|
|
||||||
import { Effect, Exit, Fiber, Schedule, Scope } from "effect";
|
|
||||||
|
|
||||||
type LockType = "shared" | "exclusive";
|
type LockType = "shared" | "exclusive";
|
||||||
|
|
||||||
|
|
@ -17,545 +8,420 @@ interface LockRequest {
|
||||||
operation: string;
|
operation: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AcquiredLock {
|
interface LockHolder {
|
||||||
id: string;
|
id: string;
|
||||||
repositoryId: string;
|
|
||||||
type: LockType;
|
|
||||||
operation: string;
|
operation: string;
|
||||||
acquiredAt: number;
|
acquiredAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RepositoryMutexTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
interface RepositoryLockState {
|
||||||
type HeartbeatTarget = "lock" | "waiter";
|
sharedHolders: Map<string, LockHolder>;
|
||||||
type QueueAttempt = { status: "acquired"; lock: AcquiredLock } | { status: "waiting" } | { status: "missing" };
|
exclusiveHolder: LockHolder | null;
|
||||||
|
waitQueue: Array<{
|
||||||
const LOCK_LEASE_MS = 30_000;
|
type: LockType;
|
||||||
const LOCK_HEARTBEAT_MS = 5_000;
|
operation: string;
|
||||||
const LOCK_POLL_MS = 250;
|
resolve: (lockId: string) => void;
|
||||||
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 {
|
class RepositoryMutex {
|
||||||
private ownerId = `owner_${Bun.randomUUIDv7()}`;
|
private locks = new Map<string, RepositoryLockState>();
|
||||||
private nextPollCleanupAt = 0;
|
private changeListeners = new Map<string, Set<() => void>>();
|
||||||
|
private lockIdCounter = 0;
|
||||||
|
|
||||||
|
private getOrCreateState(repositoryId: string): RepositoryLockState {
|
||||||
|
let state = this.locks.get(repositoryId);
|
||||||
|
if (!state) {
|
||||||
|
state = {
|
||||||
|
sharedHolders: new Map(),
|
||||||
|
exclusiveHolder: null,
|
||||||
|
waitQueue: [],
|
||||||
|
};
|
||||||
|
this.locks.set(repositoryId, state);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
private generateLockId(): string {
|
private generateLockId(): string {
|
||||||
return `lock_${Bun.randomUUIDv7()}`;
|
return `lock_${++this.lockIdCounter}_${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private abortReason(signal: AbortSignal): Error {
|
private cleanupStateIfEmpty(repositoryId: string): void {
|
||||||
return signal.reason || new Error("Operation aborted");
|
const state = this.locks.get(repositoryId);
|
||||||
|
if (state && state.sharedHolders.size === 0 && !state.exclusiveHolder && state.waitQueue.length === 0) {
|
||||||
|
this.locks.delete(repositoryId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanupExpired(tx: RepositoryMutexTransaction, now: number) {
|
private notifyChange(repositoryId: string): void {
|
||||||
tx.delete(repositoryLocksTable).where(lte(repositoryLocksTable.expiresAt, now)).run();
|
const listeners = this.changeListeners.get(repositoryId);
|
||||||
tx.delete(repositoryLockWaitersTable).where(lte(repositoryLockWaitersTable.expiresAt, now)).run();
|
if (!listeners) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanupExpiredDuringPolling(tx: RepositoryMutexTransaction, now: number) {
|
private canAcquireImmediately(state: RepositoryLockState | undefined, type: LockType): boolean {
|
||||||
if (now < this.nextPollCleanupAt) return;
|
if (!state) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
this.cleanupExpired(tx, now);
|
|
||||||
this.nextPollCleanupAt = now + LOCK_POLL_CLEANUP_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getActiveLocks(tx: RepositoryMutexTransaction, repositoryId: string, now: number) {
|
|
||||||
return tx.query.repositoryLocksTable
|
|
||||||
.findMany({
|
|
||||||
where: { AND: [{ repositoryId: { eq: repositoryId } }, { expiresAt: { gt: now } }] },
|
|
||||||
orderBy: { acquiredAt: "asc", id: "asc" },
|
|
||||||
})
|
|
||||||
.sync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getWaiters(tx: RepositoryMutexTransaction, repositoryId: string, now: number) {
|
|
||||||
return tx.query.repositoryLockWaitersTable
|
|
||||||
.findMany({
|
|
||||||
where: { AND: [{ repositoryId: { eq: repositoryId } }, { expiresAt: { gt: now } }] },
|
|
||||||
orderBy: { requestedAt: "asc", id: "asc" },
|
|
||||||
})
|
|
||||||
.sync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getActiveLockById(tx: RepositoryMutexTransaction, lockId: string, now: number) {
|
|
||||||
return tx.query.repositoryLocksTable
|
|
||||||
.findFirst({ where: { AND: [{ id: { eq: lockId } }, { expiresAt: { gt: now } }] } })
|
|
||||||
.sync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getWaiterById(tx: RepositoryMutexTransaction, waiterId: string, now: number) {
|
|
||||||
return tx.query.repositoryLockWaitersTable
|
|
||||||
.findFirst({ where: { AND: [{ id: { eq: waiterId } }, { expiresAt: { gt: now } }] } })
|
|
||||||
.sync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private canAcquireImmediately(type: LockType, activeLocks: RepositoryLock[], waiters: RepositoryLockWaiter[]) {
|
|
||||||
if (type === "shared") {
|
if (type === "shared") {
|
||||||
return (
|
const hasExclusiveInQueue = state.waitQueue.some((item) => item.type === "exclusive");
|
||||||
!activeLocks.some((lock) => lock.type === "exclusive") &&
|
return !state.exclusiveHolder && !hasExclusiveInQueue;
|
||||||
!waiters.some((waiter) => waiter.type === "exclusive")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return activeLocks.length === 0 && waiters.length === 0;
|
return !state.exclusiveHolder && state.sharedHolders.size === 0 && state.waitQueue.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private insertLock(
|
private waitForChange(repositoryIds: string[], signal?: AbortSignal) {
|
||||||
tx: RepositoryMutexTransaction,
|
if (signal?.aborted) {
|
||||||
request: LockRequest & { id: string; ownerId: string },
|
throw signal.reason || new Error("Operation aborted");
|
||||||
now: number,
|
|
||||||
) {
|
|
||||||
const lock = {
|
|
||||||
id: request.id,
|
|
||||||
repositoryId: request.repositoryId,
|
|
||||||
type: request.type,
|
|
||||||
operation: request.operation,
|
|
||||||
ownerId: request.ownerId,
|
|
||||||
acquiredAt: now,
|
|
||||||
expiresAt: now + LOCK_LEASE_MS,
|
|
||||||
heartbeatAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
tx.insert(repositoryLocksTable).values(lock).run();
|
|
||||||
|
|
||||||
return lock;
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryAcquireManyRows(requests: LockRequest[]) {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
return db.transaction((tx) => {
|
|
||||||
this.cleanupExpired(tx, now);
|
|
||||||
|
|
||||||
for (const request of requests) {
|
|
||||||
const activeLocks = this.getActiveLocks(tx, request.repositoryId, now);
|
|
||||||
const waiters = this.getWaiters(tx, request.repositoryId, now);
|
|
||||||
|
|
||||||
if (!this.canAcquireImmediately(request.type, activeLocks, waiters)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return requests.map((request) =>
|
|
||||||
this.insertLock(tx, { ...request, id: this.generateLockId(), ownerId: this.ownerId }, now),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryAcquireImmediately(request: LockRequest) {
|
|
||||||
return Effect.gen(this, function* () {
|
|
||||||
const locks = this.tryAcquireManyRows([request]);
|
|
||||||
if (!locks || locks.length === 0) return null;
|
|
||||||
|
|
||||||
const [lock] = locks;
|
|
||||||
return yield* this.createRelease(lock);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private createWaiter(request: LockRequest, waiterId: string) {
|
|
||||||
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) {
|
|
||||||
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 {
|
|
||||||
tx.delete(repositoryLockWaitersTable).where(eq(repositoryLockWaitersTable.id, waiterId)).run();
|
|
||||||
}
|
|
||||||
|
|
||||||
private promoteWaiter(tx: RepositoryMutexTransaction, waiter: RepositoryLockWaiter, now: number) {
|
|
||||||
this.deleteWaiterRow(tx, waiter.id);
|
|
||||||
return this.insertLock(tx, { ...waiter, id: waiter.id }, now);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getLeadingSharedWaiters(waiters: RepositoryLockWaiter[]) {
|
|
||||||
const leadingSharedWaiters: RepositoryLockWaiter[] = [];
|
|
||||||
for (const waiter of waiters) {
|
|
||||||
if (waiter.type === "exclusive") break;
|
|
||||||
|
|
||||||
leadingSharedWaiters.push(waiter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return leadingSharedWaiters;
|
return new Promise<void>((resolve, reject) => {
|
||||||
}
|
const uniqueRepositoryIds = [...new Set(repositoryIds)];
|
||||||
|
const cleanupCallbacks: Array<() => void> = [];
|
||||||
private tryPromoteWaiter(waiterId: string): QueueAttempt {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
return db.transaction((tx) => {
|
|
||||||
this.cleanupExpiredDuringPolling(tx, now);
|
|
||||||
|
|
||||||
const activeLock = this.getActiveLockById(tx, waiterId, now);
|
|
||||||
if (activeLock) {
|
|
||||||
return { status: "acquired", lock: activeLock };
|
|
||||||
}
|
|
||||||
|
|
||||||
const waiter = this.getWaiterById(tx, waiterId, now);
|
|
||||||
if (!waiter) {
|
|
||||||
return { status: "missing" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeLocks = this.getActiveLocks(tx, waiter.repositoryId, now);
|
|
||||||
const waiters = this.getWaiters(tx, waiter.repositoryId, now);
|
|
||||||
|
|
||||||
if (waiter.type === "exclusive") {
|
|
||||||
if (activeLocks.length > 0 || waiters[0]?.id !== waiter.id) {
|
|
||||||
return { status: "waiting" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: "acquired", lock: this.promoteWaiter(tx, waiter, now) };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeLocks.some((lock) => lock.type === "exclusive")) {
|
|
||||||
return { status: "waiting" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const leadingSharedWaiters = this.getLeadingSharedWaiters(waiters);
|
|
||||||
if (!leadingSharedWaiters.some((queuedWaiter) => queuedWaiter.id === waiter.id)) {
|
|
||||||
return { status: "waiting" };
|
|
||||||
}
|
|
||||||
|
|
||||||
let acquiredLock: AcquiredLock | null = null;
|
|
||||||
for (const sharedWaiter of leadingSharedWaiters) {
|
|
||||||
const lock = this.promoteWaiter(tx, sharedWaiter, now);
|
|
||||||
|
|
||||||
if (sharedWaiter.id === waiter.id) {
|
|
||||||
acquiredLock = lock;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!acquiredLock) {
|
|
||||||
return { status: "waiting" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: "acquired", lock: acquiredLock };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private waitForQueuedLock(request: LockRequest) {
|
|
||||||
const waiterId = this.generateLockId();
|
|
||||||
|
|
||||||
const attempt = Effect.sync(() => this.tryPromoteWaiter(waiterId)).pipe(
|
|
||||||
Effect.flatMap((attempt) => {
|
|
||||||
if (attempt.status === "acquired") {
|
|
||||||
return Effect.succeed(attempt.lock);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt.status === "missing") {
|
|
||||||
return Effect.gen(this, function* () {
|
|
||||||
yield* this.createWaiter(request, waiterId);
|
|
||||||
yield* this.startHeartbeat("waiter", waiterId);
|
|
||||||
|
|
||||||
return yield* Effect.fail("retry");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Effect.fail("retry");
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const cleanupAbandonedWaiter = Effect.gen(this, function* () {
|
|
||||||
yield* this.deleteWaiter(waiterId);
|
|
||||||
yield* this.release({ id: waiterId });
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cleanupAbandonedWaiter;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return yield* this.createRelease(lock);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
isLocked(repositoryId: string) {
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
return db.transaction((tx) => {
|
|
||||||
this.cleanupExpired(tx, now);
|
|
||||||
return this.getActiveLocks(tx, repositoryId, now).length > 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private createReleaseMany(locks: AcquiredLock[]) {
|
|
||||||
return Effect.gen(this, function* () {
|
|
||||||
const releases = yield* Effect.all(locks.map((lock) => this.createRelease(lock)));
|
|
||||||
let released = false;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (released) return;
|
|
||||||
|
|
||||||
released = true;
|
|
||||||
for (const release of releases.toReversed()) {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private createRelease(lock: AcquiredLock) {
|
|
||||||
return Effect.gen(this, function* () {
|
|
||||||
const heartbeatFiber = yield* this.startHeartbeat("lock", lock.id);
|
|
||||||
let released = false;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (released) return;
|
|
||||||
|
|
||||||
released = true;
|
|
||||||
Effect.runFork(Fiber.interrupt(heartbeatFiber));
|
|
||||||
Effect.runSync(this.release(lock));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private release(lock: Pick<AcquiredLock, "id">) {
|
|
||||||
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;
|
|
||||||
|
|
||||||
tx.delete(repositoryLocksTable)
|
|
||||||
.where(
|
|
||||||
and(eq(repositoryLocksTable.id, lock.id), eq(repositoryLocksTable.ownerId, this.ownerId)),
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
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)`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
|
|
||||||
if (target === "lock") {
|
|
||||||
yield* Effect.try(() => {
|
|
||||||
db.update(repositoryLocksTable)
|
|
||||||
.set(values)
|
|
||||||
.where(and(eq(repositoryLocksTable.id, lockId), eq(repositoryLocksTable.ownerId, this.ownerId)))
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
yield* Effect.try(() => {
|
|
||||||
db.update(repositoryLockWaitersTable)
|
|
||||||
.set(values)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(repositoryLockWaitersTable.id, lockId),
|
|
||||||
eq(repositoryLockWaitersTable.ownerId, this.ownerId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}).pipe(
|
|
||||||
Effect.catchAll((error) =>
|
|
||||||
logger.effect.warn(`[Mutex] Failed to heartbeat ${target} ${lockId}: ${String(error)}`),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise<A>((resolve, reject) => {
|
|
||||||
const fiber = Effect.runFork(effect);
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let aborting = false;
|
|
||||||
|
|
||||||
const complete = (callback: () => void) => {
|
const settle = (callback: () => void) => {
|
||||||
if (settled) return;
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
settled = true;
|
settled = true;
|
||||||
signal.removeEventListener("abort", onAbort);
|
for (const cleanup of cleanupCallbacks) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
callback();
|
callback();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAbort = () => {
|
for (const repositoryId of uniqueRepositoryIds) {
|
||||||
aborting = true;
|
let listeners = this.changeListeners.get(repositoryId);
|
||||||
Effect.runPromise(Fiber.interrupt(fiber)).then(
|
if (!listeners) {
|
||||||
(exit) =>
|
listeners = new Set();
|
||||||
complete(() => {
|
this.changeListeners.set(repositoryId, listeners);
|
||||||
if (Exit.isSuccess(exit)) {
|
}
|
||||||
resolve(exit.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reject(this.abortReason(signal));
|
const listener = () => settle(resolve);
|
||||||
}),
|
listeners.add(listener);
|
||||||
(error) => complete(() => reject(error)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
cleanupCallbacks.push(() => {
|
||||||
|
const currentListeners = this.changeListeners.get(repositoryId);
|
||||||
Effect.runPromise(Fiber.join(fiber)).then(
|
if (!currentListeners) {
|
||||||
(value) => complete(() => resolve(value)),
|
return;
|
||||||
(error) => {
|
|
||||||
if (!aborting) {
|
|
||||||
complete(() => reject(error));
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
);
|
currentListeners.delete(listener);
|
||||||
|
if (currentListeners.size === 0) {
|
||||||
|
this.changeListeners.delete(repositoryId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
const onAbort = () => {
|
||||||
|
settle(() => reject(signal.reason || new Error("Operation aborted")));
|
||||||
|
};
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort);
|
||||||
|
cleanupCallbacks.push(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async acquireShared(repositoryId: string, operation: string, signal?: AbortSignal) {
|
private tryAcquireMany(requests: LockRequest[]) {
|
||||||
return await this.runWithSignal(this.acquireSharedEffect(repositoryId, operation), signal);
|
for (const request of requests) {
|
||||||
|
if (!this.canAcquireImmediately(this.locks.get(request.repositoryId), request.type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases = requests.map((request) => {
|
||||||
|
const state = this.getOrCreateState(request.repositoryId);
|
||||||
|
const lockId = this.generateLockId();
|
||||||
|
|
||||||
|
if (request.type === "shared") {
|
||||||
|
state.sharedHolders.set(lockId, {
|
||||||
|
id: lockId,
|
||||||
|
operation: request.operation,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
state.exclusiveHolder = {
|
||||||
|
id: lockId,
|
||||||
|
operation: request.operation,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.createRelease(request.type, request.repositoryId, lockId);
|
||||||
|
});
|
||||||
|
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
released = true;
|
||||||
|
for (const release of releases.toReversed()) {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async acquireExclusive(repositoryId: string, operation: string, signal?: AbortSignal) {
|
async acquireShared(repositoryId: string, operation: string, signal?: AbortSignal): Promise<() => void> {
|
||||||
return await this.runWithSignal(this.acquireExclusiveEffect(repositoryId, operation), signal);
|
if (signal?.aborted) {
|
||||||
|
throw signal.reason || new Error("Operation aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this.getOrCreateState(repositoryId);
|
||||||
|
|
||||||
|
const hasExclusiveInQueue = state.waitQueue.some((item) => item.type === "exclusive");
|
||||||
|
|
||||||
|
if (!state.exclusiveHolder && !hasExclusiveInQueue) {
|
||||||
|
const lockId = this.generateLockId();
|
||||||
|
state.sharedHolders.set(lockId, {
|
||||||
|
id: lockId,
|
||||||
|
operation,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
});
|
||||||
|
return () => this.releaseShared(repositoryId, lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[Mutex] Waiting for shared lock on repo ${repositoryId}: ${operation} (exclusive held by: ${state.exclusiveHolder?.operation ?? "none"}, queue: ${state.waitQueue.length})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let onAbort: () => void = () => {};
|
||||||
|
let lockId: string | undefined;
|
||||||
|
try {
|
||||||
|
lockId = await new Promise<string>((resolve, reject) => {
|
||||||
|
const waiter = { type: "shared" as const, operation, resolve };
|
||||||
|
state.waitQueue.push(waiter);
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
onAbort = () => {
|
||||||
|
const index = state.waitQueue.indexOf(waiter);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.waitQueue.splice(index, 1);
|
||||||
|
this.cleanupStateIfEmpty(repositoryId);
|
||||||
|
this.notifyChange(repositoryId);
|
||||||
|
reject(signal.reason || new Error("Operation aborted"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
if (lockId) {
|
||||||
|
this.releaseShared(repositoryId, lockId);
|
||||||
|
}
|
||||||
|
throw signal.reason || new Error("Operation aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.createRelease("shared", repositoryId, lockId!);
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquireExclusive(repositoryId: string, operation: string, signal?: AbortSignal): Promise<() => void> {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw signal.reason || new Error("Operation aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this.getOrCreateState(repositoryId);
|
||||||
|
|
||||||
|
if (!state.exclusiveHolder && state.sharedHolders.size === 0 && state.waitQueue.length === 0) {
|
||||||
|
const lockId = this.generateLockId();
|
||||||
|
state.exclusiveHolder = {
|
||||||
|
id: lockId,
|
||||||
|
operation,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
};
|
||||||
|
return () => this.releaseExclusive(repositoryId, lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`[Mutex] Waiting for exclusive lock on repo ${repositoryId}: ${operation} (shared: ${state.sharedHolders.size}, exclusive: ${state.exclusiveHolder ? "yes" : "no"}, queue: ${state.waitQueue.length})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let onAbort: () => void = () => {};
|
||||||
|
let lockId: string | undefined;
|
||||||
|
try {
|
||||||
|
lockId = await new Promise<string>((resolve, reject) => {
|
||||||
|
const waiter = { type: "exclusive" as const, operation, resolve };
|
||||||
|
state.waitQueue.push(waiter);
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
onAbort = () => {
|
||||||
|
const index = state.waitQueue.indexOf(waiter);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.waitQueue.splice(index, 1);
|
||||||
|
this.cleanupStateIfEmpty(repositoryId);
|
||||||
|
this.notifyChange(repositoryId);
|
||||||
|
reject(signal.reason || new Error("Operation aborted"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
if (lockId) {
|
||||||
|
this.releaseExclusive(repositoryId, lockId);
|
||||||
|
}
|
||||||
|
throw signal.reason || new Error("Operation aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation} (${lockId})`);
|
||||||
|
|
||||||
|
return this.createRelease("exclusive", repositoryId, lockId!);
|
||||||
}
|
}
|
||||||
|
|
||||||
async acquireMany(requests: LockRequest[], signal?: AbortSignal) {
|
async acquireMany(requests: LockRequest[], signal?: AbortSignal) {
|
||||||
return await this.runWithSignal(this.acquireManyEffect(requests), signal);
|
if (signal?.aborted) {
|
||||||
|
throw signal.reason || new Error("Operation aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 releaseLocks = this.tryAcquireMany(sortedRequests);
|
||||||
|
if (releaseLocks) {
|
||||||
|
return releaseLocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitForChange(
|
||||||
|
sortedRequests.map((request) => request.repositoryId),
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw signal.reason || new Error("Operation aborted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseShared(repositoryId: string, lockId: string): void {
|
||||||
|
const state = this.locks.get(repositoryId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const holder = state.sharedHolders.get(lockId);
|
||||||
|
if (!holder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.sharedHolders.delete(lockId);
|
||||||
|
const duration = Date.now() - holder.acquiredAt;
|
||||||
|
logger.debug(`[Mutex] Released shared lock for repo ${repositoryId}: ${holder.operation} (held for ${duration}ms)`);
|
||||||
|
|
||||||
|
this.processWaitQueue(repositoryId);
|
||||||
|
this.cleanupStateIfEmpty(repositoryId);
|
||||||
|
this.notifyChange(repositoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseExclusive(repositoryId: string, lockId: string): void {
|
||||||
|
const state = this.locks.get(repositoryId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.exclusiveHolder || state.exclusiveHolder.id !== lockId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - state.exclusiveHolder.acquiredAt;
|
||||||
|
logger.debug(
|
||||||
|
`[Mutex] Released exclusive lock for repo ${repositoryId}: ${state.exclusiveHolder.operation} (held for ${duration}ms)`,
|
||||||
|
);
|
||||||
|
state.exclusiveHolder = null;
|
||||||
|
|
||||||
|
this.processWaitQueue(repositoryId);
|
||||||
|
this.cleanupStateIfEmpty(repositoryId);
|
||||||
|
this.notifyChange(repositoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private processWaitQueue(repositoryId: string): void {
|
||||||
|
const state = this.locks.get(repositoryId);
|
||||||
|
if (!state || state.waitQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.exclusiveHolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstWaiter = state.waitQueue[0];
|
||||||
|
|
||||||
|
if (firstWaiter.type === "exclusive") {
|
||||||
|
if (state.sharedHolders.size === 0) {
|
||||||
|
state.waitQueue.shift();
|
||||||
|
const lockId = this.generateLockId();
|
||||||
|
state.exclusiveHolder = {
|
||||||
|
id: lockId,
|
||||||
|
operation: firstWaiter.operation,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
};
|
||||||
|
firstWaiter.resolve(lockId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (state.waitQueue.length > 0 && state.waitQueue[0].type === "shared") {
|
||||||
|
const waiter = state.waitQueue.shift();
|
||||||
|
if (!waiter) break;
|
||||||
|
const lockId = this.generateLockId();
|
||||||
|
state.sharedHolders.set(lockId, {
|
||||||
|
id: lockId,
|
||||||
|
operation: waiter.operation,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
});
|
||||||
|
waiter.resolve(lockId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLocked(repositoryId: string): boolean {
|
||||||
|
const state = this.locks.get(repositoryId);
|
||||||
|
if (!state) return false;
|
||||||
|
return state.exclusiveHolder !== null || state.sharedHolders.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRelease(type: LockType, repositoryId: string, lockId: string) {
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
if (type === "shared") {
|
||||||
|
this.releaseShared(repositoryId, lockId);
|
||||||
|
} else {
|
||||||
|
this.releaseExclusive(repositoryId, lockId);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const repoMutex = getRepositoryMutex();
|
export const repoMutex = new RepositoryMutex();
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ const runMigrations = async () => {
|
||||||
migrate(db, { migrationsFolder });
|
migrate(db, { migrationsFolder });
|
||||||
|
|
||||||
sqlite.run("PRAGMA foreign_keys = ON;");
|
sqlite.run("PRAGMA foreign_keys = ON;");
|
||||||
sqlite.run("PRAGMA busy_timeout = 5000;");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const runDbMigrations = () => {
|
export const runDbMigrations = () => {
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ export const relations = defineRelations(schema, (r) => ({
|
||||||
sessions: r.many.sessionsTable(),
|
sessions: r.many.sessionsTable(),
|
||||||
members: r.many.member(),
|
members: r.many.member(),
|
||||||
twoFactors: r.many.twoFactor(),
|
twoFactors: r.many.twoFactor(),
|
||||||
passkeys: r.many.passkey(),
|
|
||||||
ssoProviders: r.many.ssoProvider(),
|
ssoProviders: r.many.ssoProvider(),
|
||||||
organizations: r.many.organization({
|
organizations: r.many.organization({
|
||||||
from: r.usersTable.id.through(r.member.userId),
|
from: r.usersTable.id.through(r.member.userId),
|
||||||
|
|
@ -96,12 +95,6 @@ export const relations = defineRelations(schema, (r) => ({
|
||||||
to: r.usersTable.id,
|
to: r.usersTable.id,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
passkey: {
|
|
||||||
usersTable: r.one.usersTable({
|
|
||||||
from: r.passkey.userId,
|
|
||||||
to: r.usersTable.id,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
organization: {
|
organization: {
|
||||||
users: r.many.usersTable({
|
users: r.many.usersTable({
|
||||||
alias: "usersTable_id_organization_id_via_member",
|
alias: "usersTable_id_organization_id_via_member",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import type {
|
||||||
ResticStatsDto,
|
ResticStatsDto,
|
||||||
} from "@zerobyte/core/restic";
|
} from "@zerobyte/core/restic";
|
||||||
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
|
import type { BackupWebhooks } from "@zerobyte/core/backup-hooks";
|
||||||
import type { BackendConfig, BackendStatus, BackendType } from "@zerobyte/contracts/volumes";
|
import type { BackendConfig, BackendStatus, BackendType } from "~/schemas/volumes";
|
||||||
import type { NotificationConfig, NotificationType } from "~/schemas/notifications";
|
import type { NotificationConfig, NotificationType } from "~/schemas/notifications";
|
||||||
import type { ShortId } from "~/server/utils/branded";
|
import type { ShortId } from "~/server/utils/branded";
|
||||||
import { LOCAL_AGENT_ID } from "../modules/agents/constants";
|
import { LOCAL_AGENT_ID } from "../modules/agents/constants";
|
||||||
|
|
@ -310,90 +310,6 @@ export const repositoriesTable = sqliteTable(
|
||||||
export type Repository = typeof repositoriesTable.$inferSelect;
|
export type Repository = typeof repositoriesTable.$inferSelect;
|
||||||
export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
|
export type RepositoryInsert = typeof repositoriesTable.$inferInsert;
|
||||||
|
|
||||||
export type RepositoryLockType = "shared" | "exclusive";
|
|
||||||
|
|
||||||
export const repositoryLocksTable = sqliteTable(
|
|
||||||
"repository_locks",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
repositoryId: text("repository_id").notNull(),
|
|
||||||
type: text("type").$type<RepositoryLockType>().notNull(),
|
|
||||||
operation: text("operation").notNull(),
|
|
||||||
ownerId: text("owner_id").notNull(),
|
|
||||||
acquiredAt: int("acquired_at", { mode: "number" }).notNull(),
|
|
||||||
expiresAt: int("expires_at", { mode: "number" }).notNull(),
|
|
||||||
heartbeatAt: int("heartbeat_at", { mode: "number" }).notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index("repository_locks_repository_id_idx").on(table.repositoryId),
|
|
||||||
index("repository_locks_expires_at_idx").on(table.expiresAt),
|
|
||||||
index("repository_locks_owner_id_idx").on(table.ownerId),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
export type RepositoryLock = typeof repositoryLocksTable.$inferSelect;
|
|
||||||
|
|
||||||
export const repositoryLockWaitersTable = sqliteTable(
|
|
||||||
"repository_lock_waiters",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
repositoryId: text("repository_id").notNull(),
|
|
||||||
type: text("type").$type<RepositoryLockType>().notNull(),
|
|
||||||
operation: text("operation").notNull(),
|
|
||||||
ownerId: text("owner_id").notNull(),
|
|
||||||
requestedAt: int("requested_at", { mode: "number" }).notNull(),
|
|
||||||
expiresAt: int("expires_at", { mode: "number" }).notNull(),
|
|
||||||
heartbeatAt: int("heartbeat_at", { mode: "number" }).notNull(),
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index("repository_lock_waiters_repository_id_idx").on(table.repositoryId),
|
|
||||||
index("repository_lock_waiters_expires_at_idx").on(table.expiresAt),
|
|
||||||
index("repository_lock_waiters_owner_id_idx").on(table.ownerId),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
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
|
* Backup Schedules Table
|
||||||
*/
|
*/
|
||||||
|
|
@ -547,30 +463,6 @@ export const twoFactor = sqliteTable(
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
.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)],
|
(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;
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
import { APIError } from "better-auth/api";
|
import { APIError } from "better-auth/api";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
|
import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
|
||||||
import { passkey } from "@better-auth/passkey";
|
|
||||||
import { createAuthMiddleware } from "better-auth/api";
|
import { createAuthMiddleware } from "better-auth/api";
|
||||||
import { config } from "../core/config";
|
import { config } from "../core/config";
|
||||||
import { db } from "../db/db";
|
import { db } from "../db/db";
|
||||||
|
|
@ -28,7 +27,6 @@ export const auth = betterAuth({
|
||||||
baseURL: {
|
baseURL: {
|
||||||
allowedHosts: config.allowedHosts,
|
allowedHosts: config.allowedHosts,
|
||||||
protocol: "auto",
|
protocol: "auto",
|
||||||
fallback: config.baseUrl,
|
|
||||||
},
|
},
|
||||||
trustedOrigins: config.trustedOrigins,
|
trustedOrigins: config.trustedOrigins,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
|
|
@ -171,26 +169,6 @@ export const auth = betterAuth({
|
||||||
amount: 5,
|
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(),
|
tanstackStartCookies(),
|
||||||
...(process.env.NODE_ENV === "test" ? [testUtils()] : []),
|
...(process.env.NODE_ENV === "test" ? [testUtils()] : []),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
|
||||||
import { hasActivePasskeyUser } from "~/server/modules/auth/helpers";
|
|
||||||
|
|
||||||
export const getLoginOptions = createServerFn({ method: "GET" }).handler(async () => ({
|
|
||||||
hasPasskeySignIn: await hasActivePasskeyUser(),
|
|
||||||
}));
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { fromAny, fromPartial } from "@total-typescript/shoehorn";
|
||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
import { agentManager, type ProcessWithAgentRuntime } from "../agents-manager";
|
import { agentManager, type ProcessWithAgentRuntime } from "../agents-manager";
|
||||||
import type { AgentManagerRuntime } from "../controller/server";
|
import type { AgentManagerRuntime } from "../controller/server";
|
||||||
import type { BackupRunPayload, VolumeCommand, VolumeCommandResponsePayload } from "@zerobyte/contracts/agent-protocol";
|
import type { BackupRunPayload, VolumeCommand } from "@zerobyte/contracts/agent-protocol";
|
||||||
|
|
||||||
const setAgentRuntime = (agentManagerRuntime: Partial<AgentManagerRuntime> | null) => {
|
const setAgentRuntime = (agentManagerRuntime: Partial<AgentManagerRuntime> | null) => {
|
||||||
(process as ProcessWithAgentRuntime).__zerobyteAgentRuntime = {
|
(process as ProcessWithAgentRuntime).__zerobyteAgentRuntime = {
|
||||||
|
|
@ -51,9 +51,9 @@ test("runVolumeCommand sends the command to the selected agent", async () => {
|
||||||
const runVolumeCommand = vi.fn(() =>
|
const runVolumeCommand = vi.fn(() =>
|
||||||
Effect.succeed({
|
Effect.succeed({
|
||||||
commandId: "command-1",
|
commandId: "command-1",
|
||||||
status: "success",
|
status: "success" as const,
|
||||||
command: { name: "volume.mount", result: { status: "mounted" } },
|
command: { name: "volume.mount" as const, result: { status: "mounted" as const } },
|
||||||
} satisfies VolumeCommandResponsePayload),
|
}),
|
||||||
);
|
);
|
||||||
setAgentRuntime({ runVolumeCommand });
|
setAgentRuntime({ runVolumeCommand });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
import { fromAny, fromPartial } from "@total-typescript/shoehorn";
|
import { fromAny, fromPartial } from "@total-typescript/shoehorn";
|
||||||
import type { BackupRunPayload, RestoreRunPayload } from "@zerobyte/contracts/agent-protocol";
|
import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
|
||||||
import type { AgentManagerEvent } from "../controller/server";
|
import type { AgentManagerEvent } from "../controller/server";
|
||||||
import type { ProcessWithAgentRuntime } from "../agents-manager";
|
import type { ProcessWithAgentRuntime } from "../agents-manager";
|
||||||
|
|
||||||
|
|
@ -9,8 +9,6 @@ const controllerMock = vi.hoisted(() => ({
|
||||||
onEvent: null as null | ((event: AgentManagerEvent) => void),
|
onEvent: null as null | ((event: AgentManagerEvent) => void),
|
||||||
sendBackup: vi.fn(),
|
sendBackup: vi.fn(),
|
||||||
cancelBackup: vi.fn(),
|
cancelBackup: vi.fn(),
|
||||||
sendRestore: vi.fn(),
|
|
||||||
cancelRestore: vi.fn(),
|
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -24,8 +22,6 @@ vi.mock("../controller/server", async () => {
|
||||||
stop: Effect.sync(controllerMock.stop),
|
stop: Effect.sync(controllerMock.stop),
|
||||||
sendBackup: controllerMock.sendBackup,
|
sendBackup: controllerMock.sendBackup,
|
||||||
cancelBackup: controllerMock.cancelBackup,
|
cancelBackup: controllerMock.cancelBackup,
|
||||||
sendRestore: controllerMock.sendRestore,
|
|
||||||
cancelRestore: controllerMock.cancelRestore,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
@ -41,7 +37,6 @@ const resetAgentRuntime = () => {
|
||||||
localAgentRestartTimeout: null,
|
localAgentRestartTimeout: null,
|
||||||
activeBackupsByScheduleId: new Map(),
|
activeBackupsByScheduleId: new Map(),
|
||||||
activeBackupScheduleIdsByJobId: new Map(),
|
activeBackupScheduleIdsByJobId: new Map(),
|
||||||
activeRestoresByRestoreId: new Map(),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -49,17 +44,12 @@ const backupPayload = fromPartial<BackupRunPayload>({
|
||||||
jobId: "job-1",
|
jobId: "job-1",
|
||||||
scheduleId: "schedule-1",
|
scheduleId: "schedule-1",
|
||||||
});
|
});
|
||||||
const restorePayload = fromPartial<RestoreRunPayload>({
|
|
||||||
restoreId: "restore-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete processWithAgentRuntime.__zerobyteAgentRuntime;
|
delete processWithAgentRuntime.__zerobyteAgentRuntime;
|
||||||
controllerMock.onEvent = null;
|
controllerMock.onEvent = null;
|
||||||
controllerMock.sendBackup.mockReset();
|
controllerMock.sendBackup.mockReset();
|
||||||
controllerMock.cancelBackup.mockReset();
|
controllerMock.cancelBackup.mockReset();
|
||||||
controllerMock.sendRestore.mockReset();
|
|
||||||
controllerMock.cancelRestore.mockReset();
|
|
||||||
controllerMock.stop.mockReset();
|
controllerMock.stop.mockReset();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
|
@ -83,11 +73,7 @@ test("backup progress is delivered to the running backup callback", async () =>
|
||||||
type: "backup.progress",
|
type: "backup.progress",
|
||||||
agentId: "local",
|
agentId: "local",
|
||||||
agentName: "Local Agent",
|
agentName: "Local Agent",
|
||||||
payload: fromAny({
|
payload: fromAny({ jobId: "job-1", scheduleId: "schedule-1", progress: { percentDone: 0.5 } }),
|
||||||
jobId: "job-1",
|
|
||||||
scheduleId: "schedule-1",
|
|
||||||
progress: { percentDone: 0.5 },
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
controllerMock.onEvent?.({
|
controllerMock.onEvent?.({
|
||||||
type: "backup.completed",
|
type: "backup.completed",
|
||||||
|
|
@ -106,41 +92,6 @@ test("backup progress is delivered to the running backup callback", async () =>
|
||||||
await stopAgentController();
|
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 () => {
|
test("backup failed and cancelled events resolve the matching running backup", async () => {
|
||||||
resetAgentRuntime();
|
resetAgentRuntime();
|
||||||
controllerMock.sendBackup.mockImplementation(() => Effect.succeed(true));
|
controllerMock.sendBackup.mockImplementation(() => Effect.succeed(true));
|
||||||
|
|
@ -157,12 +108,7 @@ test("backup failed and cancelled events resolve the matching running backup", a
|
||||||
type: "backup.failed",
|
type: "backup.failed",
|
||||||
agentId: "local",
|
agentId: "local",
|
||||||
agentName: "Local Agent",
|
agentName: "Local Agent",
|
||||||
payload: {
|
payload: { jobId: "job-1", scheduleId: "schedule-1", error: "failed", errorDetails: "restic failed" },
|
||||||
jobId: "job-1",
|
|
||||||
scheduleId: "schedule-1",
|
|
||||||
error: "failed",
|
|
||||||
errorDetails: "restic failed",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await expect(failedPromise).resolves.toEqual({ status: "failed", error: "restic failed" });
|
await expect(failedPromise).resolves.toEqual({ status: "failed", error: "restic failed" });
|
||||||
|
|
||||||
|
|
@ -178,10 +124,7 @@ test("backup failed and cancelled events resolve the matching running backup", a
|
||||||
agentName: "Local Agent",
|
agentName: "Local Agent",
|
||||||
payload: { jobId: "job-2", scheduleId: "schedule-2", message: "cancelled remotely" },
|
payload: { jobId: "job-2", scheduleId: "schedule-2", message: "cancelled remotely" },
|
||||||
});
|
});
|
||||||
await expect(cancelledPromise).resolves.toEqual({
|
await expect(cancelledPromise).resolves.toEqual({ status: "cancelled", message: "cancelled remotely" });
|
||||||
status: "cancelled",
|
|
||||||
message: "cancelled remotely",
|
|
||||||
});
|
|
||||||
await stopAgentController();
|
await stopAgentController();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -204,11 +147,7 @@ test("agent disconnect cancels only backups owned by that agent", async () => {
|
||||||
onProgress: vi.fn(),
|
onProgress: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
controllerMock.onEvent?.({
|
controllerMock.onEvent?.({ type: "agent.disconnected", agentId: "local", agentName: "Local Agent" });
|
||||||
type: "agent.disconnected",
|
|
||||||
agentId: "local",
|
|
||||||
agentName: "Local Agent",
|
|
||||||
});
|
|
||||||
controllerMock.onEvent?.({
|
controllerMock.onEvent?.({
|
||||||
type: "backup.completed",
|
type: "backup.completed",
|
||||||
agentId: "remote",
|
agentId: "remote",
|
||||||
|
|
@ -291,117 +230,6 @@ test("runBackup requests cancellation when the abort signal fires while sending"
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ status: "cancelled" });
|
expect(result).toEqual({ status: "cancelled" });
|
||||||
expect(controllerMock.cancelBackup).toHaveBeenCalledWith("local", {
|
expect(controllerMock.cancelBackup).toHaveBeenCalledWith("local", { jobId: "job-1", scheduleId: "schedule-1" });
|
||||||
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();
|
await stopAgentController();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { Effect } from "effect";
|
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { fromAny } from "@total-typescript/shoehorn";
|
|
||||||
import type { ProcessWithAgentRuntime } from "../helpers/runtime-state.dev";
|
import type { ProcessWithAgentRuntime } from "../helpers/runtime-state.dev";
|
||||||
|
|
||||||
const spawnMock = vi.fn();
|
const spawnMock = vi.fn();
|
||||||
|
|
@ -12,21 +10,13 @@ vi.mock("node:child_process", async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
let startLocalAgent: (typeof import("../agents-manager"))["startLocalAgent"];
|
let startLocalAgent: (typeof import("../agents-manager"))["startLocalAgent"];
|
||||||
let startAgentController: (typeof import("../agents-manager"))["startAgentController"];
|
|
||||||
let stopLocalAgent: (typeof import("../agents-manager"))["stopLocalAgent"];
|
let stopLocalAgent: (typeof import("../agents-manager"))["stopLocalAgent"];
|
||||||
let stopAgentController: (typeof import("../agents-manager"))["stopAgentController"];
|
|
||||||
let config: (typeof import("~/server/core/config"))["config"];
|
|
||||||
let originalEnableLocalAgent: boolean;
|
|
||||||
|
|
||||||
const processWithAgentRuntime = process as ProcessWithAgentRuntime;
|
const processWithAgentRuntime = process as ProcessWithAgentRuntime;
|
||||||
|
|
||||||
const setAgentRuntime = () => {
|
const setAgentRuntime = () => {
|
||||||
processWithAgentRuntime.__zerobyteAgentRuntime = {
|
processWithAgentRuntime.__zerobyteAgentRuntime = {
|
||||||
agentManager: fromAny({
|
agentManager: null,
|
||||||
stop: Effect.void,
|
|
||||||
getControllerUrl: vi.fn(() => "ws://127.0.0.1:4567"),
|
|
||||||
waitForAgentReady: vi.fn(async () => true),
|
|
||||||
}),
|
|
||||||
localAgent: null,
|
localAgent: null,
|
||||||
isStoppingLocalAgent: false,
|
isStoppingLocalAgent: false,
|
||||||
localAgentRestartTimeout: null,
|
localAgentRestartTimeout: null,
|
||||||
|
|
@ -59,18 +49,12 @@ const createFakeChild = () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
({ config } = await import("~/server/core/config"));
|
|
||||||
originalEnableLocalAgent = config.flags.enableLocalAgent;
|
|
||||||
config.flags.enableLocalAgent = true;
|
|
||||||
setAgentRuntime();
|
setAgentRuntime();
|
||||||
({ startAgentController, startLocalAgent, stopAgentController, stopLocalAgent } =
|
({ startLocalAgent, stopLocalAgent } = await import("../agents-manager"));
|
||||||
await import("../agents-manager"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await stopLocalAgent();
|
await stopLocalAgent();
|
||||||
await stopAgentController();
|
|
||||||
config.flags.enableLocalAgent = originalEnableLocalAgent;
|
|
||||||
delete processWithAgentRuntime.__zerobyteAgentRuntime;
|
delete processWithAgentRuntime.__zerobyteAgentRuntime;
|
||||||
spawnMock.mockReset();
|
spawnMock.mockReset();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
|
@ -92,13 +76,6 @@ test("respawns the local agent after an unexpected exit", async () => {
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
|
||||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
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 () => {
|
test("does not respawn the local agent after an intentional stop", async () => {
|
||||||
|
|
@ -115,13 +92,3 @@ test("does not respawn the local agent after an intentional stop", async () => {
|
||||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||||
expect(child.kill).toHaveBeenCalledTimes(1);
|
expect(child.kill).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not start the websocket server when the local agent flag is disabled", async () => {
|
|
||||||
config.flags.enableLocalAgent = false;
|
|
||||||
const serve = vi.spyOn(Bun, "serve");
|
|
||||||
|
|
||||||
await startAgentController();
|
|
||||||
|
|
||||||
expect(serve).not.toHaveBeenCalled();
|
|
||||||
expect(processWithAgentRuntime.__zerobyteAgentRuntime?.agentManager).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||||
import waitForExpect from "wait-for-expect";
|
import waitForExpect from "wait-for-expect";
|
||||||
import { fromPartial } from "@total-typescript/shoehorn";
|
import { fromPartial } from "@total-typescript/shoehorn";
|
||||||
import { createAgentMessage } from "@zerobyte/contracts/agent-protocol";
|
import { createAgentMessage } 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 { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
|
||||||
|
|
||||||
const agentsServiceMocks = vi.hoisted(() => ({
|
const agentsServiceMocks = vi.hoisted(() => ({
|
||||||
|
|
@ -37,51 +36,6 @@ const createSocket = (id: string, agentId = LOCAL_AGENT_ID) => ({
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const backupVolume = {
|
|
||||||
id: 1,
|
|
||||||
shortId: "volume-1",
|
|
||||||
name: "Volume 1",
|
|
||||||
config: { backend: "directory", path: "/tmp" },
|
|
||||||
createdAt: 0,
|
|
||||||
updatedAt: 0,
|
|
||||||
lastHealthCheck: 0,
|
|
||||||
type: "directory",
|
|
||||||
status: "mounted" as const,
|
|
||||||
lastError: null,
|
|
||||||
autoRemount: true,
|
|
||||||
agentId: LOCAL_AGENT_ID,
|
|
||||||
organizationId: "org-1",
|
|
||||||
} satisfies Volume;
|
|
||||||
|
|
||||||
const readyPayload = {
|
|
||||||
agentId: LOCAL_AGENT_ID,
|
|
||||||
protocolVersion: 1,
|
|
||||||
hostname: "host",
|
|
||||||
platform: "linux",
|
|
||||||
capabilities: { backup: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
const backupPayload = {
|
|
||||||
jobId: "job-1",
|
|
||||||
scheduleId: "schedule-1",
|
|
||||||
organizationId: "org-1",
|
|
||||||
volume: backupVolume,
|
|
||||||
repositoryConfig: { backend: "local" as const, path: "/tmp/repository" },
|
|
||||||
options: {
|
|
||||||
oneFileSystem: false,
|
|
||||||
excludePatterns: null,
|
|
||||||
excludeIfPresent: null,
|
|
||||||
includePaths: null,
|
|
||||||
includePatterns: null,
|
|
||||||
customResticParams: null,
|
|
||||||
compressionMode: "auto" as const,
|
|
||||||
},
|
|
||||||
runtime: { password: "password" },
|
|
||||||
webhooks: { pre: null, post: null },
|
|
||||||
webhookAllowedOrigins: [],
|
|
||||||
webhookTimeoutMs: 60_000,
|
|
||||||
};
|
|
||||||
|
|
||||||
type CapturedFetch = NonNullable<Parameters<typeof Bun.serve>[0]["fetch"]>;
|
type CapturedFetch = NonNullable<Parameters<typeof Bun.serve>[0]["fetch"]>;
|
||||||
|
|
||||||
const invokeFetch = (fetch: CapturedFetch | undefined, request: Request, srv: Parameters<CapturedFetch>[1]) => {
|
const invokeFetch = (fetch: CapturedFetch | undefined, request: Request, srv: Parameters<CapturedFetch>[1]) => {
|
||||||
|
|
@ -111,7 +65,7 @@ afterEach(() => {
|
||||||
test("websocket fetch rejects requests without a bearer token", async () => {
|
test("websocket fetch rejects requests without a bearer token", async () => {
|
||||||
const serve = vi
|
const serve = vi
|
||||||
.spyOn(Bun, "serve")
|
.spyOn(Bun, "serve")
|
||||||
.mockReturnValue(fromPartial({ port: 4567, stop: vi.fn(() => Promise.resolve()) }));
|
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
|
||||||
const { runtime } = await startRuntime();
|
const { runtime } = await startRuntime();
|
||||||
const fetch = serve.mock.calls[0]?.[0].fetch;
|
const fetch = serve.mock.calls[0]?.[0].fetch;
|
||||||
const upgrade = vi.fn();
|
const upgrade = vi.fn();
|
||||||
|
|
@ -120,7 +74,6 @@ test("websocket fetch rejects requests without a bearer token", async () => {
|
||||||
const response = await invokeFetch(fetch, new Request("http://localhost:3001/agent"), srv);
|
const response = await invokeFetch(fetch, new Request("http://localhost:3001/agent"), srv);
|
||||||
await Effect.runPromise(runtime.stop);
|
await Effect.runPromise(runtime.stop);
|
||||||
|
|
||||||
expect(runtime.getControllerUrl()).toBeNull();
|
|
||||||
expect(response?.status).toBe(401);
|
expect(response?.status).toBe(401);
|
||||||
expect(await response?.text()).toBe("Missing token");
|
expect(await response?.text()).toBe("Missing token");
|
||||||
expect(upgrade).not.toHaveBeenCalled();
|
expect(upgrade).not.toHaveBeenCalled();
|
||||||
|
|
@ -130,9 +83,8 @@ test("websocket fetch rejects invalid bearer tokens", async () => {
|
||||||
tokenMocks.validateAgentToken.mockResolvedValue(undefined);
|
tokenMocks.validateAgentToken.mockResolvedValue(undefined);
|
||||||
const serve = vi
|
const serve = vi
|
||||||
.spyOn(Bun, "serve")
|
.spyOn(Bun, "serve")
|
||||||
.mockReturnValue(fromPartial({ port: 4567, stop: vi.fn(() => Promise.resolve()) }));
|
.mockReturnValue(fromPartial({ port: 3001, stop: vi.fn(() => Promise.resolve()) }));
|
||||||
const { runtime } = await startRuntime();
|
const { runtime } = await startRuntime();
|
||||||
expect(runtime.getControllerUrl()).toBe("ws://127.0.0.1:4567");
|
|
||||||
const fetch = serve.mock.calls[0]?.[0].fetch;
|
const fetch = serve.mock.calls[0]?.[0].fetch;
|
||||||
const upgrade = vi.fn();
|
const upgrade = vi.fn();
|
||||||
const srv = fromPartial<Parameters<NonNullable<typeof fetch>>[1]>({ upgrade });
|
const srv = fromPartial<Parameters<NonNullable<typeof fetch>>[1]>({ upgrade });
|
||||||
|
|
@ -193,7 +145,7 @@ test("websocket lifecycle updates agent connection status", async () => {
|
||||||
const socket = createSocket("connection-1");
|
const socket = createSocket("connection-1");
|
||||||
|
|
||||||
await websocket?.open?.(fromPartial(socket));
|
await websocket?.open?.(fromPartial(socket));
|
||||||
await websocket?.message?.(fromPartial(socket), createAgentMessage("agent.ready", readyPayload));
|
await websocket?.message?.(fromPartial(socket), createAgentMessage("agent.ready", { agentId: LOCAL_AGENT_ID }));
|
||||||
await websocket?.message?.(fromPartial(socket), createAgentMessage("heartbeat.pong", { sentAt: 123 }));
|
await websocket?.message?.(fromPartial(socket), createAgentMessage("heartbeat.pong", { sentAt: 123 }));
|
||||||
await websocket?.close?.(fromPartial(socket), 1000, "done");
|
await websocket?.close?.(fromPartial(socket), 1000, "done");
|
||||||
await Effect.runPromise(runtime.stop);
|
await Effect.runPromise(runtime.stop);
|
||||||
|
|
@ -204,85 +156,12 @@ test("websocket lifecycle updates agent connection status", async () => {
|
||||||
agentName: LOCAL_AGENT_NAME,
|
agentName: LOCAL_AGENT_NAME,
|
||||||
agentKind: LOCAL_AGENT_KIND,
|
agentKind: LOCAL_AGENT_KIND,
|
||||||
});
|
});
|
||||||
expect(agentsServiceMocks.markAgentOnline).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number), {
|
expect(agentsServiceMocks.markAgentOnline).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number));
|
||||||
backup: true,
|
|
||||||
protocolVersion: 1,
|
|
||||||
protocolCompatible: true,
|
|
||||||
hostname: "host",
|
|
||||||
platform: "linux",
|
|
||||||
});
|
|
||||||
expect(agentsServiceMocks.markAgentSeen).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number));
|
expect(agentsServiceMocks.markAgentSeen).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number));
|
||||||
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledWith(LOCAL_AGENT_ID);
|
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledWith(LOCAL_AGENT_ID);
|
||||||
expect(stop).toHaveBeenCalledWith(true);
|
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 () => {
|
test("websocket open failure closes the upgraded socket", async () => {
|
||||||
agentsServiceMocks.markAgentConnecting.mockRejectedValueOnce(new Error("db unavailable"));
|
agentsServiceMocks.markAgentConnecting.mockRejectedValueOnce(new Error("db unavailable"));
|
||||||
const serve = vi
|
const serve = vi
|
||||||
|
|
@ -330,14 +209,35 @@ test("closing a replaced connection reports disconnect without marking the activ
|
||||||
|
|
||||||
await websocket?.open?.(fromPartial(oldSocket));
|
await websocket?.open?.(fromPartial(oldSocket));
|
||||||
await websocket?.open?.(fromPartial(newSocket));
|
await websocket?.open?.(fromPartial(newSocket));
|
||||||
await websocket?.message?.(fromPartial(newSocket), createAgentMessage("agent.ready", readyPayload));
|
await websocket?.message?.(fromPartial(newSocket), createAgentMessage("agent.ready", { agentId: LOCAL_AGENT_ID }));
|
||||||
await websocket?.close?.(fromPartial(oldSocket), 1000, "replaced");
|
await websocket?.close?.(fromPartial(oldSocket), 1000, "replaced");
|
||||||
|
|
||||||
expect(onEvent).toHaveBeenCalledWith(
|
expect(onEvent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ type: "agent.disconnected", agentId: LOCAL_AGENT_ID }),
|
expect.objectContaining({ type: "agent.disconnected", agentId: LOCAL_AGENT_ID }),
|
||||||
);
|
);
|
||||||
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledTimes(offlineCallsBeforeClose);
|
expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledTimes(offlineCallsBeforeClose);
|
||||||
expect(await Effect.runPromise(runtime.sendBackup(LOCAL_AGENT_ID, backupPayload))).toBe(true);
|
expect(
|
||||||
|
await Effect.runPromise(
|
||||||
|
runtime.sendBackup(LOCAL_AGENT_ID, {
|
||||||
|
jobId: "job-1",
|
||||||
|
scheduleId: "schedule-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
sourcePath: "/tmp/source",
|
||||||
|
repositoryConfig: { backend: "local" as const, path: "/tmp/repository" },
|
||||||
|
options: {},
|
||||||
|
runtime: {
|
||||||
|
password: "password",
|
||||||
|
cacheDir: "/tmp/cache",
|
||||||
|
passFile: "/tmp/pass",
|
||||||
|
defaultExcludes: [],
|
||||||
|
rcloneConfigFile: "/tmp/rclone.conf",
|
||||||
|
},
|
||||||
|
webhooks: { pre: null, post: null },
|
||||||
|
webhookAllowedOrigins: [],
|
||||||
|
webhookTimeoutMs: 60_000,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
await Effect.runPromise(runtime.stop);
|
await Effect.runPromise(runtime.stop);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -348,12 +248,29 @@ test("sendBackup is only delivered after the agent is ready", async () => {
|
||||||
const { runtime } = await startRuntime();
|
const { runtime } = await startRuntime();
|
||||||
const websocket = serve.mock.calls[0]?.[0].websocket;
|
const websocket = serve.mock.calls[0]?.[0].websocket;
|
||||||
const socket = createSocket("connection-1");
|
const socket = createSocket("connection-1");
|
||||||
const payload = backupPayload;
|
const payload = {
|
||||||
|
jobId: "job-1",
|
||||||
|
scheduleId: "schedule-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
sourcePath: "/tmp/source",
|
||||||
|
repositoryConfig: { backend: "local" as const, path: "/tmp/repository" },
|
||||||
|
options: {},
|
||||||
|
runtime: {
|
||||||
|
password: "password",
|
||||||
|
cacheDir: "/tmp/cache",
|
||||||
|
passFile: "/tmp/pass",
|
||||||
|
defaultExcludes: [],
|
||||||
|
rcloneConfigFile: "/tmp/rclone.conf",
|
||||||
|
},
|
||||||
|
webhooks: { pre: null, post: null },
|
||||||
|
webhookAllowedOrigins: [],
|
||||||
|
webhookTimeoutMs: 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
await websocket?.open?.(fromPartial(socket));
|
await websocket?.open?.(fromPartial(socket));
|
||||||
await expect(Effect.runPromise(runtime.sendBackup(LOCAL_AGENT_ID, payload))).resolves.toBe(false);
|
await expect(Effect.runPromise(runtime.sendBackup(LOCAL_AGENT_ID, payload))).resolves.toBe(false);
|
||||||
|
|
||||||
await websocket?.message?.(fromPartial(socket), createAgentMessage("agent.ready", readyPayload));
|
await websocket?.message?.(fromPartial(socket), createAgentMessage("agent.ready", { agentId: LOCAL_AGENT_ID }));
|
||||||
await expect(Effect.runPromise(runtime.sendBackup(LOCAL_AGENT_ID, payload))).resolves.toBe(true);
|
await expect(Effect.runPromise(runtime.sendBackup(LOCAL_AGENT_ID, payload))).resolves.toBe(true);
|
||||||
|
|
||||||
await waitForExpect(() => {
|
await waitForExpect(() => {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,7 @@ import { Effect, Exit, Fiber, Scope } from "effect";
|
||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import waitForExpect from "wait-for-expect";
|
import waitForExpect from "wait-for-expect";
|
||||||
import { fromPartial } from "@total-typescript/shoehorn";
|
import { fromPartial } from "@total-typescript/shoehorn";
|
||||||
import {
|
import { createAgentMessage, type AgentMessage } from "@zerobyte/contracts/agent-protocol";
|
||||||
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 { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants";
|
||||||
import { createControllerAgentSession } from "../controller/session";
|
import { createControllerAgentSession } from "../controller/session";
|
||||||
|
|
||||||
|
|
@ -56,22 +51,6 @@ const createSession = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const backupVolume = {
|
|
||||||
id: 1,
|
|
||||||
shortId: "volume-1",
|
|
||||||
name: "Volume 1",
|
|
||||||
config: { backend: "directory", path: "/tmp" },
|
|
||||||
createdAt: 0,
|
|
||||||
updatedAt: 0,
|
|
||||||
lastHealthCheck: 0,
|
|
||||||
type: "directory",
|
|
||||||
status: "mounted" as const,
|
|
||||||
lastError: null,
|
|
||||||
autoRemount: true,
|
|
||||||
agentId: LOCAL_AGENT_ID,
|
|
||||||
organizationId: "org-1",
|
|
||||||
} satisfies Volume;
|
|
||||||
|
|
||||||
test("closing the session scope interrupts the session runner", async () => {
|
test("closing the session scope interrupts the session runner", async () => {
|
||||||
const { run, closeAsync } = createSession();
|
const { run, closeAsync } = createSession();
|
||||||
const fiber = run();
|
const fiber = run();
|
||||||
|
|
@ -105,22 +84,18 @@ test("sendBackup only queues the transport message", () => {
|
||||||
jobId: "job-queued",
|
jobId: "job-queued",
|
||||||
scheduleId: "schedule-queued",
|
scheduleId: "schedule-queued",
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
volume: backupVolume,
|
sourcePath: "/tmp/source",
|
||||||
repositoryConfig: {
|
repositoryConfig: {
|
||||||
backend: "local",
|
backend: "local",
|
||||||
path: "/tmp/repository",
|
path: "/tmp/repository",
|
||||||
},
|
},
|
||||||
options: {
|
options: {},
|
||||||
oneFileSystem: false,
|
|
||||||
excludePatterns: null,
|
|
||||||
excludeIfPresent: null,
|
|
||||||
includePaths: null,
|
|
||||||
includePatterns: null,
|
|
||||||
customResticParams: null,
|
|
||||||
compressionMode: "auto",
|
|
||||||
},
|
|
||||||
runtime: {
|
runtime: {
|
||||||
password: "password",
|
password: "password",
|
||||||
|
cacheDir: "/tmp/cache",
|
||||||
|
passFile: "/tmp/pass",
|
||||||
|
defaultExcludes: [],
|
||||||
|
rcloneConfigFile: "/tmp/rclone.conf",
|
||||||
},
|
},
|
||||||
webhooks: { pre: null, post: null },
|
webhooks: { pre: null, post: null },
|
||||||
webhookAllowedOrigins: [],
|
webhookAllowedOrigins: [],
|
||||||
|
|
@ -137,19 +112,6 @@ test("invalid inbound messages are ignored", () => {
|
||||||
const onEvent = vi.fn(() => Effect.void);
|
const onEvent = vi.fn(() => Effect.void);
|
||||||
const { session, close } = createSession(onEvent);
|
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("not json"));
|
||||||
Effect.runSync(session.handleMessage(JSON.stringify({ type: "backup.progress", payload: {} })));
|
Effect.runSync(session.handleMessage(JSON.stringify({ type: "backup.progress", payload: {} })));
|
||||||
|
|
||||||
|
|
@ -162,29 +124,10 @@ test("agent.ready marks the session ready and forwards the event", () => {
|
||||||
const { session, close } = createSession(onEvent);
|
const { session, close } = createSession(onEvent);
|
||||||
|
|
||||||
expect(Effect.runSync(session.isReady())).toBe(false);
|
expect(Effect.runSync(session.isReady())).toBe(false);
|
||||||
Effect.runSync(
|
Effect.runSync(session.handleMessage(createAgentMessage("agent.ready", { agentId: LOCAL_AGENT_ID })));
|
||||||
session.handleMessage(
|
|
||||||
createAgentMessage("agent.ready", {
|
|
||||||
agentId: LOCAL_AGENT_ID,
|
|
||||||
protocolVersion: 1,
|
|
||||||
hostname: "host",
|
|
||||||
platform: "linux",
|
|
||||||
capabilities: { backup: true },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(Effect.runSync(session.isReady())).toBe(true);
|
expect(Effect.runSync(session.isReady())).toBe(true);
|
||||||
expect(onEvent).toHaveBeenCalledWith({
|
expect(onEvent).toHaveBeenCalledWith({ type: "agent.ready", payload: { agentId: LOCAL_AGENT_ID } });
|
||||||
type: "agent.ready",
|
|
||||||
payload: {
|
|
||||||
agentId: LOCAL_AGENT_ID,
|
|
||||||
protocolVersion: 1,
|
|
||||||
hostname: "host",
|
|
||||||
platform: "linux",
|
|
||||||
capabilities: { backup: true },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -210,19 +153,6 @@ test("backup agent messages are forwarded unchanged", () => {
|
||||||
},
|
},
|
||||||
} satisfies Extract<AgentMessage, { type: "backup.progress" }>;
|
} 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)));
|
Effect.runSync(session.handleMessage(createAgentMessage(message.type, message.payload)));
|
||||||
|
|
||||||
expect(onEvent).toHaveBeenCalledWith(
|
expect(onEvent).toHaveBeenCalledWith(
|
||||||
|
|
@ -238,53 +168,6 @@ test("backup agent messages are forwarded unchanged", () => {
|
||||||
close();
|
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 () => {
|
test("a dropped backup.cancel closes the session and reports a transport disconnect", async () => {
|
||||||
const send = vi.fn(() => 0);
|
const send = vi.fn(() => 0);
|
||||||
const socket = createSocket({ send, close: vi.fn() });
|
const socket = createSocket({ send, close: vi.fn() });
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import type {
|
import type { BackupRunPayload, VolumeCommand, VolumeCommandResult } from "@zerobyte/contracts/agent-protocol";
|
||||||
BackupRunPayload,
|
|
||||||
RestoreRunPayload,
|
|
||||||
VolumeCommand,
|
|
||||||
VolumeCommandResult,
|
|
||||||
} from "@zerobyte/contracts/agent-protocol";
|
|
||||||
import { Effect } from "effect";
|
import { Effect } from "effect";
|
||||||
import { config } from "../../core/config";
|
import { config } from "../../core/config";
|
||||||
import { createAgentManagerRuntime, type AgentManagerEvent } from "./controller/server";
|
import { createAgentManagerRuntime, type AgentManagerEvent } from "./controller/server";
|
||||||
|
|
@ -15,16 +10,9 @@ import {
|
||||||
type AgentRuntimeState,
|
type AgentRuntimeState,
|
||||||
type BackupExecutionProgress,
|
type BackupExecutionProgress,
|
||||||
type BackupExecutionResult,
|
type BackupExecutionResult,
|
||||||
type RestoreExecutionProgress,
|
|
||||||
type RestoreExecutionResult,
|
|
||||||
} from "./helpers/runtime-state";
|
} from "./helpers/runtime-state";
|
||||||
import { getDevAgentRuntimeState } from "./helpers/runtime-state.dev";
|
import { getDevAgentRuntimeState } from "./helpers/runtime-state.dev";
|
||||||
export type {
|
export type { BackupExecutionProgress, BackupExecutionResult } from "./helpers/runtime-state";
|
||||||
BackupExecutionProgress,
|
|
||||||
BackupExecutionResult,
|
|
||||||
RestoreExecutionProgress,
|
|
||||||
RestoreExecutionResult,
|
|
||||||
} from "./helpers/runtime-state";
|
|
||||||
export type { ProcessWithAgentRuntime } from "./helpers/runtime-state.dev";
|
export type { ProcessWithAgentRuntime } from "./helpers/runtime-state.dev";
|
||||||
|
|
||||||
type ProcessWithProductionAgentRuntime = NodeJS.Process & {
|
type ProcessWithProductionAgentRuntime = NodeJS.Process & {
|
||||||
|
|
@ -38,17 +26,6 @@ type AgentRunBackupRequest = {
|
||||||
onProgress: (progress: BackupExecutionProgress) => void;
|
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 = () => {
|
const getProductionAgentRuntimeState = () => {
|
||||||
// Nitro production builds can bundle startup plugins and API handlers into separate chunks.
|
// 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.
|
// Keep the live controller on process so both chunks see the same agent sessions.
|
||||||
|
|
@ -64,7 +41,6 @@ const getAgentRuntimeState = () => (config.__prod__ ? getProductionAgentRuntimeS
|
||||||
const getAgentManagerRuntime = () => getAgentRuntimeState().agentManager;
|
const getAgentManagerRuntime = () => getAgentRuntimeState().agentManager;
|
||||||
const getActiveBackupsByScheduleId = () => getAgentRuntimeState().activeBackupsByScheduleId;
|
const getActiveBackupsByScheduleId = () => getAgentRuntimeState().activeBackupsByScheduleId;
|
||||||
const getActiveBackupScheduleIdsByJobId = () => getAgentRuntimeState().activeBackupScheduleIdsByJobId;
|
const getActiveBackupScheduleIdsByJobId = () => getAgentRuntimeState().activeBackupScheduleIdsByJobId;
|
||||||
const getActiveRestoresByRestoreId = () => getAgentRuntimeState().activeRestoresByRestoreId;
|
|
||||||
|
|
||||||
const clearActiveBackupRun = (scheduleId: number) => {
|
const clearActiveBackupRun = (scheduleId: number) => {
|
||||||
const activeBackupsByScheduleId = getActiveBackupsByScheduleId();
|
const activeBackupsByScheduleId = getActiveBackupsByScheduleId();
|
||||||
|
|
@ -89,27 +65,6 @@ const resolveActiveBackupRun = (scheduleId: number, result: BackupExecutionResul
|
||||||
return true;
|
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 cancelActiveBackupRunsForAgent = (agentId: string, message: string) => {
|
||||||
const activeBackupsByScheduleId = getActiveBackupsByScheduleId();
|
const activeBackupsByScheduleId = getActiveBackupsByScheduleId();
|
||||||
const matchingScheduleIds = [...activeBackupsByScheduleId.values()]
|
const matchingScheduleIds = [...activeBackupsByScheduleId.values()]
|
||||||
|
|
@ -121,17 +76,6 @@ 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 getActiveBackupRun = (jobId: string, scheduleId: string, eventName: string, agentId: string) => {
|
||||||
const trackedScheduleId = getActiveBackupScheduleIdsByJobId().get(jobId);
|
const trackedScheduleId = getActiveBackupScheduleIdsByJobId().get(jobId);
|
||||||
if (trackedScheduleId === undefined) {
|
if (trackedScheduleId === undefined) {
|
||||||
|
|
@ -146,35 +90,13 @@ const getActiveBackupRun = (jobId: string, scheduleId: string, eventName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeBackupRun.scheduleShortId !== scheduleId) {
|
if (activeBackupRun.scheduleShortId !== scheduleId) {
|
||||||
logger.warn(
|
logger.warn(`Ignoring ${eventName} for job ${jobId} due to schedule mismatch ${scheduleId} from agent ${agentId}`);
|
||||||
`Ignoring ${eventName} for job ${jobId} due to schedule mismatch ${scheduleId} from agent ${agentId}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeBackupRun.agentId !== agentId) {
|
|
||||||
logger.warn(`Ignoring ${eventName} for job ${jobId} from unexpected agent ${agentId}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return activeBackupRun;
|
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 requestBackupCancellation = async (agentId: string, scheduleId: number) => {
|
||||||
const activeBackupRun = getActiveBackupsByScheduleId().get(scheduleId);
|
const activeBackupRun = getActiveBackupsByScheduleId().get(scheduleId);
|
||||||
if (!activeBackupRun) {
|
if (!activeBackupRun) {
|
||||||
|
|
@ -208,32 +130,6 @@ const requestBackupCancellation = async (agentId: string, scheduleId: number) =>
|
||||||
return true;
|
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) => {
|
const handleAgentManagerEvent = (event: AgentManagerEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "agent.disconnected": {
|
case "agent.disconnected": {
|
||||||
|
|
@ -241,14 +137,6 @@ const handleAgentManagerEvent = (event: AgentManagerEvent) => {
|
||||||
event.agentId,
|
event.agentId,
|
||||||
"The connection to the backup agent was lost. Restart the backup to ensure it completes.",
|
"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;
|
break;
|
||||||
}
|
}
|
||||||
case "backup.started": {
|
case "backup.started": {
|
||||||
|
|
@ -322,60 +210,6 @@ const handleAgentManagerEvent = (event: AgentManagerEvent) => {
|
||||||
});
|
});
|
||||||
break;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -387,10 +221,6 @@ export const startAgentController = async () => {
|
||||||
runtime.agentManager = null;
|
runtime.agentManager = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!config.flags.enableLocalAgent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextAgentManager = createAgentManagerRuntime(handleAgentManagerEvent);
|
const nextAgentManager = createAgentManagerRuntime(handleAgentManagerEvent);
|
||||||
await Effect.runPromise(nextAgentManager.start);
|
await Effect.runPromise(nextAgentManager.start);
|
||||||
runtime.agentManager = nextAgentManager;
|
runtime.agentManager = nextAgentManager;
|
||||||
|
|
@ -471,70 +301,16 @@ export const agentManager = {
|
||||||
|
|
||||||
return response.command;
|
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 () => {
|
export const startLocalAgent = async () => {
|
||||||
const runtime = getAgentRuntimeState();
|
const runtime = getAgentRuntimeState();
|
||||||
|
await spawnLocalAgentProcess(runtime);
|
||||||
|
|
||||||
if (!runtime.agentManager) {
|
if (!runtime.agentManager) {
|
||||||
throw new Error(
|
return;
|
||||||
`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))) {
|
if (!(await runtime.agentManager.waitForAgentReady(LOCAL_AGENT_ID))) {
|
||||||
throw new Error("Local agent did not become ready before startup");
|
throw new Error("Local agent did not become ready before startup");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,10 +95,9 @@ const updateAgentRuntime = async (agentId: string, values: Partial<Agent>) => {
|
||||||
return updatedAgent;
|
return updatedAgent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAgentOnline = async (agentId: string, readyAt = Date.now(), metadata?: AgentCapabilities) => {
|
const markAgentOnline = async (agentId: string, readyAt = Date.now()) => {
|
||||||
return updateAgentRuntime(agentId, {
|
return updateAgentRuntime(agentId, {
|
||||||
status: "online",
|
status: "online",
|
||||||
capabilities: metadata,
|
|
||||||
lastSeenAt: readyAt,
|
lastSeenAt: readyAt,
|
||||||
lastReadyAt: readyAt,
|
lastReadyAt: readyAt,
|
||||||
updatedAt: readyAt,
|
updatedAt: readyAt,
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,8 @@ import { logger } from "@zerobyte/core/node";
|
||||||
import { toMessage } from "@zerobyte/core/utils";
|
import { toMessage } from "@zerobyte/core/utils";
|
||||||
import type {
|
import type {
|
||||||
AgentMessage,
|
AgentMessage,
|
||||||
AgentProtocolRejection,
|
|
||||||
BackupCancelPayload,
|
BackupCancelPayload,
|
||||||
BackupRunPayload,
|
BackupRunPayload,
|
||||||
RestoreCancelPayload,
|
|
||||||
RestoreRunPayload,
|
|
||||||
VolumeCommand,
|
VolumeCommand,
|
||||||
VolumeCommandResponsePayload,
|
VolumeCommandResponsePayload,
|
||||||
} from "@zerobyte/contracts/agent-protocol";
|
} from "@zerobyte/contracts/agent-protocol";
|
||||||
|
|
@ -25,10 +22,16 @@ type AgentEventContext = {
|
||||||
agentName: string;
|
agentName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AgentBackupMessage = Extract<
|
||||||
|
AgentMessage,
|
||||||
|
{
|
||||||
|
type: "backup.started" | "backup.progress" | "backup.completed" | "backup.failed" | "backup.cancelled";
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
export type AgentManagerEvent =
|
export type AgentManagerEvent =
|
||||||
| (AgentEventContext & { type: "agent.disconnected" })
|
| (AgentEventContext & { type: "agent.disconnected" })
|
||||||
| (AgentEventContext & { type: "agent.protocolRejected"; payload: AgentProtocolRejection })
|
| (AgentEventContext & AgentBackupMessage);
|
||||||
| (AgentEventContext & AgentMessage);
|
|
||||||
|
|
||||||
type ControllerAgentSessionHandle = {
|
type ControllerAgentSessionHandle = {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
|
@ -43,7 +46,6 @@ class StopAgentManagerServerError extends Data.TaggedError("StopAgentManagerServ
|
||||||
export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) => void) {
|
export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) => void) {
|
||||||
let sessions = new Map<string, ControllerAgentSessionHandle>();
|
let sessions = new Map<string, ControllerAgentSessionHandle>();
|
||||||
let runtimeScope: Scope.CloseableScope | null = null;
|
let runtimeScope: Scope.CloseableScope | null = null;
|
||||||
let controllerUrl: string | null = null;
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
const closeSession = (sessionHandle: ControllerAgentSessionHandle) =>
|
const closeSession = (sessionHandle: ControllerAgentSessionHandle) =>
|
||||||
|
|
@ -83,7 +85,7 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
|
||||||
return !!session && Effect.runSync(session.isReady());
|
return !!session && Effect.runSync(session.isReady());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSessionEvent = (params: { agentId: string; agentName: string }) => {
|
const handleSessionEvent = (params: { agentId: string; agentName: string; sessionId: string }) => {
|
||||||
const { agentId, agentName } = params;
|
const { agentId, agentName } = params;
|
||||||
|
|
||||||
return (event: ControllerAgentSessionEvent) => {
|
return (event: ControllerAgentSessionEvent) => {
|
||||||
|
|
@ -91,20 +93,9 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
|
||||||
case "agent.ready": {
|
case "agent.ready": {
|
||||||
const at = Date.now();
|
const at = Date.now();
|
||||||
return Effect.promise(async () => {
|
return Effect.promise(async () => {
|
||||||
await agentsService.markAgentOnline(agentId, at, {
|
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": {
|
case "heartbeat.pong": {
|
||||||
const at = Date.now();
|
const at = Date.now();
|
||||||
return Effect.promise(() => agentsService.markAgentSeen(agentId, at));
|
return Effect.promise(() => agentsService.markAgentSeen(agentId, at));
|
||||||
|
|
@ -129,6 +120,7 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
|
||||||
handleSessionEvent({
|
handleSessionEvent({
|
||||||
agentId: ws.data.agentId,
|
agentId: ws.data.agentId,
|
||||||
agentName: ws.data.agentName,
|
agentName: ws.data.agentName,
|
||||||
|
sessionId: ws.data.id,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
scope,
|
scope,
|
||||||
|
|
@ -218,8 +210,7 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
|
||||||
const acquireServer = Effect.acquireRelease(
|
const acquireServer = Effect.acquireRelease(
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
Bun.serve<AgentConnectionData>({
|
Bun.serve<AgentConnectionData>({
|
||||||
hostname: "127.0.0.1",
|
port: 3001,
|
||||||
port: 0,
|
|
||||||
async fetch(req, srv) {
|
async fetch(req, srv) {
|
||||||
const authorizationHeader = req.headers.get("authorization");
|
const authorizationHeader = req.headers.get("authorization");
|
||||||
const token = authorizationHeader?.slice("Bearer ".length);
|
const token = authorizationHeader?.slice("Bearer ".length);
|
||||||
|
|
@ -283,7 +274,6 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
|
||||||
logger.info("Stopping Agent Manager...");
|
logger.info("Stopping Agent Manager...");
|
||||||
const scope = runtimeScope;
|
const scope = runtimeScope;
|
||||||
runtimeScope = null;
|
runtimeScope = null;
|
||||||
controllerUrl = null;
|
|
||||||
yield* Scope.close(scope, Exit.succeed(undefined));
|
yield* Scope.close(scope, Exit.succeed(undefined));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -301,13 +291,11 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
runtimeScope = scope;
|
runtimeScope = scope;
|
||||||
controllerUrl = `ws://127.0.0.1:${server.port}`;
|
|
||||||
logger.info(`Agent Manager listening on port ${server.port}`);
|
logger.info(`Agent Manager listening on port ${server.port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
start,
|
start,
|
||||||
getControllerUrl: () => controllerUrl,
|
|
||||||
waitForAgentReady: async (agentId: string, timeoutMs = 10_000) => {
|
waitForAgentReady: async (agentId: string, timeoutMs = 10_000) => {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
|
@ -340,9 +328,7 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(`Sent backup command ${payload.jobId} to agent ${agentId} for schedule ${payload.scheduleId}`);
|
||||||
`Sent backup command ${payload.jobId} to agent ${agentId} for schedule ${payload.scheduleId}`,
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cancelBackup: (agentId: string, payload: BackupCancelPayload) =>
|
cancelBackup: (agentId: string, payload: BackupCancelPayload) =>
|
||||||
|
|
@ -361,47 +347,6 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
|
||||||
logger.info(`Sent backup cancel for command ${payload.jobId} to agent ${agentId}`);
|
logger.info(`Sent backup cancel for command ${payload.jobId} to agent ${agentId}`);
|
||||||
return true;
|
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: (
|
runVolumeCommand: (
|
||||||
agentId: string,
|
agentId: string,
|
||||||
command: VolumeCommand,
|
command: VolumeCommand,
|
||||||
|
|
@ -410,16 +355,12 @@ export function createAgentManagerRuntime(onEvent: (event: AgentManagerEvent) =>
|
||||||
const session = getSession(agentId);
|
const session = getSession(agentId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
yield* logger.effect.warn(
|
yield* logger.effect.warn(`Cannot send volume command ${command.name}. Agent ${agentId} is not connected.`);
|
||||||
`Cannot send volume command ${command.name}. Agent ${agentId} is not connected.`,
|
|
||||||
);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(yield* session.isReady())) {
|
if (!(yield* session.isReady())) {
|
||||||
yield* logger.effect.warn(
|
yield* logger.effect.warn(`Cannot send volume command ${command.name}. Agent ${agentId} is not ready.`);
|
||||||
`Cannot send volume command ${command.name}. Agent ${agentId} is not ready.`,
|
|
||||||
);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,12 @@
|
||||||
import { Deferred, Effect, Queue, Ref, type Scope } from "effect";
|
import { Effect, Queue, Ref, type Scope } from "effect";
|
||||||
import type { AgentKind } from "../../../db/schema";
|
import type { AgentKind } from "../../../db/schema";
|
||||||
import {
|
import {
|
||||||
createControllerMessage,
|
createControllerMessage,
|
||||||
parseAgentMessage,
|
parseAgentMessage,
|
||||||
parseAgentStartupMessage,
|
|
||||||
SUPPORTED_AGENT_PROTOCOL_MAX_VERSION,
|
|
||||||
SUPPORTED_AGENT_PROTOCOL_MIN_VERSION,
|
|
||||||
type AgentMessage,
|
type AgentMessage,
|
||||||
type AgentProtocolRejection,
|
|
||||||
type BackupCancelPayload,
|
type BackupCancelPayload,
|
||||||
type BackupRunPayload,
|
type BackupRunPayload,
|
||||||
type ControllerWireMessage,
|
type ControllerWireMessage,
|
||||||
type RestoreCancelPayload,
|
|
||||||
type RestoreRunPayload,
|
|
||||||
type VolumeCommand,
|
type VolumeCommand,
|
||||||
type VolumeCommandResponsePayload,
|
type VolumeCommandResponsePayload,
|
||||||
} from "@zerobyte/contracts/agent-protocol";
|
} from "@zerobyte/contracts/agent-protocol";
|
||||||
|
|
@ -31,25 +25,27 @@ type AgentSocket = Bun.ServerWebSocket<AgentConnectionData>;
|
||||||
|
|
||||||
type SessionState = {
|
type SessionState = {
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
protocolVersion: number | null;
|
|
||||||
lastSeenAt: number | null;
|
lastSeenAt: number | null;
|
||||||
lastPongAt: number | null;
|
lastPongAt: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PendingCommand = { deferred: Deferred.Deferred<VolumeCommandResponsePayload, Error>; description: string };
|
type PendingVolumeCommand = {
|
||||||
|
resolve: (payload: VolumeCommandResponsePayload) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ControllerAgentSessionEvent =
|
export type ControllerAgentSessionEvent =
|
||||||
| Exclude<AgentMessage, { type: "volume.commandResult" }>
|
| Exclude<AgentMessage, { type: "volume.commandResult" }>
|
||||||
| { type: "agent.protocolRejected"; payload: AgentProtocolRejection }
|
| {
|
||||||
| { type: "agent.disconnected" };
|
type: "agent.disconnected";
|
||||||
|
};
|
||||||
|
|
||||||
export type ControllerAgentSession = {
|
export type ControllerAgentSession = {
|
||||||
readonly connectionId: string;
|
readonly connectionId: string;
|
||||||
handleMessage: (data: string) => Effect.Effect<void>;
|
handleMessage: (data: string) => Effect.Effect<void>;
|
||||||
sendBackup: (payload: BackupRunPayload) => Effect.Effect<boolean>;
|
sendBackup: (payload: BackupRunPayload) => Effect.Effect<boolean>;
|
||||||
sendBackupCancel: (payload: BackupCancelPayload) => 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>;
|
runVolumeCommand: (command: VolumeCommand) => Effect.Effect<VolumeCommandResponsePayload, Error>;
|
||||||
isReady: () => Effect.Effect<boolean>;
|
isReady: () => Effect.Effect<boolean>;
|
||||||
run: Effect.Effect<void, never, Scope.Scope>;
|
run: Effect.Effect<void, never, Scope.Scope>;
|
||||||
|
|
@ -62,10 +58,9 @@ export const createControllerAgentSession = (
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
let isClosed = false;
|
let isClosed = false;
|
||||||
const outboundQueue = yield* Queue.bounded<ControllerWireMessage>(64);
|
const outboundQueue = yield* Queue.bounded<ControllerWireMessage>(64);
|
||||||
const pendingCommands = yield* Ref.make(new Map<string, PendingCommand>());
|
const pendingVolumeCommands = new Map<string, PendingVolumeCommand>();
|
||||||
const state = yield* Ref.make<SessionState>({
|
const state = yield* Ref.make<SessionState>({
|
||||||
isReady: false,
|
isReady: false,
|
||||||
protocolVersion: null,
|
|
||||||
lastSeenAt: null,
|
lastSeenAt: null,
|
||||||
lastPongAt: null,
|
lastPongAt: null,
|
||||||
});
|
});
|
||||||
|
|
@ -74,9 +69,7 @@ export const createControllerAgentSession = (
|
||||||
Queue.offer(outboundQueue, message).pipe(
|
Queue.offer(outboundQueue, message).pipe(
|
||||||
Effect.catchAllCause((cause) =>
|
Effect.catchAllCause((cause) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
logger.error(
|
logger.error(`Failed to queue outbound message for agent ${socket.data.agentId}: ${toMessage(cause)}`);
|
||||||
`Failed to queue outbound message for agent ${socket.data.agentId}: ${toMessage(cause)}`,
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -84,33 +77,18 @@ export const createControllerAgentSession = (
|
||||||
|
|
||||||
const updateState = (update: (current: SessionState) => SessionState) => Ref.update(state, update);
|
const updateState = (update: (current: SessionState) => SessionState) => Ref.update(state, update);
|
||||||
|
|
||||||
const setPendingCommand = (commandId: string, pending: PendingCommand) =>
|
const rejectPendingVolumeCommands = () => {
|
||||||
Ref.update(pendingCommands, (current) => new Map(current).set(commandId, pending));
|
for (const [commandId, pending] of pendingVolumeCommands) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
const removePendingCommand = (commandId: string) =>
|
pending.reject(new Error(`Agent session closed before volume command ${commandId} completed`));
|
||||||
Ref.modify(pendingCommands, (current) => {
|
|
||||||
const pending = current.get(commandId) ?? null;
|
|
||||||
const next = new Map(current);
|
|
||||||
next.delete(commandId);
|
|
||||||
return [pending, next];
|
|
||||||
});
|
|
||||||
|
|
||||||
const rejectPendingCommands = Effect.gen(function* () {
|
|
||||||
const pendingCommandEntries = yield* Ref.get(pendingCommands);
|
|
||||||
yield* Ref.set(pendingCommands, new Map());
|
|
||||||
|
|
||||||
for (const pending of pendingCommandEntries.values()) {
|
|
||||||
yield* Deferred.fail(
|
|
||||||
pending.deferred,
|
|
||||||
new Error(`Agent session closed before ${pending.description} completed`),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
pendingVolumeCommands.clear();
|
||||||
|
};
|
||||||
|
|
||||||
const releaseSession = Effect.gen(function* () {
|
const releaseSession = Effect.gen(function* () {
|
||||||
const disconnectedAt = Date.now();
|
const disconnectedAt = Date.now();
|
||||||
yield* updateState((current) => ({ ...current, isReady: false, lastSeenAt: disconnectedAt }));
|
yield* updateState((current) => ({ ...current, isReady: false, lastSeenAt: disconnectedAt }));
|
||||||
yield* rejectPendingCommands;
|
yield* Effect.sync(rejectPendingVolumeCommands);
|
||||||
yield* onEvent({ type: "agent.disconnected" });
|
yield* onEvent({ type: "agent.disconnected" });
|
||||||
|
|
||||||
yield* Queue.shutdown(outboundQueue);
|
yield* Queue.shutdown(outboundQueue);
|
||||||
|
|
@ -174,44 +152,36 @@ export const createControllerAgentSession = (
|
||||||
return yield* Effect.never;
|
return yield* Effect.never;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleVolumeCommandResult = (payload: VolumeCommandResponsePayload) =>
|
const handleVolumeCommandResult = (payload: VolumeCommandResponsePayload) => {
|
||||||
Effect.gen(function* () {
|
const pending = pendingVolumeCommands.get(payload.commandId);
|
||||||
const pending = yield* removePendingCommand(payload.commandId);
|
if (!pending) {
|
||||||
if (!pending) {
|
logger.warn(`Received response for unknown volume command ${payload.commandId}`);
|
||||||
yield* logger.effect.warn(`Received response for unknown volume command ${payload.commandId}`);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
yield* Deferred.succeed(pending.deferred, payload);
|
pendingVolumeCommands.delete(payload.commandId);
|
||||||
});
|
clearTimeout(pending.timeout);
|
||||||
|
pending.resolve(payload);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAgentMessage = (message: AgentMessage) =>
|
const handleAgentMessage = (message: AgentMessage) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "agent.ready": {
|
case "agent.ready": {
|
||||||
const readyAt = Date.now();
|
const readyAt = Date.now();
|
||||||
yield* updateState((current) => ({
|
yield* updateState((current) => ({ ...current, isReady: true, lastSeenAt: readyAt }));
|
||||||
...current,
|
|
||||||
isReady: true,
|
|
||||||
protocolVersion: message.payload.protocolVersion,
|
|
||||||
lastSeenAt: readyAt,
|
|
||||||
}));
|
|
||||||
yield* logger.effect.info(`Agent "${socket.data.agentName}" (${socket.data.agentId}) is ready`);
|
yield* logger.effect.info(`Agent "${socket.data.agentName}" (${socket.data.agentId}) is ready`);
|
||||||
yield* onEvent(message);
|
yield* onEvent(message);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "heartbeat.pong": {
|
case "heartbeat.pong": {
|
||||||
const seenAt = Date.now();
|
const seenAt = Date.now();
|
||||||
yield* updateState((current) => ({
|
yield* updateState((current) => ({ ...current, lastSeenAt: seenAt, lastPongAt: message.payload.sentAt }));
|
||||||
...current,
|
|
||||||
lastSeenAt: seenAt,
|
|
||||||
lastPongAt: message.payload.sentAt,
|
|
||||||
}));
|
|
||||||
yield* onEvent(message);
|
yield* onEvent(message);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "volume.commandResult": {
|
case "volume.commandResult": {
|
||||||
yield* handleVolumeCommandResult(message.payload);
|
yield* Effect.sync(() => handleVolumeCommandResult(message.payload));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|
@ -221,30 +191,10 @@ 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 {
|
return {
|
||||||
connectionId: socket.data.id,
|
connectionId: socket.data.id,
|
||||||
handleMessage: (data: string) => {
|
handleMessage: (data: string) => {
|
||||||
return Effect.gen(function* () {
|
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);
|
const parsed = parseAgentMessage(data);
|
||||||
|
|
||||||
if (parsed === null) {
|
if (parsed === null) {
|
||||||
|
|
@ -253,18 +203,7 @@ export const createControllerAgentSession = (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
if (!currentState.isReady) {
|
yield* logger.effect.warn(`Invalid agent message from ${socket.data.agentId}: ${parsed.error.message}`);
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,31 +212,37 @@ export const createControllerAgentSession = (
|
||||||
},
|
},
|
||||||
sendBackup: (payload) => offerOutbound(createControllerMessage("backup.run", payload)),
|
sendBackup: (payload) => offerOutbound(createControllerMessage("backup.run", payload)),
|
||||||
sendBackupCancel: (payload) => offerOutbound(createControllerMessage("backup.cancel", payload)),
|
sendBackupCancel: (payload) => offerOutbound(createControllerMessage("backup.cancel", payload)),
|
||||||
sendRestore: (payload) => offerOutbound(createControllerMessage("restore.run", payload)),
|
runVolumeCommand: (command) => {
|
||||||
sendRestoreCancel: (payload) => offerOutbound(createControllerMessage("restore.cancel", payload)),
|
return Effect.tryPromise({
|
||||||
runVolumeCommand: (command) =>
|
try: async () => {
|
||||||
Effect.gen(function* () {
|
const commandId = Bun.randomUUIDv7();
|
||||||
const commandId = Bun.randomUUIDv7();
|
const response = new Promise<VolumeCommandResponsePayload>((resolve, reject) => {
|
||||||
const description = `volume command ${command.name}`;
|
const timeout = setTimeout(() => {
|
||||||
const deferred = yield* Deferred.make<VolumeCommandResponsePayload, Error>();
|
pendingVolumeCommands.delete(commandId);
|
||||||
yield* setPendingCommand(commandId, { deferred, description });
|
reject(new Error(`Volume command ${command.name} timed out`));
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
const queued = yield* offerOutbound(
|
pendingVolumeCommands.set(commandId, { resolve, reject, timeout });
|
||||||
createControllerMessage("volume.command", { commandId, command }),
|
});
|
||||||
);
|
|
||||||
if (!queued) {
|
|
||||||
yield* removePendingCommand(commandId);
|
|
||||||
return yield* Effect.fail(new Error(`Failed to queue volume command ${command.name}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
return yield* Deferred.await(deferred).pipe(
|
const queued = await Effect.runPromise(
|
||||||
Effect.timeoutFail({
|
offerOutbound(createControllerMessage("volume.command", { commandId, command })),
|
||||||
duration: "60 seconds",
|
);
|
||||||
onTimeout: () => new Error(`Volume command ${command.name} timed out`),
|
|
||||||
}),
|
if (!queued) {
|
||||||
Effect.ensuring(removePendingCommand(commandId)),
|
const pending = pendingVolumeCommands.get(commandId);
|
||||||
);
|
if (pending) {
|
||||||
}),
|
clearTimeout(pending.timeout);
|
||||||
|
pendingVolumeCommands.delete(commandId);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to queue volume command ${command.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
catch: (error) => (error instanceof Error ? error : new Error(toMessage(error))),
|
||||||
|
});
|
||||||
|
},
|
||||||
isReady: () => Ref.get(state).pipe(Effect.map((current) => current.isReady)),
|
isReady: () => Ref.get(state).pipe(Effect.map((current) => current.isReady)),
|
||||||
run,
|
run,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,20 @@
|
||||||
import { createAgentRuntimeState, type AgentRuntimeState } from "./runtime-state";
|
import { createAgentRuntimeState, type AgentRuntimeState } from "./runtime-state";
|
||||||
|
|
||||||
type RuntimeMapKey = "activeBackupsByScheduleId" | "activeBackupScheduleIdsByJobId" | "activeRestoresByRestoreId";
|
type LegacyAgentRuntimeState = Omit<AgentRuntimeState, "activeBackupsByScheduleId" | "activeBackupScheduleIdsByJobId"> &
|
||||||
|
Partial<Pick<AgentRuntimeState, "activeBackupsByScheduleId" | "activeBackupScheduleIdsByJobId">>;
|
||||||
type LegacyAgentRuntimeState = Omit<AgentRuntimeState, RuntimeMapKey> & Partial<Pick<AgentRuntimeState, RuntimeMapKey>>;
|
|
||||||
|
|
||||||
export type ProcessWithAgentRuntime = NodeJS.Process & {
|
export type ProcessWithAgentRuntime = NodeJS.Process & {
|
||||||
__zerobyteAgentRuntime?: LegacyAgentRuntimeState;
|
__zerobyteAgentRuntime?: LegacyAgentRuntimeState;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveRuntimeMaps = (runtime: LegacyAgentRuntimeState): runtime is AgentRuntimeState => {
|
const hasActiveBackupMaps = (runtime: LegacyAgentRuntimeState): runtime is AgentRuntimeState => {
|
||||||
return (
|
return runtime.activeBackupsByScheduleId instanceof Map && runtime.activeBackupScheduleIdsByJobId instanceof Map;
|
||||||
runtime.activeBackupsByScheduleId instanceof Map &&
|
|
||||||
runtime.activeBackupScheduleIdsByJobId instanceof Map &&
|
|
||||||
runtime.activeRestoresByRestoreId instanceof Map
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hydrateAgentRuntimeState = (runtime: LegacyAgentRuntimeState): AgentRuntimeState => ({
|
const hydrateAgentRuntimeState = (runtime: LegacyAgentRuntimeState): AgentRuntimeState => ({
|
||||||
...runtime,
|
...runtime,
|
||||||
activeBackupsByScheduleId: runtime.activeBackupsByScheduleId ?? new Map(),
|
activeBackupsByScheduleId: runtime.activeBackupsByScheduleId ?? new Map(),
|
||||||
activeBackupScheduleIdsByJobId: runtime.activeBackupScheduleIdsByJobId ?? new Map(),
|
activeBackupScheduleIdsByJobId: runtime.activeBackupScheduleIdsByJobId ?? new Map(),
|
||||||
activeRestoresByRestoreId: runtime.activeRestoresByRestoreId ?? new Map(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getDevAgentRuntimeState = (): AgentRuntimeState => {
|
export const getDevAgentRuntimeState = (): AgentRuntimeState => {
|
||||||
|
|
@ -33,7 +27,7 @@ export const getDevAgentRuntimeState = (): AgentRuntimeState => {
|
||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasActiveRuntimeMaps(existingRuntime)) {
|
if (hasActiveBackupMaps(existingRuntime)) {
|
||||||
return existingRuntime;
|
return existingRuntime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,12 @@
|
||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import type { ResticBackupOutputDto } from "@zerobyte/core/restic";
|
import type { ResticBackupOutputDto } from "@zerobyte/core/restic";
|
||||||
import type {
|
import type { BackupProgressPayload } from "@zerobyte/contracts/agent-protocol";
|
||||||
BackupProgressPayload,
|
|
||||||
RestoreCompletedPayload,
|
|
||||||
RestoreProgressPayload,
|
|
||||||
} from "@zerobyte/contracts/agent-protocol";
|
|
||||||
import type { AgentManagerRuntime } from "../controller/server";
|
import type { AgentManagerRuntime } from "../controller/server";
|
||||||
|
|
||||||
export type BackupExecutionProgress = BackupProgressPayload["progress"];
|
export type BackupExecutionProgress = BackupProgressPayload["progress"];
|
||||||
export type BackupExecutionResult =
|
export type BackupExecutionResult =
|
||||||
| { status: "unavailable"; error: Error }
|
| { 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: "failed"; error: string }
|
||||||
| { status: "cancelled"; message?: string };
|
| { status: "cancelled"; message?: string };
|
||||||
|
|
||||||
|
|
@ -35,15 +20,6 @@ type ActiveBackupRun = {
|
||||||
cancellationRequested: boolean;
|
cancellationRequested: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActiveRestoreRun = {
|
|
||||||
agentId: string;
|
|
||||||
restoreId: string;
|
|
||||||
onStarted: () => void;
|
|
||||||
onProgress: (progress: RestoreExecutionProgress) => void;
|
|
||||||
resolve: (result: RestoreExecutionResult) => void;
|
|
||||||
cancellationRequested: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgentRuntimeState = {
|
export type AgentRuntimeState = {
|
||||||
agentManager: AgentManagerRuntime | null;
|
agentManager: AgentManagerRuntime | null;
|
||||||
localAgent: ChildProcess | null;
|
localAgent: ChildProcess | null;
|
||||||
|
|
@ -51,7 +27,6 @@ export type AgentRuntimeState = {
|
||||||
localAgentRestartTimeout: ReturnType<typeof setTimeout> | null;
|
localAgentRestartTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
activeBackupsByScheduleId: Map<number, ActiveBackupRun>;
|
activeBackupsByScheduleId: Map<number, ActiveBackupRun>;
|
||||||
activeBackupScheduleIdsByJobId: Map<string, number>;
|
activeBackupScheduleIdsByJobId: Map<string, number>;
|
||||||
activeRestoresByRestoreId: Map<string, ActiveRestoreRun>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createAgentRuntimeState = (): AgentRuntimeState => ({
|
export const createAgentRuntimeState = (): AgentRuntimeState => ({
|
||||||
|
|
@ -61,5 +36,4 @@ export const createAgentRuntimeState = (): AgentRuntimeState => ({
|
||||||
localAgentRestartTimeout: null,
|
localAgentRestartTimeout: null,
|
||||||
activeBackupsByScheduleId: new Map(),
|
activeBackupsByScheduleId: new Map(),
|
||||||
activeBackupScheduleIdsByJobId: new Map(),
|
activeBackupScheduleIdsByJobId: new Map(),
|
||||||
activeRestoresByRestoreId: new Map(),
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ type LocalAgentState = {
|
||||||
localAgentRestartTimeout: ReturnType<typeof setTimeout> | null;
|
localAgentRestartTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function spawnLocalAgentProcess(runtime: LocalAgentState, controllerUrl: string) {
|
export async function spawnLocalAgentProcess(runtime: LocalAgentState) {
|
||||||
await stopLocalAgentProcess(runtime);
|
await stopLocalAgentProcess(runtime);
|
||||||
|
|
||||||
if (!config.flags.enableLocalAgent) {
|
if (!config.flags.enableLocalAgent) {
|
||||||
|
|
@ -31,7 +31,7 @@ export async function spawnLocalAgentProcess(runtime: LocalAgentState, controlle
|
||||||
const agentProcess = spawn("bun", args, {
|
const agentProcess = spawn("bun", args, {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
ZEROBYTE_CONTROLLER_URL: controllerUrl,
|
ZEROBYTE_CONTROLLER_URL: "ws://localhost:3001",
|
||||||
ZEROBYTE_AGENT_TOKEN: agentToken,
|
ZEROBYTE_AGENT_TOKEN: agentToken,
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
|
@ -62,10 +62,8 @@ export async function spawnLocalAgentProcess(runtime: LocalAgentState, controlle
|
||||||
|
|
||||||
runtime.localAgentRestartTimeout = setTimeout(() => {
|
runtime.localAgentRestartTimeout = setTimeout(() => {
|
||||||
runtime.localAgentRestartTimeout = null;
|
runtime.localAgentRestartTimeout = null;
|
||||||
void spawnLocalAgentProcess(runtime, controllerUrl).catch((error) => {
|
void spawnLocalAgentProcess(runtime).catch((error) => {
|
||||||
logger.error(
|
logger.error(`Failed to restart local agent: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
`Failed to restart local agent: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}, 1_000);
|
}, 1_000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { db } from "~/server/db/db";
|
|
||||||
import { account, passkey, usersTable } from "~/server/db/schema";
|
|
||||||
import { createUser, randomId, randomSlug } from "~/test/helpers/user-org";
|
|
||||||
import { hasActivePasskeyUser, userHasCredentialPassword, verifyUserPassword } from "../helpers";
|
|
||||||
|
|
||||||
const { verifyPassword } = vi.hoisted(() => ({
|
|
||||||
verifyPassword: vi.fn(async ({ hash }: { hash: string }) => hash === "credential-password-hash"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("better-auth/crypto", () => ({
|
|
||||||
verifyPassword,
|
|
||||||
}));
|
|
||||||
|
|
||||||
async function createAccount({
|
|
||||||
password,
|
|
||||||
providerId,
|
|
||||||
userId,
|
|
||||||
}: {
|
|
||||||
password: string | null;
|
|
||||||
providerId: string;
|
|
||||||
userId: string;
|
|
||||||
}) {
|
|
||||||
await db.insert(account).values({
|
|
||||||
id: randomId(),
|
|
||||||
accountId: randomSlug("account"),
|
|
||||||
providerId,
|
|
||||||
userId,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("verifies against the credential account when the user also has SSO accounts", async () => {
|
|
||||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
|
||||||
await createAccount({ userId, providerId: "oidc-acme", password: null });
|
|
||||||
await createAccount({ userId, providerId: "credential", password: "credential-password-hash" });
|
|
||||||
|
|
||||||
const result = await verifyUserPassword({ userId, password: "correct-password" });
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(verifyPassword).toHaveBeenCalledWith({
|
|
||||||
password: "correct-password",
|
|
||||||
hash: "credential-password-hash",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns false when the user has no credential password account", async () => {
|
|
||||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
|
||||||
await createAccount({ userId, providerId: "oidc-acme", password: null });
|
|
||||||
|
|
||||||
const result = await verifyUserPassword({ userId, password: "correct-password" });
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
expect(verifyPassword).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("userHasCredentialPassword", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await db.delete(passkey);
|
|
||||||
await db.delete(account);
|
|
||||||
await db.delete(usersTable);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns true when the user has a credential account with a password", async () => {
|
|
||||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
|
||||||
await createAccount({ userId, providerId: "credential", password: "credential-password-hash" });
|
|
||||||
|
|
||||||
await expect(userHasCredentialPassword(userId)).resolves.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns false when the user only has SSO accounts", async () => {
|
|
||||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
|
||||||
await createAccount({ userId, providerId: "oidc-acme", password: null });
|
|
||||||
|
|
||||||
await expect(userHasCredentialPassword(userId)).resolves.toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns false when the credential account has no password", async () => {
|
|
||||||
const userId = await createUser(`${randomSlug("user")}@example.com`);
|
|
||||||
await createAccount({ userId, providerId: "credential", password: null });
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { verifyPassword } from "better-auth/crypto";
|
import { verifyPassword } from "better-auth/crypto";
|
||||||
import { db } from "~/server/db/db";
|
import { db } from "~/server/db/db";
|
||||||
import { passkey, usersTable } from "~/server/db/schema";
|
|
||||||
|
|
||||||
type PasswordVerificationBody = {
|
type PasswordVerificationBody = {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -10,7 +8,7 @@ type PasswordVerificationBody = {
|
||||||
|
|
||||||
export const verifyUserPassword = async ({ password, userId }: PasswordVerificationBody) => {
|
export const verifyUserPassword = async ({ password, userId }: PasswordVerificationBody) => {
|
||||||
const userAccount = await db.query.account.findFirst({
|
const userAccount = await db.query.account.findFirst({
|
||||||
where: { AND: [{ userId }, { providerId: "credential" }] },
|
where: { userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!userAccount || !userAccount.password) {
|
if (!userAccount || !userAccount.password) {
|
||||||
|
|
@ -24,23 +22,3 @@ export const verifyUserPassword = async ({ password, userId }: PasswordVerificat
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userHasCredentialPassword = async (userId: string) => {
|
|
||||||
const userAccount = await db.query.account.findFirst({
|
|
||||||
where: { AND: [{ userId }, { providerId: "credential" }] },
|
|
||||||
columns: { password: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -10,27 +10,24 @@ import * as context from "~/server/core/request-context";
|
||||||
import * as resticModule from "~/server/core/restic";
|
import * as resticModule from "~/server/core/restic";
|
||||||
import * as spawnModule from "@zerobyte/core/node";
|
import * as spawnModule from "@zerobyte/core/node";
|
||||||
import type { ShortId } from "~/server/utils/branded";
|
import type { ShortId } from "~/server/utils/branded";
|
||||||
import { Effect } from "effect";
|
|
||||||
|
|
||||||
const setup = () => {
|
const setup = () => {
|
||||||
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||||
vi.spyOn(spawnModule, "safeSpawn").mockImplementation(() =>
|
vi.spyOn(spawnModule, "safeSpawn").mockImplementation(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
|
||||||
Promise.resolve({ exitCode: 0, summary: "", error: "" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mockSnapshots: (sourceSnapshots: unknown[], mirrorSnapshots: unknown[]) => {
|
mockSnapshots: (sourceSnapshots: unknown[], mirrorSnapshots: unknown[]) => {
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
vi.spyOn(resticModule.restic, "snapshots").mockImplementation(() => {
|
vi.spyOn(resticModule.restic, "snapshots").mockImplementation(() => {
|
||||||
callCount++;
|
callCount++;
|
||||||
if (callCount === 1) return Effect.succeed(sourceSnapshots as never);
|
if (callCount === 1) return Promise.resolve(sourceSnapshots as never);
|
||||||
return Effect.succeed(mirrorSnapshots as never);
|
return Promise.resolve(mirrorSnapshots as never);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
mockCopy: () => {
|
mockCopy: () => {
|
||||||
const copyMock = vi
|
const copyMock = vi
|
||||||
.spyOn(resticModule.restic, "copy")
|
.spyOn(resticModule.restic, "copy")
|
||||||
.mockImplementation(() => Effect.succeed({ success: true, output: "" }));
|
.mockImplementation(() => Promise.resolve({ success: true, output: "" }));
|
||||||
return copyMock;
|
return copyMock;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -193,14 +190,12 @@ describe("syncMirror", () => {
|
||||||
const copyMock = mockCopy();
|
const copyMock = mockCopy();
|
||||||
let releaseCopy: (() => void) | undefined;
|
let releaseCopy: (() => void) | undefined;
|
||||||
const copyStarted = new Promise<void>((resolve) => {
|
const copyStarted = new Promise<void>((resolve) => {
|
||||||
copyMock.mockImplementation(() =>
|
copyMock.mockImplementation(
|
||||||
Effect.promise(
|
() =>
|
||||||
() =>
|
new Promise((copyResolve) => {
|
||||||
new Promise((copyResolve) => {
|
releaseCopy = () => copyResolve({ success: true, output: "" });
|
||||||
releaseCopy = () => copyResolve({ success: true, output: "" });
|
resolve();
|
||||||
resolve();
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -213,7 +208,9 @@ describe("syncMirror", () => {
|
||||||
});
|
});
|
||||||
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
|
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
|
||||||
|
|
||||||
const firstSync = backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]);
|
await expect(
|
||||||
|
backupsService.syncMirror(schedule.shortId, mirrorRepository.shortId as ShortId, ["snap1"]),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
await copyStarted;
|
await copyStarted;
|
||||||
|
|
||||||
|
|
@ -228,8 +225,6 @@ describe("syncMirror", () => {
|
||||||
|
|
||||||
releaseCopy?.();
|
releaseCopy?.();
|
||||||
|
|
||||||
await expect(firstSync).resolves.toEqual({ success: true });
|
|
||||||
|
|
||||||
await waitForExpect(async () => {
|
await waitForExpect(async () => {
|
||||||
const mirrors = await backupsService.getMirrors(schedule.shortId);
|
const mirrors = await backupsService.getMirrors(schedule.shortId);
|
||||||
expect(mirrors[0]?.lastCopyStatus).toBe("success");
|
expect(mirrors[0]?.lastCopyStatus).toBe("success");
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,23 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import fc from "fast-check";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { fromAny } from "@total-typescript/shoehorn";
|
import { fromAny } from "@total-typescript/shoehorn";
|
||||||
import { createBackupOptions } from "../backup.helpers";
|
import { createBackupOptions, processPattern } from "../backup.helpers";
|
||||||
|
|
||||||
type BackupScheduleInput = Parameters<typeof createBackupOptions>[0];
|
type BackupScheduleInput = Parameters<typeof createBackupOptions>[0];
|
||||||
|
|
||||||
|
const safePatternSegmentArb = fc
|
||||||
|
.array(fc.constantFrom("a", "b", "c", "x", "y", "z", "0", "1", "2", "-", "_", ".", " "), {
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 12,
|
||||||
|
})
|
||||||
|
.map((chars) => chars.join(""))
|
||||||
|
.filter((segment) => segment.trim() !== "" && segment !== "." && segment !== "..");
|
||||||
|
|
||||||
|
const safeRelativePatternArb = fc
|
||||||
|
.array(safePatternSegmentArb, { minLength: 1, maxLength: 5 })
|
||||||
|
.map((segments) => segments.join("/"));
|
||||||
|
|
||||||
const createSchedule = (overrides: Partial<BackupScheduleInput> = {}): BackupScheduleInput =>
|
const createSchedule = (overrides: Partial<BackupScheduleInput> = {}): BackupScheduleInput =>
|
||||||
fromAny({
|
fromAny({
|
||||||
shortId: "sched-1234",
|
shortId: "sched-1234",
|
||||||
|
|
@ -44,6 +57,43 @@ describe("executeBackup - include / exclude patterns", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should handle the case where a subfolder has the exact same name as the volume name", () => {
|
||||||
|
// arrange
|
||||||
|
const volumeName = "SyncFolder";
|
||||||
|
const volumePath = `/${volumeName}`;
|
||||||
|
const selectedPath = `/${volumeName}`;
|
||||||
|
const schedule = createSchedule({
|
||||||
|
includePaths: [selectedPath],
|
||||||
|
});
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
|
// act
|
||||||
|
const options = createBackupOptions(schedule, volumePath, signal);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(options.includePaths).toEqual([path.join(volumePath, volumeName)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should correctly mix relative and absolute patterns", () => {
|
||||||
|
// arrange
|
||||||
|
const volumePath = "/var/lib/zerobyte/volumes/vol456/_data";
|
||||||
|
const relativeInclude = "relative/include";
|
||||||
|
const anchoredInclude = "/anchored/include";
|
||||||
|
const schedule = createSchedule({
|
||||||
|
includePatterns: [relativeInclude, anchoredInclude],
|
||||||
|
});
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
|
// act
|
||||||
|
const options = createBackupOptions(schedule, volumePath, signal);
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(options.includePatterns).toEqual([
|
||||||
|
path.join(volumePath, relativeInclude),
|
||||||
|
path.join(volumePath, "anchored/include"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("should handle empty include and exclude patterns", () => {
|
test("should handle empty include and exclude patterns", () => {
|
||||||
// arrange
|
// arrange
|
||||||
const schedule = createSchedule({
|
const schedule = createSchedule({
|
||||||
|
|
@ -60,4 +110,79 @@ describe("executeBackup - include / exclude patterns", () => {
|
||||||
expect(options.includePatterns).toEqual([]);
|
expect(options.includePatterns).toEqual([]);
|
||||||
expect(options.exclude).toEqual([]);
|
expect(options.exclude).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("processPattern keeps relative and negated relative patterns unchanged", () => {
|
||||||
|
expect(processPattern("relative/include", "/volume")).toBe("relative/include");
|
||||||
|
expect(processPattern("!*.log", "/volume")).toBe("!*.log");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects include patterns that escape the volume root", () => {
|
||||||
|
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
createBackupOptions(
|
||||||
|
createSchedule({
|
||||||
|
includePatterns: ["../../../../etc/shadow", "/../etc/passwd", "!/../../secrets.txt"],
|
||||||
|
}),
|
||||||
|
volumePath,
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
).toThrow("Include pattern escapes volume root");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("anchors relative glob include patterns to the volume path", () => {
|
||||||
|
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
|
||||||
|
const schedule = createSchedule({
|
||||||
|
includePatterns: ["**/*.xyz", "*.zip", "!**/*.tmp"],
|
||||||
|
});
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
|
const options = createBackupOptions(schedule, volumePath, signal);
|
||||||
|
|
||||||
|
expect(options.includePatterns).toEqual([
|
||||||
|
path.join(volumePath, "**/*.xyz"),
|
||||||
|
path.join(volumePath, "*.zip"),
|
||||||
|
`!${path.join(volumePath, "**/*.tmp")}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("anchors generated include patterns under the volume path", () => {
|
||||||
|
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(safeRelativePatternArb, fc.boolean(), fc.boolean(), (pattern, anchored, negated) => {
|
||||||
|
const rawPattern = `${negated ? "!" : ""}${anchored ? "/" : ""}${pattern}`;
|
||||||
|
const expected = path.join(volumePath, pattern);
|
||||||
|
|
||||||
|
expect(processPattern(rawPattern, volumePath, true)).toBe(negated ? `!${expected}` : expected);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects generated include patterns that escape the volume root", () => {
|
||||||
|
const volumePath = "/volume/root";
|
||||||
|
|
||||||
|
fc.assert(
|
||||||
|
fc.property(safeRelativePatternArb, fc.boolean(), (pattern, negated) => {
|
||||||
|
const escapingPattern = `${negated ? "!" : ""}${"../".repeat(8)}${pattern}`;
|
||||||
|
|
||||||
|
expect(() => processPattern(escapingPattern, volumePath, true)).toThrow("Include pattern escapes volume root");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps selected include paths separate from include patterns", () => {
|
||||||
|
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
|
||||||
|
const schedule = createSchedule({
|
||||||
|
includePaths: ["/movies [1]"],
|
||||||
|
includePatterns: ["**/*.txt"],
|
||||||
|
});
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
|
||||||
|
const options = createBackupOptions(schedule, volumePath, signal);
|
||||||
|
|
||||||
|
expect(options.includePaths).toEqual([path.join(volumePath, "movies [1]")]);
|
||||||
|
expect(options.includePatterns).toEqual([path.join(volumePath, "**/*.txt")]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import * as spawnModule from "@zerobyte/core/node";
|
||||||
import type { SafeSpawnParams } from "@zerobyte/core/node";
|
import type { SafeSpawnParams } from "@zerobyte/core/node";
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
import { restic } from "~/server/core/restic";
|
import { restic } from "~/server/core/restic";
|
||||||
import { NotFoundError } from "http-errors-enhanced";
|
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
|
||||||
import { fromAny } from "@total-typescript/shoehorn";
|
import { fromAny } from "@total-typescript/shoehorn";
|
||||||
import { scheduleQueries } from "../backups.queries";
|
import { scheduleQueries } from "../backups.queries";
|
||||||
import { repositoriesService } from "~/server/modules/repositories/repositories.service";
|
import { repositoriesService } from "~/server/modules/repositories/repositories.service";
|
||||||
|
|
@ -23,16 +23,13 @@ import { createAgentBackupMocks } from "~/test/helpers/agent-mock";
|
||||||
import { getScheduleByIdOrShortId } from "../helpers/backup-schedule-lookups";
|
import { getScheduleByIdOrShortId } from "../helpers/backup-schedule-lookups";
|
||||||
import { volumeService } from "~/server/modules/volumes/volume.service";
|
import { volumeService } from "~/server/modules/volumes/volume.service";
|
||||||
import { db } from "~/server/db/db";
|
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 setup = () => {
|
||||||
const resticBackupMock = vi.fn((_: SafeSpawnParams) =>
|
const resticBackupMock = vi.fn((_: SafeSpawnParams) =>
|
||||||
Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }),
|
Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }),
|
||||||
);
|
);
|
||||||
const resticForgetMock = vi.fn(() => Effect.succeed({ success: true, data: null }));
|
const resticForgetMock = vi.fn(() => Promise.resolve({ success: true, data: null }));
|
||||||
const resticCopyMock = vi.fn(() => Effect.succeed({ success: true, output: "" }));
|
const resticCopyMock = vi.fn(() => Promise.resolve({ success: true, output: "" }));
|
||||||
const { runBackupMock, cancelBackupMock } = createAgentBackupMocks(resticBackupMock);
|
const { runBackupMock, cancelBackupMock } = createAgentBackupMocks(resticBackupMock);
|
||||||
const refreshStatsMock = vi.fn(() =>
|
const refreshStatsMock = vi.fn(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
|
|
@ -52,34 +49,32 @@ const setup = () => {
|
||||||
vi.spyOn(agentManager, "runBackup").mockImplementation(runBackupMock);
|
vi.spyOn(agentManager, "runBackup").mockImplementation(runBackupMock);
|
||||||
vi.spyOn(agentManager, "cancelBackup").mockImplementation(cancelBackupMock);
|
vi.spyOn(agentManager, "cancelBackup").mockImplementation(cancelBackupMock);
|
||||||
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
vi.spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
|
||||||
const ensureHealthyVolumeMock = vi
|
const ensureHealthyVolumeMock = vi.spyOn(volumeService, "ensureHealthyVolume").mockImplementation(async (shortId) => {
|
||||||
.spyOn(volumeService, "ensureHealthyVolume")
|
const volume = await db.query.volumesTable.findFirst({
|
||||||
.mockImplementation(async (shortId) => {
|
where: {
|
||||||
const volume = await db.query.volumesTable.findFirst({
|
AND: [{ shortId: { eq: shortId } }, { organizationId: TEST_ORG_ID }],
|
||||||
where: {
|
},
|
||||||
AND: [{ shortId: { eq: shortId } }, { organizationId: TEST_ORG_ID }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!volume) {
|
|
||||||
throw new NotFoundError("Volume not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (volume.status !== "mounted") {
|
|
||||||
return {
|
|
||||||
ready: false as const,
|
|
||||||
volume,
|
|
||||||
reason: "Volume is not mounted",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ready: true as const,
|
|
||||||
volume,
|
|
||||||
remounted: false,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!volume) {
|
||||||
|
throw new NotFoundError("Volume not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (volume.status !== "mounted") {
|
||||||
|
return {
|
||||||
|
ready: false as const,
|
||||||
|
volume,
|
||||||
|
reason: "Volume is not mounted",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: true as const,
|
||||||
|
volume,
|
||||||
|
remounted: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resticBackupMock,
|
resticBackupMock,
|
||||||
resticForgetMock,
|
resticForgetMock,
|
||||||
|
|
@ -91,25 +86,12 @@ 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(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
config.flags.enableLocalAgent = true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("backup execution - validation failures", () => {
|
describe("backup execution - validation failures", () => {
|
||||||
test("does not fail validation when the agent runtime owns volume readiness", async () => {
|
test("should fail backup when volume is not mounted", async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const { resticBackupMock } = setup();
|
const { resticBackupMock } = setup();
|
||||||
const volume = await createTestVolume({ status: "unmounted" });
|
const volume = await createTestVolume({ status: "unmounted" });
|
||||||
|
|
@ -123,7 +105,55 @@ describe("backup execution - validation failures", () => {
|
||||||
const result = await backupsService.validateBackupExecution(schedule.id);
|
const result = await backupsService.validateBackupExecution(schedule.id);
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
|
expect(result.type).toBe("failure");
|
||||||
|
if (result.type === "failure") {
|
||||||
|
expect(result.error).toBeInstanceOf(BadRequestError);
|
||||||
|
expect(result.error.message).toBe("Volume is not mounted");
|
||||||
|
}
|
||||||
|
expect(resticBackupMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runs a preflight volume health check before starting a backup", async () => {
|
||||||
|
setup();
|
||||||
|
const volume = await createTestVolume();
|
||||||
|
const repository = await createTestRepository();
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
volumeId: volume.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
});
|
||||||
|
const ensureHealthyVolumeSpy = vi.spyOn(volumeService, "ensureHealthyVolume").mockResolvedValue({
|
||||||
|
ready: true,
|
||||||
|
volume,
|
||||||
|
remounted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await backupsService.validateBackupExecution(schedule.id);
|
||||||
|
|
||||||
expect(result.type).toBe("success");
|
expect(result.type).toBe("success");
|
||||||
|
expect(ensureHealthyVolumeSpy).toHaveBeenCalledWith(volume.shortId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails validation when the preflight health check cannot recover the volume", async () => {
|
||||||
|
const { resticBackupMock } = setup();
|
||||||
|
const volume = await createTestVolume();
|
||||||
|
const repository = await createTestRepository();
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
volumeId: volume.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
});
|
||||||
|
vi.spyOn(volumeService, "ensureHealthyVolume").mockResolvedValue({
|
||||||
|
ready: false,
|
||||||
|
volume: { ...volume, status: "error", lastError: "stale mount" },
|
||||||
|
reason: "stale mount",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await backupsService.validateBackupExecution(schedule.id);
|
||||||
|
|
||||||
|
expect(result.type).toBe("failure");
|
||||||
|
if (result.type === "failure") {
|
||||||
|
expect(result.error).toBeInstanceOf(BadRequestError);
|
||||||
|
expect(result.error.message).toBe("stale mount");
|
||||||
|
}
|
||||||
expect(resticBackupMock).not.toHaveBeenCalled();
|
expect(resticBackupMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -246,102 +276,6 @@ describe("backup execution - validation failures", () => {
|
||||||
).toBe(false);
|
).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 () => {
|
test("passes configured backup webhooks to the backup agent", async () => {
|
||||||
const { runBackupMock } = setup();
|
const { runBackupMock } = setup();
|
||||||
const volume = await createTestVolume();
|
const volume = await createTestVolume();
|
||||||
|
|
@ -389,7 +323,6 @@ describe("backup execution - validation failures", () => {
|
||||||
privateKey: "key",
|
privateKey: "key",
|
||||||
path: "/data",
|
path: "/data",
|
||||||
skipHostKeyCheck: false,
|
skipHostKeyCheck: false,
|
||||||
allowLegacySshRsa: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const repository = await createTestRepository();
|
const repository = await createTestRepository();
|
||||||
|
|
@ -483,52 +416,8 @@ describe("backup execution - validation failures", () => {
|
||||||
await backupsService.executeBackup(schedule.id);
|
await backupsService.executeBackup(schedule.id);
|
||||||
|
|
||||||
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
||||||
const task = await getBackupTaskForSchedule(schedule.id);
|
|
||||||
expect(updatedSchedule.lastBackupStatus).toBe("error");
|
expect(updatedSchedule.lastBackupStatus).toBe("error");
|
||||||
expect(updatedSchedule.lastBackupError).toBe("Local backup agent is not connected");
|
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -572,10 +461,7 @@ describe("backup execution - routing", () => {
|
||||||
|
|
||||||
await backupsService.executeBackup(schedule.id);
|
await backupsService.executeBackup(schedule.id);
|
||||||
|
|
||||||
expect(runBackupMock).toHaveBeenCalledWith(
|
expect(runBackupMock).toHaveBeenCalledWith("agent-remote", expect.objectContaining({ scheduleId: schedule.id }));
|
||||||
"agent-remote",
|
|
||||||
expect.objectContaining({ scheduleId: schedule.id }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -603,15 +489,8 @@ describe("stop backup", () => {
|
||||||
await backupsService.executeBackup(schedule.id);
|
await backupsService.executeBackup(schedule.id);
|
||||||
|
|
||||||
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
||||||
const task = await getBackupTaskForSchedule(schedule.id);
|
|
||||||
expect(updatedSchedule.lastBackupStatus).toBe("warning");
|
expect(updatedSchedule.lastBackupStatus).toBe("warning");
|
||||||
expect(updatedSchedule.lastBackupError).toBe("error: open /mnt/data/private.db: permission denied");
|
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(
|
expect(notificationSpy).toHaveBeenLastCalledWith(
|
||||||
schedule.id,
|
schedule.id,
|
||||||
"warning",
|
"warning",
|
||||||
|
|
@ -764,48 +643,6 @@ describe("stop backup", () => {
|
||||||
expect(updatedSchedule.lastBackupError).toBe("Backup was stopped by the user");
|
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 () => {
|
test("should stop a running backup when the cancel command cannot be delivered", async () => {
|
||||||
const { resticBackupMock, cancelBackupMock } = setup();
|
const { resticBackupMock, cancelBackupMock } = setup();
|
||||||
const volume = await createTestVolume();
|
const volume = await createTestVolume();
|
||||||
|
|
@ -849,8 +686,6 @@ describe("stop backup", () => {
|
||||||
await waitForExpect(async () => {
|
await waitForExpect(async () => {
|
||||||
const queuedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
const queuedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
||||||
expect(queuedSchedule.lastBackupStatus).toBe("in_progress");
|
expect(queuedSchedule.lastBackupStatus).toBe("in_progress");
|
||||||
const task = await getBackupTaskForSchedule(schedule.id);
|
|
||||||
expect(task?.status).toBe("queued");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resticBackupMock).not.toHaveBeenCalled();
|
expect(resticBackupMock).not.toHaveBeenCalled();
|
||||||
|
|
@ -863,11 +698,8 @@ describe("stop backup", () => {
|
||||||
await executePromise;
|
await executePromise;
|
||||||
|
|
||||||
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
||||||
const task = await getBackupTaskForSchedule(schedule.id);
|
|
||||||
expect(updatedSchedule.lastBackupStatus).toBe("warning");
|
expect(updatedSchedule.lastBackupStatus).toBe("warning");
|
||||||
expect(updatedSchedule.lastBackupError).toBe("Backup was stopped by the user");
|
expect(updatedSchedule.lastBackupError).toBe("Backup was stopped by the user");
|
||||||
expect(task?.status).toBe("cancelled");
|
|
||||||
expect(task?.cancellationRequested).toBe(true);
|
|
||||||
expect(resticBackupMock).not.toHaveBeenCalled();
|
expect(resticBackupMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -968,41 +800,6 @@ describe("stop backup", () => {
|
||||||
expect(updatedSchedule.lastBackupError).toBe("Backup was stopped by the user");
|
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 () => {
|
test("should throw NotFoundError when schedule does not exist", async () => {
|
||||||
setup();
|
setup();
|
||||||
// act & assert
|
// act & assert
|
||||||
|
|
@ -1077,9 +874,7 @@ describe("retention policy - runForget", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// act & assert
|
// act & assert
|
||||||
await expect(backupsService.runForget(schedule.id, "non-existent-repo")).rejects.toThrow(
|
await expect(backupsService.runForget(schedule.id, "non-existent-repo")).rejects.toThrow("Repository not found");
|
||||||
"Repository not found",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1168,14 +963,12 @@ describe("mirror operations", () => {
|
||||||
|
|
||||||
const originalMirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
|
const originalMirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
|
||||||
|
|
||||||
resticCopyMock.mockImplementationOnce(() =>
|
resticCopyMock.mockImplementationOnce(async () => {
|
||||||
Effect.promise(async () => {
|
await backupsService.updateMirrors(schedule.id, {
|
||||||
await backupsService.updateMirrors(schedule.id, {
|
mirrors: [{ repositoryId: mirrorRepository.id, enabled: true }],
|
||||||
mirrors: [{ repositoryId: mirrorRepository.id, enabled: true }],
|
});
|
||||||
});
|
return { success: true, output: "" };
|
||||||
return { success: true, output: "" };
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await backupsService.copyToMirrors(schedule.id, sourceRepository, null);
|
await backupsService.copyToMirrors(schedule.id, sourceRepository, null);
|
||||||
|
|
@ -1202,11 +995,7 @@ describe("mirror operations", () => {
|
||||||
|
|
||||||
const mirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
|
const mirror = await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
|
||||||
|
|
||||||
resticCopyMock.mockImplementationOnce(() =>
|
resticCopyMock.mockImplementationOnce(() => Promise.reject(new Error("Copy failed")));
|
||||||
Effect.sync(() => {
|
|
||||||
throw new Error("Copy failed");
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await backupsService.copyToMirrors(schedule.id, sourceRepository, null);
|
await backupsService.copyToMirrors(schedule.id, sourceRepository, null);
|
||||||
|
|
@ -1234,7 +1023,7 @@ describe("mirror operations", () => {
|
||||||
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
|
await createTestBackupScheduleMirror(schedule.id, mirrorRepository.id);
|
||||||
|
|
||||||
resticCopyMock.mockClear();
|
resticCopyMock.mockClear();
|
||||||
resticCopyMock.mockImplementation(() => Effect.succeed({ success: true, output: "" }));
|
resticCopyMock.mockImplementation(() => Promise.resolve({ success: true, output: "" }));
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await backupsService.copyToMirrors(schedule.id, sourceRepository, schedule.retentionPolicy);
|
await backupsService.copyToMirrors(schedule.id, sourceRepository, schedule.retentionPolicy);
|
||||||
|
|
@ -1302,16 +1091,14 @@ describe("mirror operations", () => {
|
||||||
resolveFirstCopyStarted = resolve;
|
resolveFirstCopyStarted = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
resticCopyMock.mockImplementationOnce(() =>
|
resticCopyMock.mockImplementationOnce(
|
||||||
Effect.promise(
|
() =>
|
||||||
() =>
|
new Promise((resolve) => {
|
||||||
new Promise((resolve) => {
|
resolveFirstCopyStarted();
|
||||||
resolveFirstCopyStarted();
|
releaseFirstCopy = () => resolve({ success: true, output: "" });
|
||||||
releaseFirstCopy = () => resolve({ success: true, output: "" });
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
resticCopyMock.mockImplementation(() => Effect.succeed({ success: true, output: "" }));
|
resticCopyMock.mockImplementation(() => Promise.resolve({ success: true, output: "" }));
|
||||||
|
|
||||||
const firstCopyPromise = backupsService.copyToMirrors(firstSchedule.id, sourceRepository, null);
|
const firstCopyPromise = backupsService.copyToMirrors(firstSchedule.id, sourceRepository, null);
|
||||||
await firstCopyStarted;
|
await firstCopyStarted;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Effect } from "effect";
|
||||||
import { runBackupLifecycle } from "@zerobyte/core/backup-hooks";
|
import { runBackupLifecycle } from "@zerobyte/core/backup-hooks";
|
||||||
import type { BackupSchedule, Volume, Repository } from "../../db/schema";
|
import type { BackupSchedule, Volume, Repository } from "../../db/schema";
|
||||||
import { config } from "../../core/config";
|
import { config } from "../../core/config";
|
||||||
|
|
@ -6,16 +7,14 @@ import type { BackupRunPayload } from "@zerobyte/contracts/agent-protocol";
|
||||||
import { agentManager, type BackupExecutionProgress } from "../agents/agents-manager";
|
import { agentManager, type BackupExecutionProgress } from "../agents/agents-manager";
|
||||||
import { LOCAL_AGENT_ID } from "../agents/constants";
|
import { LOCAL_AGENT_ID } from "../agents/constants";
|
||||||
import { getVolumePath } from "../volumes/helpers";
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
import { decryptVolumeConfig } from "../volumes/volume-config-secrets";
|
|
||||||
import { decryptRepositoryConfig } from "../repositories/repository-config-secrets";
|
import { decryptRepositoryConfig } from "../repositories/repository-config-secrets";
|
||||||
import { createBackupOptions } from "./backup.helpers";
|
import { createBackupOptions } from "./backup.helpers";
|
||||||
import { runEffectPromise, toErrorDetails } from "../../utils/errors";
|
import { toErrorDetails } from "../../utils/errors";
|
||||||
import { BadRequestError } from "http-errors-enhanced";
|
import { BadRequestError } from "http-errors-enhanced";
|
||||||
|
|
||||||
const FUSE_VOLUME_BACKENDS = new Set<Volume["type"]>(["rclone", "sftp", "webdav"]);
|
const FUSE_VOLUME_BACKENDS = new Set<Volume["type"]>(["rclone", "sftp", "webdav"]);
|
||||||
const IGNORE_INODE_FLAG = "--ignore-inode";
|
const IGNORE_INODE_FLAG = "--ignore-inode";
|
||||||
type BackupExecutionRequest = {
|
type BackupExecutionRequest = {
|
||||||
jobId: string;
|
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
schedule: BackupSchedule;
|
schedule: BackupSchedule;
|
||||||
volume: Volume;
|
volume: Volume;
|
||||||
|
|
@ -43,9 +42,13 @@ const createBackupRunPayload = async ({
|
||||||
volume,
|
volume,
|
||||||
repository,
|
repository,
|
||||||
organizationId,
|
organizationId,
|
||||||
}: BackupExecutionRequest): Promise<BackupRunPayload> => {
|
}: BackupExecutionRequest & { jobId: string }): Promise<BackupRunPayload> => {
|
||||||
const agentVolume = { ...volume, config: await decryptVolumeConfig(volume.config) };
|
const sourcePath = getVolumePath(volume);
|
||||||
const customResticParams = schedule.customResticParams ?? [];
|
const { signal: _, ...options } = createBackupOptions(schedule, sourcePath);
|
||||||
|
|
||||||
|
if (FUSE_VOLUME_BACKENDS.has(volume.type) && !options.customResticParams.includes(IGNORE_INODE_FLAG)) {
|
||||||
|
options.customResticParams = [...options.customResticParams, IGNORE_INODE_FLAG];
|
||||||
|
}
|
||||||
|
|
||||||
const repositoryConfig = await decryptRepositoryConfig(repository.config);
|
const repositoryConfig = await decryptRepositoryConfig(repository.config);
|
||||||
const encryptedResticPassword = await resticDeps.getOrganizationResticPassword(organizationId);
|
const encryptedResticPassword = await resticDeps.getOrganizationResticPassword(organizationId);
|
||||||
|
|
@ -55,22 +58,19 @@ const createBackupRunPayload = async ({
|
||||||
jobId,
|
jobId,
|
||||||
scheduleId: schedule.shortId,
|
scheduleId: schedule.shortId,
|
||||||
organizationId,
|
organizationId,
|
||||||
volume: agentVolume,
|
sourcePath,
|
||||||
repositoryConfig,
|
repositoryConfig,
|
||||||
options: {
|
options: {
|
||||||
oneFileSystem: schedule.oneFileSystem,
|
...options,
|
||||||
excludePatterns: schedule.excludePatterns,
|
|
||||||
excludeIfPresent: schedule.excludeIfPresent,
|
|
||||||
includePaths: schedule.includePaths,
|
|
||||||
includePatterns: schedule.includePatterns,
|
|
||||||
customResticParams:
|
|
||||||
FUSE_VOLUME_BACKENDS.has(volume.type) && !customResticParams.includes(IGNORE_INODE_FLAG)
|
|
||||||
? [...customResticParams, IGNORE_INODE_FLAG]
|
|
||||||
: customResticParams,
|
|
||||||
compressionMode: repository.compressionMode ?? "auto",
|
compressionMode: repository.compressionMode ?? "auto",
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
password: resticPassword,
|
password: resticPassword,
|
||||||
|
cacheDir: resticDeps.resticCacheDir,
|
||||||
|
passFile: resticDeps.resticPassFile,
|
||||||
|
defaultExcludes: resticDeps.defaultExcludes,
|
||||||
|
rcloneConfigFile: resticDeps.rcloneConfigFile,
|
||||||
|
hostname: resticDeps.hostname,
|
||||||
},
|
},
|
||||||
webhooks: schedule.backupWebhooks ?? { pre: null, post: null },
|
webhooks: schedule.backupWebhooks ?? { pre: null, post: null },
|
||||||
webhookAllowedOrigins: config.webhookAllowedOrigins,
|
webhookAllowedOrigins: config.webhookAllowedOrigins,
|
||||||
|
|
@ -80,25 +80,17 @@ const createBackupRunPayload = async ({
|
||||||
|
|
||||||
const executeBackupWithoutAgent = async (
|
const executeBackupWithoutAgent = async (
|
||||||
payload: BackupRunPayload,
|
payload: BackupRunPayload,
|
||||||
{ schedule, volume, signal, onProgress }: BackupExecutionRequest,
|
{ signal, onProgress }: Pick<BackupExecutionRequest, "signal" | "onProgress">,
|
||||||
) => {
|
) => {
|
||||||
const sourcePath = getVolumePath(volume);
|
return Effect.runPromise(
|
||||||
const { signal: _, ...backupOptions } = createBackupOptions(schedule, sourcePath, signal);
|
|
||||||
const options = {
|
|
||||||
...backupOptions,
|
|
||||||
customResticParams: payload.options.customResticParams ?? [],
|
|
||||||
compressionMode: payload.options.compressionMode,
|
|
||||||
};
|
|
||||||
|
|
||||||
return runEffectPromise(
|
|
||||||
runBackupLifecycle({
|
runBackupLifecycle({
|
||||||
restic,
|
restic,
|
||||||
repositoryConfig: payload.repositoryConfig,
|
repositoryConfig: payload.repositoryConfig,
|
||||||
sourcePath,
|
sourcePath: payload.sourcePath,
|
||||||
jobId: payload.jobId,
|
jobId: payload.jobId,
|
||||||
scheduleId: payload.scheduleId,
|
scheduleId: payload.scheduleId,
|
||||||
organizationId: payload.organizationId,
|
organizationId: payload.organizationId,
|
||||||
options,
|
options: payload.options,
|
||||||
webhooks: payload.webhooks,
|
webhooks: payload.webhooks,
|
||||||
webhookAllowedOrigins: payload.webhookAllowedOrigins,
|
webhookAllowedOrigins: payload.webhookAllowedOrigins,
|
||||||
webhookTimeoutMs: payload.webhookTimeoutMs,
|
webhookTimeoutMs: payload.webhookTimeoutMs,
|
||||||
|
|
@ -120,7 +112,7 @@ export const backupExecutor = {
|
||||||
activeControllersByScheduleId.delete(scheduleId);
|
activeControllersByScheduleId.delete(scheduleId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
execute: async (request: BackupExecutionRequest) => {
|
execute: async (request: Omit<BackupExecutionRequest, "jobId">) => {
|
||||||
const trackedExecution = activeControllersByScheduleId.get(request.scheduleId);
|
const trackedExecution = activeControllersByScheduleId.get(request.scheduleId);
|
||||||
if (!trackedExecution || trackedExecution.abortController.signal !== request.signal) {
|
if (!trackedExecution || trackedExecution.abortController.signal !== request.signal) {
|
||||||
throw new Error(`Backup execution for schedule ${request.scheduleId} was not tracked`);
|
throw new Error(`Backup execution for schedule ${request.scheduleId} was not tracked`);
|
||||||
|
|
@ -130,7 +122,9 @@ export const backupExecutor = {
|
||||||
throw request.signal.reason || new Error("Operation aborted");
|
throw request.signal.reason || new Error("Operation aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await createBackupRunPayload(request);
|
const jobId = Bun.randomUUIDv7();
|
||||||
|
|
||||||
|
const payload = await createBackupRunPayload({ ...request, jobId });
|
||||||
|
|
||||||
if (request.signal.aborted) {
|
if (request.signal.aborted) {
|
||||||
throw request.signal.reason || new Error("Operation aborted");
|
throw request.signal.reason || new Error("Operation aborted");
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { CronExpressionParser } from "cron-parser";
|
import { CronExpressionParser } from "cron-parser";
|
||||||
import { createBackupOptions as createAgentBackupOptions } from "../../../../apps/agent/src/commands/helpers/backup.helpers";
|
import path from "node:path";
|
||||||
import type { BackupSchedule } from "~/server/db/schema";
|
import type { BackupSchedule } from "~/server/db/schema";
|
||||||
import { toMessage } from "~/server/utils/errors";
|
import { toMessage } from "~/server/utils/errors";
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
|
|
@ -28,20 +28,45 @@ export const isValidCron = (expression: string) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createBackupOptions = (schedule: BackupSchedule, volumePath: string, signal?: AbortSignal) =>
|
export const processPattern = (pattern: string, volumePath: string, relative = false) => {
|
||||||
createAgentBackupOptions(
|
const isNegated = pattern.startsWith("!");
|
||||||
{
|
const p = isNegated ? pattern.slice(1) : pattern;
|
||||||
scheduleId: schedule.shortId,
|
|
||||||
options: {
|
const ensurePatternIsWithinVolume = (candidate: string) => {
|
||||||
oneFileSystem: schedule.oneFileSystem,
|
const resolvedVolumePath = path.resolve(volumePath);
|
||||||
excludePatterns: schedule.excludePatterns,
|
const resolvedCandidatePath = path.resolve(volumePath, candidate);
|
||||||
excludeIfPresent: schedule.excludeIfPresent,
|
const relativePath = path.relative(resolvedVolumePath, resolvedCandidatePath);
|
||||||
includePaths: schedule.includePaths,
|
|
||||||
includePatterns: schedule.includePatterns,
|
if (relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
|
||||||
customResticParams: schedule.customResticParams,
|
throw new Error(`Include pattern escapes volume root: ${pattern}`);
|
||||||
compressionMode: "auto",
|
}
|
||||||
},
|
};
|
||||||
},
|
|
||||||
volumePath,
|
if (!p.startsWith("/")) {
|
||||||
signal,
|
if (!relative) return pattern;
|
||||||
);
|
ensurePatternIsWithinVolume(p);
|
||||||
|
const processed = path.join(volumePath, p);
|
||||||
|
return isNegated ? `!${processed}` : processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relative) {
|
||||||
|
ensurePatternIsWithinVolume(p.slice(1));
|
||||||
|
}
|
||||||
|
const processed = path.join(volumePath, p.slice(1));
|
||||||
|
return isNegated ? `!${processed}` : processed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBackupOptions = (schedule: BackupSchedule, volumePath: string, signal?: AbortSignal) => ({
|
||||||
|
tags: [schedule.shortId],
|
||||||
|
oneFileSystem: schedule.oneFileSystem,
|
||||||
|
signal,
|
||||||
|
exclude: schedule.excludePatterns ? schedule.excludePatterns.map((p) => processPattern(p, volumePath)) : undefined,
|
||||||
|
excludeIfPresent: schedule.excludeIfPresent ?? undefined,
|
||||||
|
includePaths: schedule.includePaths
|
||||||
|
? schedule.includePaths.map((p) => processPattern(p, volumePath, true))
|
||||||
|
: undefined,
|
||||||
|
includePatterns: schedule.includePatterns
|
||||||
|
? schedule.includePatterns.map((p) => processPattern(p, volumePath, true))
|
||||||
|
: undefined,
|
||||||
|
customResticParams: schedule.customResticParams ?? [],
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { backupWebhooksSchema } from "@zerobyte/core/backup-hooks";
|
import { backupWebhooksSchema } from "@zerobyte/core/backup-hooks";
|
||||||
import { publicVolumeSchema } from "@zerobyte/contracts/volumes";
|
import { volumeSchema } from "../volumes/volume.dto";
|
||||||
import { repositorySchema } from "../repositories/repositories.dto";
|
import { repositorySchema } from "../repositories/repositories.dto";
|
||||||
import { backupProgressEventSchema } from "~/schemas/events-dto";
|
import { backupProgressEventSchema } from "~/schemas/events-dto";
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ const backupScheduleSchema = z.object({
|
||||||
nextBackupAt: z.number().nullable(),
|
nextBackupAt: z.number().nullable(),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
updatedAt: z.number(),
|
updatedAt: z.number(),
|
||||||
volume: publicVolumeSchema,
|
volume: volumeSchema,
|
||||||
repository: repositorySchema,
|
repository: repositorySchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,29 +28,7 @@ import { getScheduleByIdOrShortId } from "./helpers/backup-schedule-lookups";
|
||||||
import { copyToMirrors, runForget, syncSnapshotsToMirror } from "./helpers/backup-maintenance";
|
import { copyToMirrors, runForget, syncSnapshotsToMirror } from "./helpers/backup-maintenance";
|
||||||
import { restic } from "../../core/restic";
|
import { restic } from "../../core/restic";
|
||||||
import { mirrorQueries } from "./backups.queries";
|
import { mirrorQueries } from "./backups.queries";
|
||||||
import { runEffectPromise, toMessage } from "../../utils/errors";
|
import { 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 listSchedules = async () => {
|
||||||
const organizationId = getOrganizationId();
|
const organizationId = getOrganizationId();
|
||||||
const schedules = await db.query.backupSchedulesTable.findMany({
|
const schedules = await db.query.backupSchedulesTable.findMany({
|
||||||
|
|
@ -95,10 +73,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
|
||||||
|
|
||||||
const repository = await db.query.repositoriesTable.findFirst({
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [{ OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] }, { organizationId }],
|
||||||
{ OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] },
|
|
||||||
{ organizationId },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -174,10 +149,7 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
|
||||||
|
|
||||||
const repository = await db.query.repositoriesTable.findFirst({
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [{ OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] }, { organizationId }],
|
||||||
{ OR: [{ id: data.repositoryId }, { shortId: { eq: asShortId(data.repositoryId) } }] },
|
|
||||||
{ organizationId },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -187,11 +159,7 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
|
||||||
|
|
||||||
const cronExpression = data.cronExpression ?? schedule.cronExpression;
|
const cronExpression = data.cronExpression ?? schedule.cronExpression;
|
||||||
const nextBackupAt =
|
const nextBackupAt =
|
||||||
data.cronExpression === ""
|
data.cronExpression === "" ? null : data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt;
|
||||||
? null
|
|
||||||
: data.cronExpression
|
|
||||||
? calculateNextRun(cronExpression)
|
|
||||||
: schedule.nextBackupAt;
|
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(backupSchedulesTable)
|
.update(backupSchedulesTable)
|
||||||
|
|
@ -383,12 +351,7 @@ const reorderSchedules = async (scheduleShortIds: ShortId[]) => {
|
||||||
for (const [index, scheduleId] of scheduleIds.entries()) {
|
for (const [index, scheduleId] of scheduleIds.entries()) {
|
||||||
tx.update(backupSchedulesTable)
|
tx.update(backupSchedulesTable)
|
||||||
.set({ sortOrder: index, updatedAt: now })
|
.set({ sortOrder: index, updatedAt: now })
|
||||||
.where(
|
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)))
|
||||||
and(
|
|
||||||
eq(backupSchedulesTable.id, scheduleId),
|
|
||||||
eq(backupSchedulesTable.organizationId, organizationId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -439,16 +402,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||||
...(ctx.schedule.cronExpression ? { nextBackupAt: calculateNextRun(ctx.schedule.cronExpression) } : {}),
|
...(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);
|
const abortController = backupExecutor.track(scheduleId);
|
||||||
let domainHandlerCompleted = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const releaseLock = await repoMutex.acquireShared(
|
const releaseLock = await repoMutex.acquireShared(
|
||||||
|
|
@ -458,10 +412,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
taskStore.markRunning(task.id);
|
|
||||||
|
|
||||||
const executionResult = await backupExecutor.execute({
|
const executionResult = await backupExecutor.execute({
|
||||||
jobId: task.id,
|
|
||||||
scheduleId,
|
scheduleId,
|
||||||
schedule: ctx.schedule,
|
schedule: ctx.schedule,
|
||||||
volume: ctx.volume,
|
volume: ctx.volume,
|
||||||
|
|
@ -470,63 +421,33 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
updateBackupProgress(ctx, 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) {
|
switch (executionResult.status) {
|
||||||
case "unavailable": {
|
case "unavailable":
|
||||||
await handleBackupFailure(scheduleId, ctx.organizationId, executionResult.error, manual, ctx);
|
return handleBackupFailure(scheduleId, ctx.organizationId, executionResult.error, manual, ctx);
|
||||||
domainHandlerCompleted = true;
|
|
||||||
taskStore.fail(task.id, toMessage(executionResult.error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "completed":
|
case "completed":
|
||||||
await finalizeSuccessfulBackup(
|
return finalizeSuccessfulBackup(
|
||||||
ctx,
|
ctx,
|
||||||
executionResult.exitCode,
|
executionResult.exitCode,
|
||||||
executionResult.result,
|
executionResult.result,
|
||||||
executionResult.warningDetails,
|
executionResult.warningDetails,
|
||||||
);
|
);
|
||||||
domainHandlerCompleted = true;
|
case "failed":
|
||||||
taskStore.complete(task.id, {
|
return handleBackupFailure(scheduleId, ctx.organizationId, executionResult.error, manual, ctx);
|
||||||
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":
|
case "cancelled":
|
||||||
await handleBackupCancellation(scheduleId, ctx.organizationId, executionResult.message);
|
return handleBackupCancellation(scheduleId, ctx.organizationId, executionResult.message);
|
||||||
domainHandlerCompleted = true;
|
|
||||||
taskStore.cancel(task.id, executionResult.message ?? "Backup was stopped by the user");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock();
|
releaseLock();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
taskStore.cancel(task.id, "Backup was stopped by the user");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domainHandlerCompleted) {
|
return handleBackupFailure(scheduleId, ctx.organizationId, error, manual, ctx);
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleBackupFailure(scheduleId, ctx.organizationId, error, manual, ctx);
|
|
||||||
taskStore.fail(task.id, toMessage(error));
|
|
||||||
} finally {
|
} finally {
|
||||||
backupExecutor.untrack(scheduleId, abortController);
|
backupExecutor.untrack(scheduleId, abortController);
|
||||||
cache.del(cacheKeys.backup.progress(scheduleId));
|
cache.del(cacheKeys.backup.progress(scheduleId));
|
||||||
|
|
@ -546,26 +467,8 @@ const stopBackup = async (scheduleId: number) => {
|
||||||
throw new NotFoundError("Backup schedule not found");
|
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 {
|
try {
|
||||||
if (!(await backupExecutor.cancel(scheduleId))) {
|
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");
|
throw new ConflictError("No backup is currently running for this schedule");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -595,15 +498,10 @@ const getMirrorSyncStatus = async (scheduleIdOrShortId: number | string, mirrorS
|
||||||
throw new NotFoundError("Mirror not found for this schedule");
|
throw new NotFoundError("Mirror not found for this schedule");
|
||||||
}
|
}
|
||||||
|
|
||||||
const [sourceSnapshots, mirrorSnapshots] = await runEffectPromise(
|
const [sourceSnapshots, mirrorSnapshots] = await Promise.all([
|
||||||
Effect.all(
|
restic.snapshots(schedule.repository.config, { tags: [schedule.shortId], organizationId }),
|
||||||
[
|
restic.snapshots(mirrorRepo.config, { tags: [schedule.shortId], organizationId }),
|
||||||
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));
|
const mirrorSnapshotTimes = new Set(mirrorSnapshots.map((s) => s.time));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import { scheduleQueries } from "../backups.queries";
|
||||||
import type { BackupExecutionProgress } from "../../agents/agents-manager";
|
import type { BackupExecutionProgress } from "../../agents/agents-manager";
|
||||||
import { repositoriesService } from "../../repositories/repositories.service";
|
import { repositoriesService } from "../../repositories/repositories.service";
|
||||||
import { volumeService } from "../../volumes/volume.service";
|
import { volumeService } from "../../volumes/volume.service";
|
||||||
import { config } from "../../../core/config";
|
|
||||||
import { LOCAL_AGENT_ID } from "../../agents/constants";
|
|
||||||
import { copyToMirrors, runForget } from "./backup-maintenance";
|
import { copyToMirrors, runForget } from "./backup-maintenance";
|
||||||
|
|
||||||
interface BackupContext {
|
interface BackupContext {
|
||||||
|
|
@ -42,9 +40,6 @@ type ValidationSkipped = {
|
||||||
|
|
||||||
type ValidationResult = ValidationSuccess | ValidationFailure | ValidationSkipped;
|
type ValidationResult = ValidationSuccess | ValidationFailure | ValidationSkipped;
|
||||||
|
|
||||||
const requiresControllerLocalVolumeReadiness = (volume: Volume) =>
|
|
||||||
volume.agentId === LOCAL_AGENT_ID && !config.flags.enableLocalAgent;
|
|
||||||
|
|
||||||
export function getBackupProgress(scheduleId: number): BackupProgressEventDto | undefined {
|
export function getBackupProgress(scheduleId: number): BackupProgressEventDto | undefined {
|
||||||
return cache.get<BackupProgressEventDto>(cacheKeys.backup.progress(scheduleId));
|
return cache.get<BackupProgressEventDto>(cacheKeys.backup.progress(scheduleId));
|
||||||
}
|
}
|
||||||
|
|
@ -77,13 +72,6 @@ export async function validateBackupExecution(scheduleId: number, manual = false
|
||||||
return { type: "failure", error: new NotFoundError("Repository not found"), partialContext: { schedule, volume } };
|
return { type: "failure", error: new NotFoundError("Repository not found"), partialContext: { schedule, volume } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!requiresControllerLocalVolumeReadiness(volume)) {
|
|
||||||
return {
|
|
||||||
type: "success",
|
|
||||||
context: { schedule, volume, repository, organizationId },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const volumeReadiness = await volumeService.ensureHealthyVolume(volume.shortId);
|
const volumeReadiness = await volumeService.ensureHealthyVolume(volume.shortId);
|
||||||
|
|
||||||
if (!volumeReadiness.ready) {
|
if (!volumeReadiness.ready) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { restic } from "../../../core/restic";
|
||||||
import { repoMutex } from "../../../core/repository-mutex";
|
import { repoMutex } from "../../../core/repository-mutex";
|
||||||
import { serverEvents } from "../../../core/events";
|
import { serverEvents } from "../../../core/events";
|
||||||
import { cache, cacheKeys } from "../../../utils/cache";
|
import { cache, cacheKeys } from "../../../utils/cache";
|
||||||
import { runEffectPromise, toMessage } from "../../../utils/errors";
|
import { toMessage } from "../../../utils/errors";
|
||||||
import { getOrganizationId } from "~/server/core/request-context";
|
import { getOrganizationId } from "~/server/core/request-context";
|
||||||
import { mirrorQueries, repositoryQueries, scheduleQueries } from "../backups.queries";
|
import { mirrorQueries, repositoryQueries, scheduleQueries } from "../backups.queries";
|
||||||
|
|
||||||
|
|
@ -31,9 +31,7 @@ export async function runForget(scheduleId: number, repositoryId?: string, organ
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`);
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runEffectPromise(
|
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId });
|
||||||
restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId }),
|
|
||||||
);
|
|
||||||
cache.delByPrefix(cacheKeys.repository.all(repository.id));
|
cache.delByPrefix(cacheKeys.repository.all(repository.id));
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock();
|
releaseLock();
|
||||||
|
|
@ -120,13 +118,11 @@ export async function syncSnapshotsToMirror(
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runEffectPromise(
|
await restic.copy(sourceRepository.config, mirrorRepository.config, {
|
||||||
restic.copy(sourceRepository.config, mirrorRepository.config, {
|
tag: schedule.shortId,
|
||||||
tag: schedule.shortId,
|
organizationId,
|
||||||
organizationId,
|
snapshotIds,
|
||||||
snapshotIds,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
cache.delByPrefix(cacheKeys.repository.all(mirrorRepository.id));
|
cache.delByPrefix(cacheKeys.repository.all(mirrorRepository.id));
|
||||||
} finally {
|
} finally {
|
||||||
releaseLocks();
|
releaseLocks();
|
||||||
|
|
@ -210,12 +206,7 @@ async function copyToSingleMirror(
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runEffectPromise(
|
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId });
|
||||||
restic.copy(sourceRepository.config, mirror.repository.config, {
|
|
||||||
tag: schedule.shortId,
|
|
||||||
organizationId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
cache.delByPrefix(cacheKeys.repository.all(mirror.repository.id));
|
cache.delByPrefix(cacheKeys.repository.all(mirror.repository.id));
|
||||||
} finally {
|
} finally {
|
||||||
releaseLocks();
|
releaseLocks();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { Scheduler } from "../../../core/scheduler";
|
import { Scheduler } from "../../../core/scheduler";
|
||||||
import * as bootstrapModule from "../bootstrap";
|
import * as bootstrapModule from "../bootstrap";
|
||||||
import { agentManager } from "../../agents/agents-manager";
|
import { agentManager } from "../../agents/agents-manager";
|
||||||
|
|
@ -12,14 +12,8 @@ const loadShutdownModule = async () => {
|
||||||
return import(moduleUrl.href);
|
return import(moduleUrl.href);
|
||||||
};
|
};
|
||||||
|
|
||||||
let originalEnableLocalAgent: boolean;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalEnableLocalAgent = config.flags.enableLocalAgent;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
config.flags.enableLocalAgent = originalEnableLocalAgent;
|
config.flags.enableLocalAgent = true;
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -53,12 +47,9 @@ describe("shutdown", () => {
|
||||||
|
|
||||||
expect(events).toEqual(["scheduler.stop", "agents.stop"]);
|
expect(events).toEqual(["scheduler.stop", "agents.stop"]);
|
||||||
expect(runVolumeCommand).not.toHaveBeenCalled();
|
expect(runVolumeCommand).not.toHaveBeenCalled();
|
||||||
const updated = await db.query.volumesTable.findFirst({ where: { id: volume.id } });
|
|
||||||
expect(updated).toBeDefined();
|
|
||||||
expect(updated!.status).toBe("mounted");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps mounted status while running the legacy controller-local fallback unmount on shutdown", async () => {
|
test("keeps legacy controller-local fallback unmount on shutdown", async () => {
|
||||||
config.flags.enableLocalAgent = false;
|
config.flags.enableLocalAgent = false;
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
vi.spyOn(Scheduler, "stop").mockImplementation(async () => {
|
vi.spyOn(Scheduler, "stop").mockImplementation(async () => {
|
||||||
|
|
@ -67,9 +58,6 @@ describe("shutdown", () => {
|
||||||
vi.spyOn(bootstrapModule, "stopApplicationRuntime").mockImplementation(async () => {
|
vi.spyOn(bootstrapModule, "stopApplicationRuntime").mockImplementation(async () => {
|
||||||
events.push("agents.stop");
|
events.push("agents.stop");
|
||||||
});
|
});
|
||||||
const runVolumeCommand = vi.spyOn(agentManager, "runVolumeCommand").mockImplementation(async () => {
|
|
||||||
throw new Error("runVolumeCommand should not be called during fallback shutdown");
|
|
||||||
});
|
|
||||||
|
|
||||||
const volume = await createTestVolume({
|
const volume = await createTestVolume({
|
||||||
name: "Fallback shutdown test volume",
|
name: "Fallback shutdown test volume",
|
||||||
|
|
@ -83,8 +71,6 @@ describe("shutdown", () => {
|
||||||
|
|
||||||
const updated = await db.query.volumesTable.findFirst({ where: { id: volume.id } });
|
const updated = await db.query.volumesTable.findFirst({ where: { id: volume.id } });
|
||||||
expect(events).toEqual(["scheduler.stop", "agents.stop"]);
|
expect(events).toEqual(["scheduler.stop", "agents.stop"]);
|
||||||
expect(runVolumeCommand).not.toHaveBeenCalled();
|
expect(updated?.status).toBe("unmounted");
|
||||||
expect(updated).toBeDefined();
|
|
||||||
expect(updated!.status).toBe("mounted");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
@ -4,8 +4,6 @@ import { v00002 } from "./migrations/00002-isolate-restic-passwords";
|
||||||
import { v00003 } from "./migrations/00003-assign-organization";
|
import { v00003 } from "./migrations/00003-assign-organization";
|
||||||
import { v00004 } from "./migrations/00004-concat-path-name";
|
import { v00004 } from "./migrations/00004-concat-path-name";
|
||||||
import { v00005 } from "./migrations/00005-split-backup-include-paths";
|
import { v00005 } from "./migrations/00005-split-backup-include-paths";
|
||||||
import { v00006 } from "./migrations/00006-map-smb-files-to-container-uid-gid";
|
|
||||||
import { v00007 } from "./migrations/00007-require-recovery-key-redownload";
|
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { appMetadataTable, usersTable } from "../../db/schema";
|
import { appMetadataTable, usersTable } from "../../db/schema";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
|
|
@ -18,12 +16,7 @@ const recordMigrationCheckpoint = async (version: string): Promise<void> => {
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(appMetadataTable)
|
.insert(appMetadataTable)
|
||||||
.values({
|
.values({ key, value: JSON.stringify({ completedAt: new Date().toISOString() }), createdAt: now, updatedAt: now })
|
||||||
key,
|
|
||||||
value: JSON.stringify({ completedAt: new Date().toISOString() }),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: appMetadataTable.key,
|
target: appMetadataTable.key,
|
||||||
set: { value: JSON.stringify({ completedAt: new Date().toISOString() }), updatedAt: now },
|
set: { value: JSON.stringify({ completedAt: new Date().toISOString() }), updatedAt: now },
|
||||||
|
|
@ -45,7 +38,7 @@ type MigrationEntity = {
|
||||||
dependsOn?: string[];
|
dependsOn?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004, v00005, v00006, v00007];
|
const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004, v00005];
|
||||||
|
|
||||||
export const runMigrations = async () => {
|
export const runMigrations = async () => {
|
||||||
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
|
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { logger } from "@zerobyte/core/node";
|
||||||
import { toMessage } from "~/server/utils/errors";
|
import { toMessage } from "~/server/utils/errors";
|
||||||
import { cryptoUtils } from "~/server/utils/crypto";
|
import { cryptoUtils } from "~/server/utils/crypto";
|
||||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||||
import type { BackendConfig } from "@zerobyte/contracts/volumes";
|
import type { BackendConfig } from "~/schemas/volumes";
|
||||||
import type { NotificationConfig } from "~/schemas/notifications";
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
import { RESTIC_PASS_FILE } from "~/server/core/constants";
|
import { RESTIC_PASS_FILE } from "~/server/core/constants";
|
||||||
import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
|
import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { logger } from "@zerobyte/core/node";
|
|
||||||
import { db } from "../../../db/db";
|
|
||||||
import { volumesTable } from "../../../db/schema";
|
|
||||||
import { toMessage } from "~/server/utils/errors";
|
|
||||||
|
|
||||||
const execute = async () => {
|
|
||||||
const errors: Array<{ name: string; error: string }> = [];
|
|
||||||
const volumes = await db.query.volumesTable.findMany();
|
|
||||||
let migratedCount = 0;
|
|
||||||
|
|
||||||
for (const volume of volumes) {
|
|
||||||
if (volume.type !== "smb" || volume.config.backend !== "smb" || volume.config.mapToContainerUidGid !== undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.update(volumesTable)
|
|
||||||
.set({
|
|
||||||
config: { ...volume.config, mapToContainerUidGid: true },
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
})
|
|
||||||
.where(eq(volumesTable.id, volume.id));
|
|
||||||
|
|
||||||
migratedCount += 1;
|
|
||||||
} catch (error) {
|
|
||||||
errors.push({
|
|
||||||
name: `volume:${volume.id}`,
|
|
||||||
error: toMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Migration 00006-map-smb-files-to-container-uid-gid updated ${migratedCount} SMB volumes.`);
|
|
||||||
|
|
||||||
return { success: errors.length === 0, errors };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const v00006 = {
|
|
||||||
execute,
|
|
||||||
id: "00006-map-smb-files-to-container-uid-gid",
|
|
||||||
type: "maintenance" as const,
|
|
||||||
};
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
|
||||||
import { logger } from "@zerobyte/core/node";
|
|
||||||
import { db } from "../../../db/db";
|
|
||||||
import { account, member, usersTable } from "../../../db/schema";
|
|
||||||
import { toMessage } from "~/server/utils/errors";
|
|
||||||
|
|
||||||
const execute = async () => {
|
|
||||||
const errors: Array<{ name: string; error: string }> = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const affectedUsers = await db
|
|
||||||
.select({ id: usersTable.id })
|
|
||||||
.from(usersTable)
|
|
||||||
.innerJoin(account, and(eq(account.userId, usersTable.id), eq(account.providerId, "credential")))
|
|
||||||
.innerJoin(member, and(eq(member.userId, usersTable.id), inArray(member.role, ["owner", "admin"])))
|
|
||||||
.where(eq(usersTable.hasDownloadedResticPassword, true));
|
|
||||||
const affectedUserIds = [...new Set(affectedUsers.map((user) => user.id))];
|
|
||||||
|
|
||||||
if (affectedUserIds.length > 0) {
|
|
||||||
await db
|
|
||||||
.update(usersTable)
|
|
||||||
.set({ hasDownloadedResticPassword: false })
|
|
||||||
.where(inArray(usersTable.id, affectedUserIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Migration 00007-require-recovery-key-redownload marked ${affectedUserIds.length} users for recovery key re-download.`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
errors.push({
|
|
||||||
name: "recovery-key-redownload",
|
|
||||||
error: toMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: errors.length === 0, errors };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const v00007 = {
|
|
||||||
execute,
|
|
||||||
id: "00007-require-recovery-key-redownload",
|
|
||||||
type: "maintenance" as const,
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue