├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── docs.yml │ ├── release-please.yml │ ├── release.yml │ ├── test.yml │ └── update-restic.yml ├── .gitignore ├── .goreleaser.yaml ├── .vscode ├── launch.json └── tasks.json ├── CHANGELOG.md ├── Dockerfile.alpine ├── Dockerfile.scratch ├── LICENSE ├── README.md ├── build └── windows │ ├── icon.ico │ └── install.nsi ├── cmd ├── backrest │ └── backrest.go └── backrestmon │ ├── backrestmon.go │ └── icon.ico ├── docs ├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── .prettierignore ├── app.config.ts ├── content │ ├── 0.index.md │ ├── 1.introduction │ │ ├── 1.getting-started.md │ │ ├── 2.restore-files.md │ │ └── _dir.yml │ ├── 2.docs │ │ ├── 1.operations.md │ │ ├── 2.hooks.md │ │ ├── 3.api.md │ │ └── _dir.yml │ └── 3.cookbooks │ │ ├── 1.command-hook-examples.md │ │ ├── 2.slack-hook-build-kit-examples.md │ │ ├── 3.reverse-proxy-examples.md │ │ ├── 4.ssh-remote.md │ │ └── _dir.yml ├── nuxt.config.ts ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── screenshots │ │ ├── add-plan-view.png │ │ ├── add-repo-view.png │ │ ├── index-snapshots-btn.png │ │ ├── restore-dialog.png │ │ ├── restore-progress.png │ │ ├── settings-view.png │ │ ├── slack-clean-job.png │ │ └── tree-view-for-restore-article.png ├── renovate.json ├── tokens.config.ts └── tsconfig.json ├── gen └── go │ ├── google │ └── api │ │ ├── annotations.pb.go │ │ └── http.pb.go │ ├── types │ └── value.pb.go │ └── v1 │ ├── authentication.pb.go │ ├── authentication_grpc.pb.go │ ├── config.pb.go │ ├── crypto.pb.go │ ├── operations.pb.go │ ├── restic.pb.go │ ├── service.pb.go │ ├── service_grpc.pb.go │ ├── syncservice.pb.go │ ├── syncservice_grpc.pb.go │ └── v1connect │ ├── authentication.connect.go │ ├── service.connect.go │ └── syncservice.connect.go ├── go.mod ├── go.sum ├── install.sh ├── internal ├── api │ ├── authenticationhandler.go │ ├── backresthandler.go │ ├── backresthandler_test.go │ ├── downloadhandler.go │ ├── signing.go │ └── syncapi │ │ ├── identity.go │ │ ├── identity_test.go │ │ ├── remoteconfigstore.go │ │ ├── syncapi_test.go │ │ ├── syncclient.go │ │ ├── synchandler.go │ │ ├── syncmanager.go │ │ └── uriutil.go ├── auth │ ├── auth.go │ ├── auth_test.go │ ├── bearer.go │ └── middleware.go ├── config │ ├── config.go │ ├── config_test.go │ ├── configutil.go │ ├── jsonstore.go │ ├── memstore.go │ ├── migrations │ │ ├── 003relativescheduling.go │ │ ├── 003relativescheduling_test.go │ │ ├── 004repoguid.go │ │ ├── migrations.go │ │ └── migrations_test.go │ ├── validate.go │ └── validationutil │ │ ├── validationutil.go │ │ └── validationutil_test.go ├── cryptoutil │ └── idutil.go ├── env │ └── environment.go ├── hook │ ├── errors.go │ ├── hook.go │ ├── hook_test.go │ ├── hookutil │ │ ├── httputil.go │ │ └── templateutil.go │ └── types │ │ ├── command.go │ │ ├── discord.go │ │ ├── gotify.go │ │ ├── healthchecks.go │ │ ├── registry.go │ │ ├── shoutrrr.go │ │ └── slack.go ├── ioutil │ ├── iobatching.go │ └── ioutil.go ├── logstore │ ├── logstore.go │ ├── logstore_test.go │ ├── shardedmutex.go │ └── tarmigrate.go ├── metric │ └── metric.go ├── oplog │ ├── memstore │ │ └── memstore.go │ ├── migrations.go │ ├── oplog.go │ ├── query.go │ ├── randmodno.go │ ├── sqlitestore │ │ ├── migrations.go │ │ ├── migrations_test.go │ │ ├── sqlitestore.go │ │ └── sqlutil.go │ └── storetests │ │ └── storecontract_test.go ├── orchestrator │ ├── logging │ │ └── logging.go │ ├── orchestrator.go │ ├── orchestrator_test.go │ ├── repo │ │ ├── command_prefix.go │ │ ├── env.go │ │ ├── logging.go │ │ ├── repo.go │ │ ├── repo_test.go │ │ └── tags.go │ ├── scheduling_test.go │ ├── taskrunnerimpl.go │ └── tasks │ │ ├── errors.go │ │ ├── flowidutil.go │ │ ├── hookvars.go │ │ ├── hookvars_test.go │ │ ├── scheduling_test.go │ │ ├── task.go │ │ ├── taskbackup.go │ │ ├── taskcheck.go │ │ ├── taskcollectgarbage.go │ │ ├── taskforget.go │ │ ├── taskforgetsnapshot.go │ │ ├── taskindexsnapshots.go │ │ ├── taskprune.go │ │ ├── taskrestore.go │ │ ├── taskruncommand.go │ │ └── taskstats.go ├── protoutil │ ├── conditions.go │ ├── conditions_test.go │ ├── conversion.go │ ├── conversion_test.go │ ├── opselector.go │ ├── schedule.go │ ├── syncconversion.go │ ├── syncconversion_test.go │ └── validation.go ├── queue │ ├── genheap.go │ ├── genheap_test.go │ ├── timepriorityqueue.go │ ├── timepriorityqueue_test.go │ ├── timequeue.go │ └── timequeue_test.go ├── resticinstaller │ ├── downloadhelper.go │ ├── resticinstaller.go │ ├── urls.go │ ├── verify.go │ ├── version.go │ └── version_test.go └── testutil │ ├── deadline.go │ ├── logging.go │ ├── operations.go │ └── try.go ├── pkg └── restic │ ├── error.go │ ├── logging.go │ ├── outputs.go │ ├── outputs_test.go │ ├── restic.go │ └── restic_test.go ├── proto ├── buf.gen.yaml ├── buf.yaml ├── google │ └── api │ │ ├── annotations.proto │ │ └── http.proto ├── types │ └── value.proto ├── update.sh └── v1 │ ├── authentication.proto │ ├── config.proto │ ├── crypto.proto │ ├── operations.proto │ ├── restic.proto │ ├── service.proto │ └── syncservice.proto ├── scripts ├── generate-installers.sh ├── latest-restic-version.sh ├── manage.sh ├── testing │ ├── ramdisk-mount.sh │ ├── ramdisk-unmount.sh │ ├── run-fresh.sh │ └── run-in-dir.sh └── update-restic-version.sh ├── test └── helpers │ ├── installrestic.go │ └── testdata.go ├── uninstall.sh └── webui ├── .gitignore ├── .proxyrc.json ├── assets ├── favicon.png ├── favicon.svg ├── logo-black.svg └── logo.svg ├── gen └── ts │ ├── google │ └── api │ │ ├── annotations_pb.ts │ │ └── http_pb.ts │ ├── types │ └── value_pb.ts │ └── v1 │ ├── authentication_pb.ts │ ├── config_pb.ts │ ├── crypto_pb.ts │ ├── operations_pb.ts │ ├── restic_pb.ts │ ├── service_pb.ts │ └── syncservice_pb.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── .prettierrc ├── api.ts ├── components │ ├── ActivityBar.tsx │ ├── Alerts.tsx │ ├── ConfigProvider.tsx │ ├── HooksFormList.tsx │ ├── LogView.tsx │ ├── ModalManager.tsx │ ├── OperationIcon.tsx │ ├── OperationListView.tsx │ ├── OperationRow.tsx │ ├── OperationTreeView.tsx │ ├── ScheduleFormItem.tsx │ ├── SnapshotBrowser.tsx │ ├── SpinButton.tsx │ ├── StatsPanel.tsx │ └── URIAutocomplete.tsx ├── constants.ts ├── index.html ├── index.sass ├── index.tsx ├── lib │ ├── browserutil.ts │ ├── cronutil.ts │ ├── formatting.ts │ └── formutil.ts ├── state │ ├── buildcfg.ts │ ├── configutil.ts │ ├── flowdisplayaggregator.ts │ ├── logstate.ts │ └── oplog.ts └── views │ ├── AddPlanModal.tsx │ ├── AddRepoModal.tsx │ ├── App.tsx │ ├── GettingStartedGuide.tsx │ ├── LoginModal.tsx │ ├── MainContentArea.tsx │ ├── PlanView.tsx │ ├── RepoView.tsx │ ├── RunCommandModal.tsx │ ├── SettingsModal.tsx │ └── SummaryDashboard.tsx ├── tsconfig.json ├── webui.go ├── webui_test.go ├── webuinix.go └── webuiwin.go /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ## Based on microsoft go devcontainer - https://github.com/microsoft/vscode-dev-containers/blob/v0.205.2/containers/go/.devcontainer/Dockerfile 2 | # [Choice] Go version (use -bullseye variants on local arm64/Apple Silicon): 1, 1.16, 1.17, 1-bullseye, 1.16-bullseye, 1.17-bullseye, 1-buster, 1.16-buster, 1.17-buster 3 | ARG VARIANT=1-bullseye 4 | FROM mcr.microsoft.com/vscode/devcontainers/go:${VARIANT} 5 | 6 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 7 | ARG NODE_VERSION="none" 8 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 9 | 10 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 11 | && apt-get -y install --no-install-recommends protobuf-compiler 12 | 13 | USER vscode 14 | 15 | RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest \ 16 | && go install github.com/bufbuild/buf/cmd/buf@v1.27.2 \ 17 | && go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest \ 18 | && go install google.golang.org/protobuf/cmd/protoc-gen-go@latest \ 19 | && go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest \ 20 | && go install github.com/GeertJohan/go.rice/rice@latest \ 21 | && go install github.com/goreleaser/goreleaser@latest \ 22 | && npm install -g @bufbuild/protoc-gen-es @connectrpc/protoc-gen-connect-es 23 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Go", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "args": { 6 | // Update the VARIANT arg to pick a version of Go: 1, 1.16, 1.17 7 | // Append -bullseye or -buster to pin to an OS version. 8 | // Use -bullseye variants on local arm64/Apple Silicon. 9 | "VARIANT": "1-1.24-bookworm", 10 | // Options 11 | "NODE_VERSION": "lts/*" 12 | } 13 | }, 14 | "runArgs": [ 15 | "--cap-add=SYS_PTRACE", 16 | "--security-opt", 17 | "seccomp=unconfined" 18 | ], 19 | "customizations": { 20 | "vscode": { 21 | // Set *default* container specific settings.json values on container create. 22 | "settings": { 23 | "go.toolsManagement.checkForUpdates": "local", 24 | "go.useLanguageServer": true, 25 | "go.gopath": "/go", 26 | "go.goroot": "/usr/local/go", 27 | "typescript.tsdk": "webui/node_modules/typescript/lib", 28 | "typescript.enablePromptUseWorkspaceTsdk": true, 29 | "gitlens.telemetry.enabled": false 30 | }, 31 | // Add the IDs of extensions you want installed when the container is created. 32 | "extensions": [ 33 | "golang.Go", 34 | "ms-azuretools.vscode-docker", 35 | "mhutchie.git-graph", 36 | "eamodio.gitlens", 37 | "donjayamanne.githistory", 38 | "esbenp.prettier-vscode", 39 | "iulian-radu-at.vscode-tasks-sidebar" 40 | ] 41 | } 42 | }, 43 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 44 | // "forwardPorts": [], 45 | // Use 'postCreateCommand' to run commands after the container is created. 46 | // "postCreateCommand": "go version", 47 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 48 | "remoteUser": "vscode" 49 | } 50 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | docs/**/*.png filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: garethgeorge 2 | github: garethgeorge 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an issue with Backrest 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | Note: if you have a question or need support please post in the [discussions area](https://github.com/garethgeorge/backrest/discussions). 10 | 11 | **Describe the bug** 12 | A clear and concise description of what the bug is. 13 | 14 | **To Reproduce** 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Screenshots** 26 | If applicable, add screenshots to help explain your problem. 27 | 28 | **Platform Info** 29 | 30 | - OS and Architecture [e.g. Windows 10 x64, Darwin arm64] 31 | - Backrest Version [e.g. 0.0.0] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | Note: if you have a question or want discussion please post in the [discussions area](https://github.com/garethgeorge/backrest/discussions). 10 | 11 | **Is your feature request related to a problem? Please describe.** 12 | A clear and concise description of what the problem is. 13 | 14 | **Describe the solution you'd like** 15 | A clear and concise description of what you want to happen. 16 | 17 | **Additional context** 18 | Add any other context or screenshots about the feature request here. 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Build Snapshot Release 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | paths-ignore: 10 | - "docs/**" 11 | - "*.md" 12 | pull_request: 13 | branches: ["main"] 14 | paths-ignore: 15 | - "docs/**" 16 | - "*.md" 17 | workflow_dispatch: 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@v3 27 | 28 | - name: Set up Go 29 | uses: actions/setup-go@v4 30 | with: 31 | go-version: "1.24" 32 | 33 | - name: Setup NodeJS 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: "20" 37 | 38 | - name: Setup pnpm 39 | uses: pnpm/action-setup@v4 40 | with: 41 | version: 9 42 | 43 | - name: Build 44 | uses: goreleaser/goreleaser-action@v5 45 | with: 46 | distribution: goreleaser 47 | version: latest 48 | args: release --snapshot --clean 49 | 50 | - name: Upload Artifacts 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: backrest-snapshot-builds 54 | path: | 55 | dist/*.tar.gz 56 | dist/*.zip 57 | 58 | - name: Generate Installers 59 | run: | 60 | mkdir -p dist-installers 61 | ./scripts/generate-installers.sh ./dist-installers 62 | 63 | - name: Upload Installers 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: backrest-snapshot-installers 67 | path: dist-installers/*.exe 68 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Docs 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | paths: 10 | - "docs/**" 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | with: 21 | lfs: true 22 | 23 | - name: Set up NodeJS 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: "20" 27 | 28 | - name: Setup pnpm 29 | uses: pnpm/action-setup@v4 30 | with: 31 | version: 9 32 | 33 | - name: Install dependencies 34 | run: pnpm install --prefix ./docs 35 | 36 | - name: Build 37 | run: | 38 | cd docs 39 | pnpm run generate 40 | ls -la .output/public 41 | 42 | - name: Fix permissions 43 | run: | 44 | chmod -c -R +rX "docs/.output/public" | while read line; do 45 | echo "::warning title=Invalid file permissions automatically fixed::$line" 46 | done 47 | 48 | - name: Upload static files as artifact 49 | id: deployment 50 | uses: actions/upload-pages-artifact@v3 # or specific "vX.X.X" version tag for this action 51 | with: 52 | path: docs/.output/public 53 | 54 | deploy: 55 | environment: 56 | name: github-pages 57 | url: ${{ steps.deployment.outputs.page_url }} 58 | permissions: 59 | pages: write 60 | id-token: write 61 | actions: read 62 | runs-on: ubuntu-latest 63 | needs: build 64 | steps: 65 | - name: Deploy to GitHub Pages 66 | id: deployment 67 | uses: actions/deploy-pages@v4 68 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: Release Please 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: google-github-actions/release-please-action@v4 17 | with: 18 | token: ${{ secrets.RELEASE_PLEASE_TOKEN }} 19 | release-type: simple 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Tagged Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | tagged-release: 14 | name: "Tagged Release" 15 | runs-on: "ubuntu-latest" 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up QEMU 23 | uses: docker/setup-qemu-action@v3 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v4 27 | with: 28 | go-version: "1.24" 29 | 30 | - name: Setup NodeJS 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: "20" 34 | 35 | - name: Setup pnpm 36 | uses: pnpm/action-setup@v4 37 | with: 38 | version: 9 39 | 40 | - name: Login to Docker Hub 41 | uses: docker/login-action@v3 42 | with: 43 | username: ${{ secrets.DOCKERHUB_USERNAME }} 44 | password: ${{ secrets.DOCKERHUB_TOKEN }} 45 | 46 | - name: Run GoReleaser 47 | uses: goreleaser/goreleaser-action@v5 48 | with: 49 | distribution: goreleaser 50 | version: latest 51 | args: release --clean 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | HOMEBREW_GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} 55 | 56 | - name: Upload Artifacts 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: release-artifacts 60 | path: dist/* 61 | 62 | tagged-release-installers: 63 | name: "Tagged Release Installers" 64 | runs-on: "ubuntu-latest" 65 | needs: tagged-release 66 | 67 | steps: 68 | - uses: actions/checkout@v4 69 | with: 70 | fetch-depth: 0 71 | 72 | # download dist artifacts from previous job 73 | - name: Download Artifacts 74 | uses: actions/download-artifact@v4 75 | with: 76 | name: release-artifacts 77 | path: dist 78 | 79 | - name: Generate Installers 80 | run: | 81 | mkdir -p dist-installers 82 | ./scripts/generate-installers.sh ./dist-installers 83 | 84 | - name: Upload Assets 85 | uses: softprops/action-gh-release@v2 86 | if: startsWith(github.ref, 'refs/tags/') 87 | with: 88 | files: | 89 | ./dist-installers/Backrest-setup-x86_64.exe 90 | ./dist-installers/Backrest-setup-arm64.exe 91 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | paths-ignore: 10 | - "docs/**" 11 | - "*.md" 12 | pull_request: 13 | branches: ["main"] 14 | paths-ignore: 15 | - "docs/**" 16 | - "*.md" 17 | workflow_dispatch: 18 | 19 | jobs: 20 | test-nix: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v4 27 | with: 28 | go-version: "1.24" 29 | 30 | - name: Install gotestsum 31 | run: go install gotest.tools/gotestsum@latest 32 | 33 | - name: Create Fake WebUI Sources 34 | run: | 35 | mkdir -p webui/dist 36 | # Create an empty gzip file for testing 37 | gzip -c /dev/null > webui/dist/index.html.gz 38 | 39 | - name: Build 40 | run: go build ./... 41 | 42 | - name: Test 43 | run: PATH=$(pwd):$PATH gotestsum ./... -- --race 44 | 45 | test-win: 46 | runs-on: windows-latest 47 | steps: 48 | - uses: actions/checkout@v3 49 | 50 | - name: Set up Go 51 | uses: actions/setup-go@v4 52 | with: 53 | go-version: "1.24" 54 | 55 | - name: Create Fake WebUI Sources 56 | run: | 57 | New-Item -Path .\webui\dist-windows\index.html -ItemType File -Force 58 | 59 | - name: Build 60 | run: go build ./... 61 | 62 | - name: Test 63 | run: go test ./... 64 | -------------------------------------------------------------------------------- /.github/workflows/update-restic.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Update Restic 5 | 6 | on: 7 | schedule: 8 | - cron: "0 0 * * *" 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | 15 | jobs: 16 | update-restic-version: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Check for updates 22 | run: | 23 | ./scripts/update-restic-version.sh 24 | 25 | - name: Create Pull Request 26 | uses: peter-evans/create-pull-request@v7 27 | with: 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | commit-message: "chore: update restic version" 30 | title: "chore: update restic version" 31 | body: Beep boop. Bot generated PR to update backrest to the latest restic version. 32 | assignees: garethgeorge 33 | branch: "update-restic-version" 34 | base: "main" 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test* 2 | backrest-* 3 | dist 4 | __debug_bin 5 | cmd/backrest/backrest 6 | *.exe 7 | .DS_Store 8 | .idea/ 9 | .pnpm-store/ -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Go backend", 6 | "type": "go", 7 | "request": "launch", 8 | "mode": "debug", 9 | "program": "${workspaceFolder}/cmd/backrest/backrest.go", 10 | "output": "__debug_bin", 11 | "preLaunchTask": "Build Webui" 12 | }, 13 | { 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "Parcel", 17 | "postDebugTask": "Terminate Parcel", 18 | "name": "Debug TS frontend", 19 | "url": "http://localhost:1234", 20 | "webRoot": "${workspaceFolder}/webui/src/", 21 | "sourceMapPathOverrides": { 22 | "../*": "${webRoot}/*" 23 | } 24 | } 25 | ], 26 | "compounds": [ 27 | { 28 | "name": "Debug Backrest (backend+frontend)", 29 | "configurations": ["Debug Go backend", "Debug TS frontend"], 30 | "stopAll": true 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "npm i", 6 | "type": "shell", 7 | "command": "cd webui && npx --yes pnpm i" 8 | }, 9 | { 10 | "label": "Parcel", 11 | "type": "npm", 12 | "script": "start", 13 | "dependsOn": "npm i", 14 | "isBackground": true, 15 | "problemMatcher": { 16 | "background": { 17 | "activeOnStart": true, 18 | "beginsPattern": "parcel serve", 19 | "endsPattern": "Built in" 20 | }, 21 | "pattern": { 22 | "regexp": ".*" 23 | } 24 | }, 25 | "path": "webui" 26 | }, 27 | { 28 | "label": "Build Webui", 29 | "type": "npm", 30 | "script": "build", 31 | "path": "webui", 32 | "group": "build", 33 | "problemMatcher": [], 34 | "dependsOn": "npm i" 35 | }, 36 | { 37 | "label": "Terminate Parcel", 38 | "command": "echo ${input:terminate}", 39 | "type": "shell", 40 | "problemMatcher": [] 41 | } 42 | ], 43 | "inputs": [ 44 | { 45 | "id": "terminate", 46 | "type": "command", 47 | "command": "workbench.action.tasks.terminate", 48 | "args": "Parcel" 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /Dockerfile.alpine: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | LABEL org.opencontainers.image.source="https://github.com/garethgeorge/backrest" 3 | RUN apk --no-cache add tini ca-certificates curl bash rclone openssh tzdata docker-cli 4 | RUN mkdir -p /tmp 5 | COPY backrest /backrest 6 | RUN /backrest --install-deps-only 7 | RUN mkdir -p /bin && mv /root/.local/share/backrest/restic /bin/restic 8 | 9 | ENTRYPOINT ["/sbin/tini", "--"] 10 | CMD ["/backrest", "--bind-address", ":9898"] 11 | -------------------------------------------------------------------------------- /Dockerfile.scratch: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS alpine 2 | RUN apk add --no-cache ca-certificates tini-static 3 | RUN mkdir /tmp-orig 4 | COPY backrest /backrest 5 | RUN /backrest --install-deps-only 6 | RUN mkdir -p /bin && mv /root/.local/share/backrest/restic /bin/restic 7 | 8 | FROM scratch 9 | LABEL org.opencontainers.image.source="https://github.com/garethgeorge/backrest" 10 | COPY --from=alpine /tmp-orig /tmp 11 | COPY --from=alpine /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 12 | COPY --from=alpine /bin /bin 13 | COPY --from=alpine /sbin/tini-static /tini 14 | COPY backrest /backrest 15 | 16 | ENTRYPOINT ["/tini", "--"] 17 | CMD ["/backrest", "--bind-address", ":9898"] 18 | -------------------------------------------------------------------------------- /build/windows/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garethgeorge/backrest/9a4e0c5c74c5cdfa3ac4d78d910fcf061f560e4c/build/windows/icon.ico -------------------------------------------------------------------------------- /cmd/backrestmon/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garethgeorge/backrest/9a4e0c5c74c5cdfa3ac4d78d910fcf061f560e4c/cmd/backrestmon/icon.ico -------------------------------------------------------------------------------- /docs/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["@nuxt/eslint-config"], 4 | ignorePatterns: ["dist", "node_modules", ".output", ".nuxt"], 5 | rules: { 6 | "vue/max-attributes-per-line": "off", 7 | "vue/multi-word-component-names": "off", 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.iml 3 | .idea 4 | *.log* 5 | .nuxt 6 | .vscode 7 | .DS_Store 8 | coverage 9 | dist 10 | sw.* 11 | .env 12 | .output 13 | -------------------------------------------------------------------------------- /docs/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /docs/.prettierignore: -------------------------------------------------------------------------------- 1 | * -------------------------------------------------------------------------------- /docs/app.config.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/nuxt-themes/docus/blob/main/nuxt.schema.ts 2 | export default defineAppConfig({ 3 | docus: { 4 | title: "Backrest", 5 | description: "Backrest is a web UI and orchestrator for restic backup.", 6 | // image: 'https://user-images.githubusercontent.com/904724/185365452-87b7ca7b-6030-4813-a2db-5e65c785bf88.png', 7 | socials: { 8 | github: "garethgeorge/backrest", 9 | }, 10 | github: { 11 | dir: "docs/content", 12 | branch: "main", 13 | repo: "backrest", 14 | owner: "garethgeorge", 15 | edit: true, 16 | }, 17 | aside: { 18 | level: 0, 19 | collapsed: false, 20 | exclude: [], 21 | }, 22 | main: { 23 | padded: true, 24 | fluid: true, 25 | }, 26 | header: { 27 | logo: false, 28 | showLinkIcon: true, 29 | exclude: [], 30 | fluid: true, 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /docs/content/0.index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | navigation: false 4 | layout: page 5 | main: 6 | fluid: false 7 | --- 8 | 9 | :ellipsis{right=0px width=75% blur=150px} 10 | 11 | ::block-hero 12 | --- 13 | cta: 14 | - Get started 15 | - /introduction/getting-started 16 | secondary: 17 | - Open on GitHub → 18 | - https://github.com/garethgeorge/backrest 19 | --- 20 | 21 | #title 22 | Web UI and orchestrator for [Restic](https://restic.net) backup. 23 | 24 | #description 25 | 26 | Backrest is a web-accessible backup solution built on top of [restic](https://restic.net/) and providing a WebUI which wraps the restic CLI and makes it easy to create repos, browse snapshots, and restore files. Additionally, Backrest can run in the background and take an opinionated approach to scheduling snapshots and orchestrating repo health operations. 27 | 28 | 29 | #extra 30 | ::list 31 | - Import your existing restic repositories 32 | - Cron scheduled backups and health operations (e.g. prune and forget) 33 | - UI for browsing and restoring files from snapshots 34 | - Configurable backup notifications (e.g. Discord, Slack, Shoutrrr, Gotify) 35 | - Add shell command hooks to run before and after backup operations. 36 | - Compatible with rclone remotes 37 | - Cross-platform support (Linux, macOS, Windows, FreeBSD, [Docker](https://hub.docker.com/r/garethgeorge/backrest)) 38 | - Backup to any restic supported storage (e.g. S3, B2, Azure, GCS, local, SFTP, and all [rclone remotes](https://rclone.org/)) 39 | :: 40 | 41 | #support 42 | ::code-group 43 | ```bash [MacOS] 44 | brew tap garethgeorge/homebrew-backrest-tap 45 | brew install backrest 46 | brew services start backrest 47 | ``` 48 | ```bash [Arch Linux] 49 | paru -Sy backrest 50 | sudo systemctl enable --now backrest@$USER.service 51 | ``` 52 | ```yaml [docker-compose] 53 | version: "3.2" 54 | services: 55 | backrest: 56 | image: garethgeorge/backrest 57 | container_name: backrest 58 | hostname: backrest 59 | volumes: 60 | - ./backrest/data:/data 61 | - ./backrest/config:/config 62 | - ./backrest/cache:/cache 63 | environment: 64 | - BACKREST_DATA=/data 65 | - BACKREST_CONFIG=/config/config.json 66 | - XDG_CACHE_HOME=/cache 67 | - TZ=America/Los_Angeles 68 | restart: unless-stopped 69 | ports: 70 | - 9898:9898 71 | ``` 72 | :: 73 | :: 74 | -------------------------------------------------------------------------------- /docs/content/1.introduction/2.restore-files.md: -------------------------------------------------------------------------------- 1 | # Restore Files 2 | 3 | This guide will walk you through the basic steps of using Backrest to restore files. 4 | 5 | ## Prerequisites 6 | 7 | - A running Backrest instance 8 | - A repo configured in Backrest 9 | 10 | ## Indexing Snapshots 11 | 12 | A snapshot is a point-in-time backup of your files. This is interchangeable with the term "backup". To restore files, you first need to index the snapshots in your repository. This is done automatically by Backrest when you 13 | 14 | 1. first add a repository 15 | 2. run a backup 16 | 17 | if you have recently added your repository or are using backrest to regularly create backups there is nothing to do here. If you've created your backups some other way, you may need to index them before they will show up in the UI. To do this, click the "Index Snapshots" button in the repository view. 18 | 19 | Index Snapshots Button 20 | 21 | ## Restoring a Snapshot 22 | 23 | Once your snapshots are indexed, they are visible in backrest in a tree view ordered by their creation timestamp. To view details about a snapshot, click on it in the tree view. This will open a side panel with the history of operations that created the snapshot (if it was created by this backrest install) as well as the snapshot operation itself which includes: 24 | 25 | * Metadata about the snapshot 26 | * Snapshot browser which can be used to browse and restore files in the snapshot 27 | 28 | Tree View for Restore Article 29 | 30 | To restore a snapshot start by browsing for the files you'd like to restore. Click on the "Snapshot Browser" shown in the red box in the image above. This view will expand to show the files in your snapshot. 31 | 32 | ::alert{type="warning"} 33 | If your repo is using remote storage browsing can be very slow as restic fetches pack files to index the directory structure of your snapshot. 34 | :: 35 | 36 | Once you have found the directory you'd like to restore, hover over the directory and click the restore icon and select "Restore to path". The restore location options are 37 | 38 | 1. Restore to a specific location, the default populated location will be the folder name + the first 8 digets of the snapshot's ID. 39 | 2. If the location is left empty, Backrest will attempt to locate and restore to your Downloads directory. 40 | 41 | Once you have selected a location, click "Restore". Backrest will begin the restore process as a new operation visible at the top of the operation tree. You can monitor the progress of the restore in the operation tree. 42 | 43 | Restore Progress 44 | 45 | -------------------------------------------------------------------------------- /docs/content/1.introduction/_dir.yml: -------------------------------------------------------------------------------- 1 | icon: ph:star-duotone 2 | navigation.redirect: /introduction/getting-started 3 | -------------------------------------------------------------------------------- /docs/content/2.docs/3.api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | Backrest provides a limited HTTP API for interacting with the backrest service. To use the API authentication must be disabled (or you can optionally provide a username and password with basic auth headers) e.g. `curl -u user:password http://localhost:9898/v1/` 4 | 5 | All of Backrest's API endpoints are defined as a gRPC service and are exposed over HTTP by a JSON RPC gateway for easy scripting. For the full service definition see [service.proto](https://github.com/garethgeorge/backrest/blob/main/proto/v1/service.proto). 6 | 7 | ::alert{type="warning"} 8 | Only the APIs documented below are considered stable, other endpoints may be subject to change. 9 | :: 10 | 11 | ### Backup API 12 | 13 | The backup API can be used to trigger execution of a plan e.g. 14 | 15 | ``` 16 | curl -X POST 'localhost:9898/v1.Backrest/Backup' --data '{"value": "YOUR_PLAN_ID"}' -H 'Content-Type: application/json' 17 | ``` 18 | 19 | The request will block until the operation has completed. A 200 response means the backup completed successfully, if the request times out the operation will continue in the background. 20 | ### Operations API 21 | 22 | The operations API can be used to fetch operation history e.g. 23 | 24 | ``` 25 | curl -X POST 'localhost:9898/v1.Backrest/GetOperations' --data '{}' -H 'Content-Type: application/json' 26 | ``` 27 | 28 | More complex selectors can be applied e.g. 29 | 30 | ``` 31 | curl -X POST 'localhost:9898/v1.Backrest/GetOperations' --data '{"selector": {"planId": "YOUR_PLAN_ID"}}' -H 'Content-Type: application/json' 32 | ``` 33 | 34 | For details on the structure of operations returned see the [operations.proto](https://github.com/garethgeorge/backrest/blob/main/proto/v1/operations.proto). 35 | 36 | ::alert{type="warning"} 37 | The structure of the operation history is subject to change over time. Different fields may be added or removed in future versions. 38 | :: 39 | -------------------------------------------------------------------------------- /docs/content/2.docs/_dir.yml: -------------------------------------------------------------------------------- 1 | title: "Docs" 2 | icon: heroicons-outline:bookmark-alt 3 | -------------------------------------------------------------------------------- /docs/content/3.cookbooks/2.slack-hook-build-kit-examples.md: -------------------------------------------------------------------------------- 1 | # Slack Hook Build Kit Examples 2 | 3 | ## Overview 4 | 5 | When using the Slack Hook you can provide a simple message or a [Slack Block Kit](https://api.slack.com/block-kit) message. You can leverage the [Block Kit Builder](https://app.slack.com/block-kit-builder) to Preview the message. 6 | 7 | ## Clean Job Summary 8 | 9 |
10 | Settings View 11 |
12 | 13 | ``` 14 | { 15 | "blocks": [ 16 | { 17 | "type": "section", 18 | "text": { 19 | "type": "mrkdwn", 20 | "text": "Backup: *{{ .Plan.Id }}* ➝ *{{ .Repo.Id }}* at *{{ .FormatTime .CurTime }}*" 21 | } 22 | }, 23 | { 24 | "type": "divider" 25 | }, 26 | {{ if .Error }} 27 | { 28 | "type": "section", 29 | "text": { 30 | "type": "mrkdwn", 31 | "text": ":x: *Snapshot failed:*\n```{{ .Error }}```" 32 | } 33 | } 34 | {{ else }} 35 | { 36 | "type": "section", 37 | "text": { 38 | "type": "mrkdwn", 39 | "text": ":white_check_mark: *Snapshot `{{ .SnapshotId }}` created*" 40 | } 41 | }, 42 | { 43 | "type": "section", 44 | "fields": [ 45 | { 46 | "type": "mrkdwn", 47 | "text": "*Total Added:*\n`{{ .FormatSizeBytes .SnapshotStats.DataAdded }}`" 48 | }, 49 | { 50 | "type": "mrkdwn", 51 | "text": "*Files Processed:*\n`{{ .SnapshotStats.TotalFilesProcessed }}`" 52 | }, 53 | { 54 | "type": "mrkdwn", 55 | "text": "*Bytes Processed:*\n`{{ .FormatSizeBytes .SnapshotStats.TotalBytesProcessed }}`" 56 | }, 57 | { 58 | "type": "mrkdwn", 59 | "text": "*Time:*\n`{{ .SnapshotStats.TotalDuration }}s`" 60 | } 61 | ] 62 | }, 63 | { 64 | "type": "section", 65 | "text": { 66 | "type": "mrkdwn", 67 | "text": "*Backup Stats:*\n• *Files:* `{{ .SnapshotStats.FilesNew }} new`, `{{ .SnapshotStats.FilesChanged }} changed`, `{{ .SnapshotStats.FilesUnmodified }} unmodified`\n• *Dirs:* `{{ .SnapshotStats.DirsNew }} new`, `{{ .SnapshotStats.DirsChanged }} changed`, `{{ .SnapshotStats.DirsUnmodified }} unmodified`" 68 | } 69 | } 70 | {{ end }} 71 | ] 72 | } 73 | ``` -------------------------------------------------------------------------------- /docs/content/3.cookbooks/3.reverse-proxy-examples.md: -------------------------------------------------------------------------------- 1 | # Reverse Proxy Examples 2 | 3 | ## Introduction 4 | 5 | Reverse proxies like [Caddy](https://caddyserver.com/) and [Traefik](https://traefik.io/traefik/) can be configured to front and protect your Backrest endpoint. 6 | 7 | ## Using Caddy 8 | For this example, we'll be running Caddy alongside Backrest via docker-compose.yaml but you can adapt this config to your environment. 9 | 10 | Here is an example docker-compose.yaml: 11 | ``` 12 | version: "3.2" 13 | services: 14 | backrest: 15 | image: garethgeorge/backrest 16 | container_name: backrest 17 | hostname: 18 | volumes: 19 | - ./backrest/data:/data 20 | - ./backrest/config:/config 21 | - ./backrest/cache:/cache 22 | - /MY-BACKUP-DATA:/userdata # mount your directories to backup somewhere in the filesystem 23 | - /MY-REPOS:/repos # (optional) mount your restic repositories somewhere in the filesystem. 24 | environment: 25 | - BACKREST_DATA=/data # path for backrest data. restic binary and the database are placed here. 26 | - BACKREST_CONFIG=/config/config.json # path for the backrest config file. 27 | - XDG_CACHE_HOME=/cache # path for the restic cache which greatly improves performance. 28 | restart: unless-stopped 29 | depends_on: 30 | - caddy 31 | caddy: 32 | image: caddy 33 | container_name: caddy 34 | ports: 35 | - "443:443" 36 | - "443:443/udp" 37 | volumes: 38 | - ./caddy/Caddyfile:/etc/caddy/Caddyfile 39 | restart: unless-stopped 40 | ``` 41 | 42 | Your Caddyfile should look like this: 43 | ``` 44 | { 45 | https_port 443 46 | } 47 | 48 | backrest.example.com { 49 | tls internal 50 | reverse_proxy backrest:9898 51 | } 52 | ``` 53 | 54 | Some items to note: 55 | - The `reverse_proxy` line in your Caddyfile **must** match your Backrest container's name! 56 | - You can extend this with [acme_dns](https://github.com/caddy-dns/acmedns) to obtain certificates for your endpoint. 57 | - `tls internal` means that Caddy will generate and utilize a self-signed certificate. 58 | - You can create an [authentication portal](https://caddyserver.com/docs/json/apps/http/servers/routes/handle/auth_portal/) to allow login via Google, etc. 59 | - You can opt to have Caddy listen to requests on port 80 (HTTP) but that's not recommended for security reasons. 60 | -------------------------------------------------------------------------------- /docs/content/3.cookbooks/_dir.yml: -------------------------------------------------------------------------------- 1 | title: Cookbooks 2 | icon: material-symbols:stockpot 3 | -------------------------------------------------------------------------------- /docs/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | extends: ["@nuxt-themes/docus"], 3 | devtools: { enabled: true }, 4 | ssr: true, 5 | 6 | app: { 7 | baseURL: "/backrest/", 8 | }, 9 | 10 | compatibilityDate: "2025-02-19", 11 | }); -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backrest-docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate", 9 | "preview": "nuxi preview", 10 | "lint": "eslint ." 11 | }, 12 | "devDependencies": { 13 | "@nuxt-themes/docus": "^1.14.8", 14 | "@nuxt/devtools": "^1.3.1", 15 | "@nuxt/eslint-config": "^0.3.13", 16 | "@nuxtjs/plausible": "^1.0.0", 17 | "@types/node": "^20.12.12", 18 | "eslint": "^8.57.0", 19 | "nuxt": "^3.11.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garethgeorge/backrest/9a4e0c5c74c5cdfa3ac4d78d910fcf061f560e4c/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/screenshots/add-plan-view.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:61c887f52049232be557822eb38eff8d0670e033af03af57895e1595697b47e0 3 | size 171653 4 | -------------------------------------------------------------------------------- /docs/public/screenshots/add-repo-view.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:bbf9898e9e894e6fbe18ae4f73ad7ad95e208114fc7da59b727d8c4ebfa7bd98 3 | size 211442 4 | -------------------------------------------------------------------------------- /docs/public/screenshots/index-snapshots-btn.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:151d4d13af376f58ba20ae5405bc8fe319c790d4bba52c2eb33febb59d156911 3 | size 243083 4 | -------------------------------------------------------------------------------- /docs/public/screenshots/restore-dialog.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:a03b669b06cf8816987d13960801b715982349643d2c6e4aafc3700daf1b28f6 3 | size 72138 4 | -------------------------------------------------------------------------------- /docs/public/screenshots/restore-progress.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:6954a6f1f8aae635552fdada63b97205a433fe006a0abf8a1329364f4ddf6a91 3 | size 327095 4 | -------------------------------------------------------------------------------- /docs/public/screenshots/settings-view.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:915d6571839e93221809b0e9b02debb885b7fc61ed7a9f174ad91ec7c93c256b 3 | size 42234 4 | -------------------------------------------------------------------------------- /docs/public/screenshots/slack-clean-job.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garethgeorge/backrest/9a4e0c5c74c5cdfa3ac4d78d910fcf061f560e4c/docs/public/screenshots/slack-clean-job.png -------------------------------------------------------------------------------- /docs/public/screenshots/tree-view-for-restore-article.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:e51bb7e336d8216856858644d9f9542077e4acb446996969d7eec2fe68310f62 3 | size 346262 4 | -------------------------------------------------------------------------------- /docs/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>nuxt/renovate-config-nuxt"], 3 | "lockFileMaintenance": { 4 | "enabled": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/tokens.config.ts: -------------------------------------------------------------------------------- 1 | import { defineTheme } from "pinceau"; 2 | 3 | export default defineTheme({}); 4 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | cd "$(dirname "$0")" # cd to the directory of this script 4 | 5 | install_or_update_unix() { 6 | if systemctl is-active --quiet backrest; then 7 | sudo systemctl stop backrest 8 | echo "Paused backrest for update" 9 | fi 10 | install_unix 11 | } 12 | 13 | install_unix() { 14 | echo "Installing backrest to /usr/local/bin" 15 | sudo mkdir -p /usr/local/bin 16 | 17 | sudo cp $(ls -1 backrest | head -n 1) /usr/local/bin 18 | } 19 | 20 | create_systemd_service() { 21 | if [ ! -d /etc/systemd/system ]; then 22 | echo "Systemd not found. This script is only for systemd based systems." 23 | exit 1 24 | fi 25 | 26 | if [ -f /etc/systemd/system/backrest.service ]; then 27 | echo "Systemd unit already exists. Skipping creation." 28 | return 0 29 | fi 30 | 31 | echo "Creating systemd service at /etc/systemd/system/backrest.service" 32 | 33 | sudo tee /etc/systemd/system/backrest.service > /dev/null <<- EOM 34 | [Unit] 35 | Description=Backrest Service 36 | After=network.target 37 | 38 | [Service] 39 | Type=simple 40 | User=$(whoami) 41 | Group=$(whoami) 42 | ExecStart=/usr/local/bin/backrest 43 | Environment="BACKREST_PORT=127.0.0.1:9898" 44 | 45 | [Install] 46 | WantedBy=multi-user.target 47 | EOM 48 | 49 | echo "Reloading systemd daemon" 50 | sudo systemctl daemon-reload 51 | } 52 | 53 | create_launchd_plist() { 54 | echo "Creating launchd plist at /Library/LaunchAgents/com.backrest.plist" 55 | 56 | sudo tee /Library/LaunchAgents/com.backrest.plist > /dev/null <<- EOM 57 | 58 | 59 | 60 | 61 | Label 62 | com.backrest 63 | ProgramArguments 64 | 65 | /usr/local/bin/backrest 66 | 67 | KeepAlive 68 | 69 | EnvironmentVariables 70 | 71 | PATH 72 | /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin 73 | BACKREST_PORT 74 | 127.0.0.1:9898 75 | 76 | 77 | 78 | EOM 79 | } 80 | 81 | enable_launchd_plist() { 82 | echo "Trying to unload any previous version of com.backrest.plist" 83 | launchctl unload /Library/LaunchAgents/com.backrest.plist || true 84 | echo "Loading com.backrest.plist" 85 | launchctl load -w /Library/LaunchAgents/com.backrest.plist 86 | } 87 | 88 | OS=$(uname -s) 89 | if [ "$OS" = "Darwin" ]; then 90 | echo "Installing on Darwin" 91 | install_unix 92 | create_launchd_plist 93 | enable_launchd_plist 94 | sudo xattr -d com.apple.quarantine /usr/local/bin/backrest # remove quarantine flag 95 | elif [ "$OS" = "Linux" ]; then 96 | echo "Installing on Linux" 97 | install_or_update_unix 98 | create_systemd_service 99 | echo "Enabling systemd service backrest.service" 100 | sudo systemctl enable backrest 101 | sudo systemctl start backrest 102 | else 103 | echo "Unknown OS: $OS. This script only supports Darwin and Linux." 104 | exit 1 105 | fi 106 | 107 | echo "Logs are available at ~/.local/share/backrest/processlogs/backrest.log" 108 | echo "Access backrest WebUI at http://localhost:9898" 109 | -------------------------------------------------------------------------------- /internal/api/authenticationhandler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "connectrpc.com/connect" 7 | "github.com/garethgeorge/backrest/gen/go/types" 8 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 9 | "github.com/garethgeorge/backrest/gen/go/v1/v1connect" 10 | "github.com/garethgeorge/backrest/internal/auth" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | type AuthenticationHandler struct { 15 | // v1connect.UnimplementedAuthenticationHandler 16 | authenticator *auth.Authenticator 17 | } 18 | 19 | var _ v1connect.AuthenticationHandler = &AuthenticationHandler{} 20 | 21 | func NewAuthenticationHandler(authenticator *auth.Authenticator) *AuthenticationHandler { 22 | return &AuthenticationHandler{ 23 | authenticator: authenticator, 24 | } 25 | } 26 | 27 | func (s *AuthenticationHandler) Login(ctx context.Context, req *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) { 28 | zap.L().Debug("login request", zap.String("username", req.Msg.Username)) 29 | user, err := s.authenticator.Login(req.Msg.Username, req.Msg.Password) 30 | if err != nil { 31 | zap.L().Warn("failed login attempt", zap.Error(err)) 32 | return nil, connect.NewError(connect.CodeUnauthenticated, auth.ErrInvalidPassword) 33 | } 34 | 35 | token, err := s.authenticator.CreateJWT(user) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | return connect.NewResponse(&v1.LoginResponse{ 41 | Token: token, 42 | }), nil 43 | } 44 | 45 | func (s *AuthenticationHandler) HashPassword(ctx context.Context, req *connect.Request[types.StringValue]) (*connect.Response[types.StringValue], error) { 46 | hash, err := auth.CreatePassword(req.Msg.Value) 47 | if err != nil { 48 | return nil, err 49 | } 50 | return connect.NewResponse(&types.StringValue{Value: hash}), nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/api/signing.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "crypto" 5 | "crypto/hmac" 6 | "crypto/rand" 7 | "encoding/binary" 8 | ) 9 | 10 | var ( 11 | secret = make([]byte, 32) 12 | ) 13 | 14 | func init() { 15 | n, err := rand.Read(secret) 16 | if n != 32 || err != nil { 17 | panic("failed to generate secret key; is /dev/urandom available?") 18 | } 19 | } 20 | 21 | func sign(data []byte) ([]byte, error) { 22 | h := hmac.New(crypto.SHA256.New, secret) 23 | if n, err := h.Write(data); n != len(data) || err != nil { 24 | return nil, err 25 | } 26 | return h.Sum(nil), nil 27 | } 28 | 29 | func signInt64(data int64) ([]byte, error) { 30 | dataBytes := make([]byte, 8) 31 | binary.BigEndian.PutUint64(dataBytes, uint64(data)) 32 | return sign(dataBytes) 33 | } 34 | -------------------------------------------------------------------------------- /internal/api/syncapi/identity.go: -------------------------------------------------------------------------------- 1 | package syncapi 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "crypto/rand" 7 | "crypto/sha256" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "errors" 11 | "fmt" 12 | "os" 13 | ) 14 | 15 | var ( 16 | curve = elliptic.P256() // ed25519 17 | ) 18 | 19 | type Identity struct { 20 | InstanceID string 21 | credentialFile string 22 | 23 | privateKey *ecdsa.PrivateKey 24 | publicKey *ecdsa.PublicKey 25 | } 26 | 27 | func NewIdentity(instanceID, credentialFile string) (*Identity, error) { 28 | i := &Identity{ 29 | InstanceID: instanceID, 30 | credentialFile: credentialFile, 31 | } 32 | if err := i.loadOrGenerateKey(); err != nil { 33 | return nil, err 34 | } 35 | return i, nil 36 | } 37 | 38 | func (i *Identity) loadOrGenerateKey() error { 39 | privKeyBytes, errpriv := os.ReadFile(i.credentialFile) 40 | pubKeyBytes, errpub := os.ReadFile(i.credentialFile + ".pub") 41 | if errpriv != nil || errpub != nil { 42 | if os.IsNotExist(errpriv) || os.IsNotExist(errpub) { 43 | return i.generateKeys() 44 | } 45 | if errpriv != nil { 46 | return fmt.Errorf("open private key: %w", errpriv) 47 | } 48 | if errpub != nil { 49 | return fmt.Errorf("open public key: %w", errpub) 50 | } 51 | } 52 | 53 | privKeyBlock, _ := pem.Decode(privKeyBytes) 54 | if privKeyBlock == nil { 55 | return errors.New("no private key found in pem") 56 | } 57 | privKey, err := x509.ParseECPrivateKey(privKeyBlock.Bytes) 58 | if err != nil { 59 | return fmt.Errorf("parse private key: %w", err) 60 | } 61 | 62 | pubKeyBlock, _ := pem.Decode(pubKeyBytes) 63 | if pubKeyBlock == nil { 64 | return errors.New("no public key found in pem") 65 | } 66 | pubKey, err := x509.ParsePKIXPublicKey(pubKeyBytes) 67 | if err != nil { 68 | return fmt.Errorf("parse public key: %w", err) 69 | } 70 | 71 | i.privateKey = privKey 72 | i.publicKey = pubKey.(*ecdsa.PublicKey) 73 | 74 | return nil 75 | } 76 | 77 | func (i *Identity) generateKeys() error { 78 | privKey, err := ecdsa.GenerateKey(curve, rand.Reader) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | i.privateKey = privKey 84 | i.publicKey = &privKey.PublicKey 85 | 86 | privateKeyBytes, err := x509.MarshalECPrivateKey(i.privateKey) 87 | if err != nil { 88 | return fmt.Errorf("marshal private key: %w", err) 89 | } 90 | pemPrivateKeyBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE", Bytes: privateKeyBytes}) 91 | if err := os.WriteFile(i.credentialFile, pemPrivateKeyBytes, 0600); err != nil { 92 | return fmt.Errorf("write private key: %w", err) 93 | } 94 | 95 | publicKeyBytes, err := x509.MarshalPKIXPublicKey(&i.privateKey.PublicKey) 96 | if err != nil { 97 | return fmt.Errorf("marshal public key: %w", err) 98 | } 99 | pemPublicKeyBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PUBLIC", Bytes: publicKeyBytes}) 100 | if err := os.WriteFile(i.credentialFile+".pub", pemPublicKeyBytes, 0600); err != nil { 101 | return fmt.Errorf("write public key: %w", err) 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func (i *Identity) SignMessage(message []byte) ([]byte, error) { 108 | hash := sha256.Sum256(message) 109 | 110 | sig, err := ecdsa.SignASN1(rand.Reader, i.privateKey, hash[:]) 111 | if err != nil { 112 | return nil, err 113 | } 114 | return sig, nil 115 | } 116 | 117 | func (i *Identity) VerifySignature(message, sig []byte) error { 118 | hash := sha256.Sum256(message) 119 | if !ecdsa.VerifyASN1(i.publicKey, hash[:], sig) { 120 | return errors.New("signature verification failed") 121 | } 122 | return nil 123 | } 124 | -------------------------------------------------------------------------------- /internal/api/syncapi/identity_test.go: -------------------------------------------------------------------------------- 1 | package syncapi 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestIdentity(t *testing.T) { 11 | dir := t.TempDir() 12 | 13 | // Create a new identity 14 | ident, err := NewIdentity("test-instance", filepath.Join(dir, "myidentity.pem")) 15 | if err != nil { 16 | t.Fatalf("failed to create identity: %v", err) 17 | } 18 | 19 | signature, err := ident.SignMessage([]byte("hello world!")) 20 | fmt.Printf("signed message: %x\n", signature) 21 | 22 | // Load and print identity file 23 | bytes, _ := os.ReadFile(filepath.Join(dir, "myidentity.pem")) 24 | t.Log(string(bytes)) 25 | 26 | // Load and print public key file 27 | bytes, _ = os.ReadFile(filepath.Join(dir, "myidentity.pem.pub")) 28 | t.Log(string(bytes)) 29 | } 30 | -------------------------------------------------------------------------------- /internal/api/syncapi/uriutil.go: -------------------------------------------------------------------------------- 1 | package syncapi 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | ) 7 | 8 | var ErrNotBackrestURI = errors.New("not a backrest URI") 9 | 10 | func CreateRemoteRepoURI(instanceUrl string) (string, error) { 11 | u, err := url.Parse(instanceUrl) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | if u.Scheme == "http" { 17 | u.Scheme = "backrest" 18 | } else if u.Scheme == "https" { 19 | u.Scheme = "sbackrest" 20 | } else { 21 | return "", errors.New("unsupported scheme") 22 | } 23 | 24 | return u.String(), nil 25 | } 26 | 27 | func IsBackrestRemoteRepoURI(repoUri string) bool { 28 | u, err := url.Parse(repoUri) 29 | if err != nil { 30 | return false 31 | } 32 | 33 | return u.Scheme == "backrest" 34 | } 35 | 36 | func InstanceForBackrestURI(repoUri string) (string, error) { 37 | u, err := url.Parse(repoUri) 38 | if err != nil { 39 | return "", err 40 | } 41 | 42 | if u.Scheme != "backrest" { 43 | return "", errors.New("not a backrest URI") 44 | } 45 | 46 | return u.Hostname(), nil 47 | } 48 | 49 | func RepoForBackrestURI(repoUri string) (string, error) { 50 | u, err := url.Parse(repoUri) 51 | if err != nil { 52 | return "", err 53 | } 54 | 55 | if u.Scheme != "backrest" { 56 | return "", errors.New("not a backrest URI") 57 | } 58 | 59 | return u.Path, nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/auth/auth_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 8 | "github.com/garethgeorge/backrest/internal/config" 9 | ) 10 | 11 | func TestLogin(t *testing.T) { 12 | pass := makePass(t, "testPass") 13 | pass2 := makePass(t, "testPass2") 14 | 15 | config := &config.MemoryStore{ 16 | Config: &v1.Config{ 17 | Auth: &v1.Auth{ 18 | Users: []*v1.User{ 19 | { 20 | Name: "test", 21 | Password: &v1.User_PasswordBcrypt{ 22 | PasswordBcrypt: pass, 23 | }, 24 | }, 25 | { 26 | Name: "anotheruser", 27 | Password: &v1.User_PasswordBcrypt{ 28 | PasswordBcrypt: pass2, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | } 35 | 36 | auth := NewAuthenticator([]byte("key"), config) 37 | 38 | tests := []struct { 39 | name string 40 | username string 41 | password string 42 | wantErr error 43 | }{ 44 | {"user 1 valid password", "test", "testPass", nil}, 45 | {"user 2 valid password", "anotheruser", "testPass2", nil}, 46 | {"user 1 wrong password", "test", "wrongPass", ErrInvalidPassword}, 47 | {"invalid user", "nonexistent", "testPass", ErrUserNotFound}, 48 | } 49 | 50 | for _, test := range tests { 51 | t.Run(test.name, func(t *testing.T) { 52 | user, err := auth.Login(test.username, test.password) 53 | if !errors.Is(err, test.wantErr) { 54 | t.Fatalf("Expected error %v, got %v", test.wantErr, err) 55 | } 56 | if err == nil && user.Name != test.username { 57 | t.Fatalf("Expected user name to be '%s', got '%s'", test.username, user.Name) 58 | } 59 | }) 60 | } 61 | } 62 | 63 | func makePass(t *testing.T, pass string) string { 64 | p, err := CreatePassword(pass) 65 | if err != nil { 66 | t.Fatalf("Error creating password: %v", err) 67 | } 68 | return p 69 | } 70 | -------------------------------------------------------------------------------- /internal/auth/bearer.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func ParseBearerToken(token string) (string, error) { 9 | if !strings.HasPrefix(token, "Bearer ") { 10 | return "", fmt.Errorf("invalid token") 11 | } 12 | return token[7:], nil 13 | } 14 | -------------------------------------------------------------------------------- /internal/auth/middleware.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "go.uber.org/zap" 8 | ) 9 | 10 | type contextKey string 11 | 12 | func (k contextKey) String() string { 13 | return "auth context value " + string(k) 14 | } 15 | 16 | const UserContextKey contextKey = "user" 17 | const APIKeyContextKey contextKey = "api_key" 18 | 19 | func RequireAuthentication(h http.Handler, auth *Authenticator) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | config, err := auth.config.Get() 22 | if err != nil { 23 | zap.S().Errorf("auth middleware failed to get config: %v", err) 24 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 25 | return 26 | } 27 | if config.GetAuth() == nil || config.GetAuth().GetDisabled() { 28 | h.ServeHTTP(w, r) 29 | return 30 | } 31 | 32 | username, password, usesBasicAuth := r.BasicAuth() 33 | if usesBasicAuth { 34 | user, err := auth.Login(username, password) 35 | if err == nil { 36 | ctx := context.WithValue(r.Context(), UserContextKey, user) 37 | h.ServeHTTP(w, r.WithContext(ctx)) 38 | return 39 | } 40 | } 41 | 42 | // TODO: process the API Key 43 | 44 | token, err := ParseBearerToken(r.Header.Get("Authorization")) 45 | if err != nil { 46 | http.Error(w, "Unauthorized (No Authorization Header)", http.StatusUnauthorized) 47 | return 48 | } 49 | 50 | user, err := auth.VerifyJWT(token) 51 | if err != nil { 52 | zap.S().Warnf("auth middleware blocked bad JWT: %v", err) 53 | http.Error(w, "Unauthorized (Bad Token)", http.StatusUnauthorized) 54 | return 55 | } 56 | 57 | ctx := context.WithValue(r.Context(), UserContextKey, user) 58 | h.ServeHTTP(w, r.WithContext(ctx)) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /internal/config/configutil.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 5 | ) 6 | 7 | func FindPlan(cfg *v1.Config, planID string) *v1.Plan { 8 | for _, plan := range cfg.Plans { 9 | if plan.Id == planID { 10 | return plan 11 | } 12 | } 13 | return nil 14 | } 15 | 16 | func FindRepo(cfg *v1.Config, repoID string) *v1.Repo { 17 | for _, repo := range cfg.Repos { 18 | if repo.Id == repoID { 19 | return repo 20 | } 21 | } 22 | return nil 23 | } 24 | 25 | func FindRepoByGUID(cfg *v1.Config, guid string) *v1.Repo { 26 | for _, repo := range cfg.Repos { 27 | if repo.Guid == guid { 28 | return repo 29 | } 30 | } 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/config/jsonstore.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "sync" 10 | "time" 11 | 12 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 13 | "github.com/natefinch/atomic" 14 | "google.golang.org/protobuf/encoding/protojson" 15 | ) 16 | 17 | var ( 18 | configKeepVersions = 10 19 | ) 20 | 21 | type JsonFileStore struct { 22 | Path string 23 | mu sync.Mutex 24 | } 25 | 26 | var _ ConfigStore = &JsonFileStore{} 27 | 28 | func (f *JsonFileStore) Get() (*v1.Config, error) { 29 | f.mu.Lock() 30 | defer f.mu.Unlock() 31 | 32 | data, err := os.ReadFile(f.Path) 33 | if err != nil { 34 | if errors.Is(err, os.ErrNotExist) { 35 | return nil, ErrConfigNotFound 36 | } 37 | return nil, fmt.Errorf("failed to read config file: %w", err) 38 | } 39 | 40 | var config v1.Config 41 | if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(data, &config); err != nil { 42 | return nil, fmt.Errorf("failed to unmarshal config: %w", err) 43 | } 44 | 45 | return &config, nil 46 | } 47 | 48 | func (f *JsonFileStore) Update(config *v1.Config) error { 49 | f.mu.Lock() 50 | defer f.mu.Unlock() 51 | 52 | data, err := protojson.MarshalOptions{ 53 | Indent: " ", 54 | Multiline: true, 55 | }.Marshal(config) 56 | if err != nil { 57 | return fmt.Errorf("marshal config: %w", err) 58 | } 59 | 60 | err = os.MkdirAll(filepath.Dir(f.Path), 0755) 61 | if err != nil { 62 | return fmt.Errorf("create config directory: %w", err) 63 | } 64 | 65 | // backup the old config file 66 | if err := f.makeBackup(); err != nil { 67 | return fmt.Errorf("backup config file: %w", err) 68 | } 69 | 70 | err = atomic.WriteFile(f.Path, bytes.NewReader(data)) 71 | if err != nil { 72 | return fmt.Errorf("write config file: %w", err) 73 | } 74 | 75 | // only the user running backrest should be able to read the config. 76 | if err := os.Chmod(f.Path, 0600); err != nil { 77 | return fmt.Errorf("chmod(0600) config file: %w", err) 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func (f *JsonFileStore) makeBackup() error { 84 | curConfig, err := os.ReadFile(f.Path) 85 | if err != nil { 86 | if errors.Is(err, os.ErrNotExist) { 87 | return nil 88 | } 89 | return err 90 | } 91 | 92 | // backup the current config file 93 | backupName := fmt.Sprintf("%s.bak.%s", f.Path, time.Now().Format("2006-01-02-15-04-05")) 94 | if err := atomic.WriteFile(backupName, bytes.NewBuffer(curConfig)); err != nil { 95 | return err 96 | } 97 | if err := os.Chmod(backupName, 0600); err != nil { 98 | return err 99 | } 100 | 101 | // only keep the last 10 versions 102 | files, err := filepath.Glob(f.Path + ".bak.*") 103 | if err != nil { 104 | return err 105 | } 106 | if len(files) > configKeepVersions { 107 | for _, file := range files[:len(files)-configKeepVersions] { 108 | if err := os.Remove(file); err != nil { 109 | return err 110 | } 111 | } 112 | } 113 | 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /internal/config/memstore.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "sync" 5 | 6 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 7 | ) 8 | 9 | type MemoryStore struct { 10 | mu sync.Mutex 11 | Config *v1.Config 12 | } 13 | 14 | var _ ConfigStore = &MemoryStore{} 15 | 16 | func (c *MemoryStore) Get() (*v1.Config, error) { 17 | c.mu.Lock() 18 | defer c.mu.Unlock() 19 | return c.Config, nil 20 | } 21 | 22 | func (c *MemoryStore) Update(config *v1.Config) error { 23 | c.mu.Lock() 24 | defer c.mu.Unlock() 25 | c.Config = config 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/config/migrations/003relativescheduling.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 5 | "go.uber.org/zap" 6 | ) 7 | 8 | var migration003RelativeScheduling = func(config *v1.Config) { 9 | zap.L().Info("applying config migration 003: relative scheduling") 10 | // loop over plans and examine prune policy's 11 | for _, repo := range config.Repos { 12 | prunePolicy := repo.GetPrunePolicy() 13 | if prunePolicy == nil { 14 | continue 15 | } 16 | 17 | if schedule := repo.GetPrunePolicy().GetSchedule(); schedule != nil { 18 | schedule.Clock = v1.Schedule_CLOCK_LAST_RUN_TIME 19 | } 20 | 21 | if schedule := repo.GetCheckPolicy().GetSchedule(); schedule != nil { 22 | schedule.Clock = v1.Schedule_CLOCK_LAST_RUN_TIME 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/config/migrations/003relativescheduling_test.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "testing" 5 | 6 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 7 | "google.golang.org/protobuf/proto" 8 | ) 9 | 10 | func Test003Migration(t *testing.T) { 11 | config := &v1.Config{ 12 | Repos: []*v1.Repo{ 13 | { 14 | Id: "prune", 15 | PrunePolicy: &v1.PrunePolicy{ 16 | Schedule: &v1.Schedule{ 17 | Schedule: &v1.Schedule_MaxFrequencyDays{ 18 | MaxFrequencyDays: 1, 19 | }, 20 | }, 21 | }, 22 | CheckPolicy: &v1.CheckPolicy{ 23 | Schedule: &v1.Schedule{ 24 | Schedule: &v1.Schedule_MaxFrequencyDays{ 25 | MaxFrequencyDays: 1, 26 | }, 27 | }, 28 | }, 29 | }, 30 | }, 31 | } 32 | 33 | want := proto.Clone(config).(*v1.Config) 34 | want.Repos[0].PrunePolicy.Schedule.Clock = v1.Schedule_CLOCK_LAST_RUN_TIME 35 | want.Repos[0].CheckPolicy.Schedule.Clock = v1.Schedule_CLOCK_LAST_RUN_TIME 36 | 37 | migration003RelativeScheduling(config) 38 | 39 | if !proto.Equal(config, want) { 40 | t.Errorf("got %v, want %v", config, want) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/config/migrations/004repoguid.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 5 | "github.com/garethgeorge/backrest/internal/cryptoutil" 6 | ) 7 | 8 | var migration004RepoGuid = func(config *v1.Config) { 9 | for _, repo := range config.Repos { 10 | if repo.Guid != "" { 11 | continue 12 | } 13 | repo.Guid = cryptoutil.MustRandomID(cryptoutil.DefaultIDBits) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /internal/config/migrations/migrations.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | 6 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 7 | "go.uber.org/zap" 8 | "google.golang.org/protobuf/proto" 9 | ) 10 | 11 | var migrations = []*func(*v1.Config){ 12 | &noop, // migration001PrunePolicy is deprecated 13 | &noop, // migration002Schedules is deprecated 14 | &migration003RelativeScheduling, 15 | &migration004RepoGuid, 16 | } 17 | 18 | var CurrentVersion = int32(len(migrations)) 19 | 20 | func ApplyMigrations(config *v1.Config) error { 21 | if config.Version == 0 { 22 | if proto.Equal(config, &v1.Config{}) { 23 | config.Version = CurrentVersion 24 | return nil 25 | } 26 | return fmt.Errorf("config version 0 is invalid") 27 | } 28 | 29 | startMigration := int(config.Version) 30 | if startMigration < 0 { 31 | startMigration = 0 32 | } else if startMigration > int(CurrentVersion) { 33 | zap.S().Warnf("config version %d is greater than the latest known spec %d. Were you previously running a newer version of backrest? Ensure that your install is up to date.", startMigration, CurrentVersion) 34 | return fmt.Errorf("config version %d is greater than the latest known config format %d", startMigration, CurrentVersion) 35 | } 36 | 37 | for idx := startMigration; idx < len(migrations); idx += 1 { 38 | m := migrations[idx] 39 | if m == &noop { 40 | return fmt.Errorf("config version %d is too old to migrate, please try first upgrading to backrest 1.4.0 which is the last version that may be compatible with your config", config.Version) 41 | } 42 | (*m)(config) 43 | } 44 | config.Version = CurrentVersion 45 | return nil 46 | } 47 | 48 | var noop = func(config *v1.Config) { 49 | // do nothing 50 | } 51 | -------------------------------------------------------------------------------- /internal/config/migrations/migrations_test.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "testing" 5 | 6 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 7 | ) 8 | 9 | func TestApplyMigrations(t *testing.T) { 10 | t.Parallel() 11 | 12 | tests := []struct { 13 | name string 14 | config *v1.Config 15 | wantErr bool 16 | }{ 17 | { 18 | name: "too old to migrate", 19 | config: &v1.Config{ 20 | Version: 1, 21 | }, 22 | wantErr: true, // too old to migrate 23 | }, 24 | { 25 | name: "empty config", 26 | config: &v1.Config{}, 27 | wantErr: false, 28 | }, 29 | { 30 | name: "latest version", 31 | config: &v1.Config{ 32 | Version: CurrentVersion, 33 | }, 34 | }, 35 | { 36 | name: "apply relative scheduling migration", 37 | config: &v1.Config{ 38 | Version: 2, // higest version that still needs the migration 39 | Repos: []*v1.Repo{ 40 | { 41 | Id: "repo-relative", 42 | CheckPolicy: &v1.CheckPolicy{ 43 | Schedule: &v1.Schedule{ 44 | Schedule: &v1.Schedule_MaxFrequencyDays{MaxFrequencyDays: 1}, 45 | }, 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | } 52 | 53 | for _, tc := range tests { 54 | tc := tc 55 | t.Run(tc.name, func(t *testing.T) { 56 | t.Parallel() 57 | 58 | err := ApplyMigrations(tc.config) 59 | if (err != nil) != tc.wantErr { 60 | t.Errorf("ApplyMigrations() error = %v, wantErr %v", err, tc.wantErr) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/config/validationutil/validationutil.go: -------------------------------------------------------------------------------- 1 | package validationutil 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | IDMaxLen = 50 // maximum length of an ID 12 | sanitizeIDRegex = regexp.MustCompile(`[^a-zA-Z0-9_\-\.]+`) // matches invalid characters in an ID 13 | idRegex = regexp.MustCompile(`[a-zA-Z0-9_\-\.]*`) // matches a valid ID (including empty string) 14 | ) 15 | 16 | var ( 17 | ErrEmpty = errors.New("empty") 18 | ErrTooLong = errors.New("too long") 19 | ErrInvalidChars = errors.New("contains invalid characters") 20 | ) 21 | 22 | func SanitizeID(id string) string { 23 | return sanitizeIDRegex.ReplaceAllString(id, "_") 24 | } 25 | 26 | // ValidateID checks if an ID is valid. 27 | // It returns an error if the ID contains invalid characters, is empty, or is too long. 28 | // The maxLen parameter is the maximum length of the ID. If maxLen is 0, the ID length is not checked. 29 | func ValidateID(id string, maxLen int) error { 30 | if strings.HasPrefix(id, "_") && strings.HasSuffix(id, "_") { 31 | return errors.New("IDs starting and ending with '_' are reserved by backrest") 32 | } 33 | if !idRegex.MatchString(id) { 34 | return ErrInvalidChars 35 | } 36 | if len(id) == 0 { 37 | return ErrEmpty 38 | } 39 | if maxLen > 0 && len(id) > maxLen { 40 | return fmt.Errorf("(> %d chars): %w", maxLen, ErrTooLong) 41 | } 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/config/validationutil/validationutil_test.go: -------------------------------------------------------------------------------- 1 | package validationutil 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSanitizeID(t *testing.T) { 8 | tcs := []struct { 9 | name string 10 | id string 11 | want string 12 | }{ 13 | { 14 | name: "empty", 15 | id: "", 16 | want: "", 17 | }, 18 | { 19 | name: "no change", 20 | id: "abc123", 21 | want: "abc123", 22 | }, 23 | { 24 | name: "spaces", 25 | id: "a b c 1 2 3", 26 | want: "a_b_c_1_2_3", 27 | }, 28 | { 29 | name: "special characters", 30 | id: "a!b@c#1$2%3", 31 | want: "a_b_c_1_2_3", 32 | }, 33 | { 34 | name: "unicode", 35 | id: "a👍b👍c👍1👍2👍3", 36 | want: "a_b_c_1_2_3", 37 | }, 38 | } 39 | 40 | for _, tc := range tcs { 41 | tc := tc 42 | t.Run(tc.name, func(t *testing.T) { 43 | got := SanitizeID(tc.id) 44 | if got != tc.want { 45 | t.Errorf("SanitizeID(%q) = %q, want %q", tc.id, got, tc.want) 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/cryptoutil/idutil.go: -------------------------------------------------------------------------------- 1 | package cryptoutil 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/binary" 6 | "encoding/hex" 7 | ) 8 | 9 | var ( 10 | DefaultIDBits = 256 11 | ) 12 | 13 | func RandomUint64() (uint64, error) { 14 | b := make([]byte, 8) 15 | _, err := rand.Read(b) 16 | if err != nil { 17 | return 0, err 18 | } 19 | return binary.BigEndian.Uint64(b), nil 20 | } 21 | 22 | func MustRandomUint64() uint64 { 23 | id, err := RandomUint64() 24 | if err != nil { 25 | panic(err) 26 | } 27 | return id 28 | } 29 | 30 | func RandomID(bits int) (string, error) { 31 | b := make([]byte, bits/8) 32 | _, err := rand.Read(b) 33 | if err != nil { 34 | return "", err 35 | } 36 | return hex.EncodeToString(b), nil 37 | } 38 | 39 | func MustRandomID(bits int) string { 40 | id, err := RandomID(bits) 41 | if err != nil { 42 | panic(err) 43 | } 44 | return id 45 | } 46 | 47 | func TruncateID(id string, bits int) string { 48 | if len(id)*4 < bits { 49 | return id 50 | } 51 | return id[:bits/4] 52 | } 53 | -------------------------------------------------------------------------------- /internal/env/environment.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | ) 12 | 13 | var ( 14 | EnvVarConfigPath = "BACKREST_CONFIG" // path to config file 15 | EnvVarDataDir = "BACKREST_DATA" // path to data directory 16 | EnvVarBindAddress = "BACKREST_PORT" // port to bind to (default 9898) 17 | EnvVarBinPath = "BACKREST_RESTIC_COMMAND" // path to restic binary (default restic) 18 | ) 19 | 20 | var flagDataDir = flag.String("data-dir", "", "path to data directory, defaults to XDG_DATA_HOME/.local/backrest. Overrides BACKREST_DATA environment variable.") 21 | var flagConfigPath = flag.String("config-file", "", "path to config file, defaults to XDG_CONFIG_HOME/backrest/config.json. Overrides BACKREST_CONFIG environment variable.") 22 | var flagBindAddress = flag.String("bind-address", "", "address to bind to, defaults to 127.0.0.1:9898. Use :9898 to listen on all interfaces. Overrides BACKREST_PORT environment variable.") 23 | var flagResticBinPath = flag.String("restic-cmd", "", "path to restic binary, defaults to a backrest managed version of restic. Overrides BACKREST_RESTIC_COMMAND environment variable.") 24 | 25 | // ConfigFilePath 26 | // - *nix systems use $XDG_CONFIG_HOME/backrest/config.json 27 | // - windows uses %APPDATA%/backrest/config.json 28 | func ConfigFilePath() string { 29 | if *flagConfigPath != "" { 30 | return *flagConfigPath 31 | } 32 | if val := os.Getenv(EnvVarConfigPath); val != "" { 33 | return val 34 | } 35 | return filepath.Join(getConfigDir(), "backrest", "config.json") 36 | } 37 | 38 | // DataDir 39 | // - *nix systems use $XDG_DATA_HOME/backrest 40 | // - windows uses %APPDATA%/backrest/data 41 | func DataDir() string { 42 | if *flagDataDir != "" { 43 | return *flagDataDir 44 | } 45 | if val := os.Getenv(EnvVarDataDir); val != "" { 46 | return val 47 | } 48 | if val := os.Getenv("XDG_DATA_HOME"); val != "" { 49 | return path.Join(val, "backrest") 50 | } 51 | 52 | if runtime.GOOS == "windows" { 53 | return filepath.Join(getConfigDir(), "backrest", "data") 54 | } 55 | return path.Join(getHomeDir(), ".local/share/backrest") 56 | } 57 | 58 | func BindAddress() string { 59 | if *flagBindAddress != "" { 60 | return formatBindAddress(*flagBindAddress) 61 | } 62 | if val := os.Getenv(EnvVarBindAddress); val != "" { 63 | return formatBindAddress(val) 64 | } 65 | return "127.0.0.1:9898" 66 | } 67 | 68 | func ResticBinPath() string { 69 | if *flagResticBinPath != "" { 70 | return *flagResticBinPath 71 | } 72 | if val := os.Getenv(EnvVarBinPath); val != "" { 73 | return val 74 | } 75 | return "" 76 | } 77 | 78 | func LogsPath() string { 79 | dataDir := DataDir() 80 | return filepath.Join(dataDir, "processlogs") 81 | } 82 | 83 | func getHomeDir() string { 84 | home, err := os.UserHomeDir() 85 | if err != nil { 86 | panic(fmt.Errorf("couldn't determine home directory: %v", err)) 87 | } 88 | return home 89 | } 90 | 91 | func getConfigDir() string { 92 | if runtime.GOOS == "windows" { 93 | cfgDir, err := os.UserConfigDir() 94 | if err != nil { 95 | panic(fmt.Errorf("couldn't determine config directory: %v", err)) 96 | } 97 | return cfgDir 98 | } 99 | if val := os.Getenv("XDG_CONFIG_HOME"); val != "" { 100 | return val 101 | } 102 | return filepath.Join(getHomeDir(), ".config") 103 | } 104 | 105 | func formatBindAddress(addr string) string { 106 | if !strings.Contains(addr, ":") { 107 | return ":" + addr 108 | } 109 | return addr 110 | } 111 | -------------------------------------------------------------------------------- /internal/hook/errors.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // HookErrorCancel requests that the calling operation cancel itself. It must be handled explicitly caller. Subsequent hooks will be skipped. 9 | type HookErrorRequestCancel struct { 10 | Err error 11 | } 12 | 13 | func (e HookErrorRequestCancel) Error() string { 14 | return fmt.Sprintf("cancel: %v", e.Err.Error()) 15 | } 16 | 17 | func (e HookErrorRequestCancel) Unwrap() error { 18 | return e.Err 19 | } 20 | 21 | // HookErrorFatal stops evaluation of subsequent hooks and will propagate to the hook flow's caller 22 | type HookErrorFatal struct { 23 | Err error 24 | } 25 | 26 | func (e HookErrorFatal) Error() string { 27 | return fmt.Sprintf("fatal: %v", e.Err.Error()) 28 | } 29 | 30 | func (e HookErrorFatal) Unwrap() error { 31 | return e.Err 32 | } 33 | 34 | type RetryBackoffPolicy = func(attempt int) time.Duration 35 | 36 | // HookErrorRetry requests that the calling operation retry after a specified backoff duration 37 | type HookErrorRetry struct { 38 | Err error 39 | Backoff RetryBackoffPolicy 40 | } 41 | 42 | func (e HookErrorRetry) Error() string { 43 | return fmt.Sprintf("retry: %v", e.Err.Error()) 44 | } 45 | -------------------------------------------------------------------------------- /internal/hook/hook_test.go: -------------------------------------------------------------------------------- 1 | package hook 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 8 | ) 9 | 10 | // TestApplyHookErrorPolicy tests that applyHookErrorPolicy is defined for all values of Hook_OnError. 11 | func TestApplyHookErrorPolicy(t *testing.T) { 12 | values := v1.Hook_OnError(0).Descriptor().Values() 13 | for i := 0; i < values.Len(); i++ { 14 | applyHookErrorPolicy(v1.Hook_OnError(values.Get(i).Number()), errors.New("an error")) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /internal/hook/hookutil/httputil.go: -------------------------------------------------------------------------------- 1 | package hookutil 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | func PostRequest(url string, contentType string, body io.Reader) (string, error) { 10 | r, err := http.Post(url, contentType, body) 11 | if err != nil { 12 | return "", fmt.Errorf("send request %v: %w", url, err) 13 | } 14 | if r.StatusCode == 204 { 15 | return "", nil 16 | } else if r.StatusCode != 200 { 17 | return "", fmt.Errorf("unexpected status %v: %s", r.StatusCode, r.Status) 18 | } 19 | defer r.Body.Close() 20 | bodyBytes, err := io.ReadAll(r.Body) 21 | if err != nil { 22 | return "", fmt.Errorf("read response: %w", err) 23 | } 24 | return string(bodyBytes), nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/hook/hookutil/templateutil.go: -------------------------------------------------------------------------------- 1 | package hookutil 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | "text/template" 8 | ) 9 | 10 | var ( 11 | DefaultTemplate = `{{ .Summary }}` 12 | ) 13 | 14 | func RenderTemplate(text string, vars interface{}) (string, error) { 15 | template, err := template.New("template").Parse(text) 16 | if err != nil { 17 | return "", fmt.Errorf("parse template: %w", err) 18 | } 19 | 20 | buf := &bytes.Buffer{} 21 | if err := template.Execute(buf, vars); err != nil { 22 | return "", fmt.Errorf("execute template: %w", err) 23 | } 24 | 25 | return buf.String(), nil 26 | } 27 | 28 | func RenderTemplateOrDefault(template string, defaultTmpl string, vars interface{}) (string, error) { 29 | if strings.Trim(template, " ") == "" { 30 | return RenderTemplate(defaultTmpl, vars) 31 | } 32 | return RenderTemplate(template, vars) 33 | } 34 | -------------------------------------------------------------------------------- /internal/hook/types/command.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os/exec" 8 | "reflect" 9 | "runtime" 10 | "strings" 11 | 12 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 13 | "github.com/garethgeorge/backrest/internal/hook/hookutil" 14 | "github.com/garethgeorge/backrest/internal/ioutil" 15 | "github.com/garethgeorge/backrest/internal/orchestrator/logging" 16 | "github.com/garethgeorge/backrest/internal/orchestrator/tasks" 17 | "github.com/google/shlex" 18 | ) 19 | 20 | type commandHandler struct{} 21 | 22 | func (commandHandler) Name() string { 23 | return "command" 24 | } 25 | 26 | func (commandHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error { 27 | command, err := hookutil.RenderTemplate(h.GetActionCommand().GetCommand(), vars) 28 | if err != nil { 29 | return fmt.Errorf("template rendering: %w", err) 30 | } 31 | 32 | writer := logging.WriterFromContext(ctx) 33 | 34 | // Parse out the shell to use if a #! prefix is present 35 | shell := []string{"sh"} 36 | if runtime.GOOS == "windows" { 37 | shell = []string{"powershell", "-NoLogo", "-NoProfile", "-Command", "-"} 38 | } 39 | 40 | if len(command) > 2 && command[0:2] == "#!" { 41 | nextLine := strings.Index(command, "\n") 42 | if nextLine == -1 { 43 | nextLine = len(command) 44 | } 45 | shell, err = shlex.Split(strings.Trim(command[2:nextLine], " ")) 46 | if err != nil { 47 | return fmt.Errorf("parsing shell for command: %w", err) 48 | } else if len(shell) == 0 { 49 | return errors.New("must specify shell for command") 50 | } 51 | command = command[nextLine+1:] 52 | } 53 | 54 | scriptWriter := &ioutil.LinePrefixer{W: writer, Prefix: []byte("[script] ")} 55 | fmt.Fprintf(scriptWriter, "%v\n%v\n", shell, command) 56 | scriptWriter.Close() 57 | outputWriter := &ioutil.LinePrefixer{W: writer, Prefix: []byte("[output] ")} 58 | defer outputWriter.Close() 59 | 60 | // Run the command in the specified shell 61 | execCmd := exec.Command(shell[0], shell[1:]...) 62 | execCmd.Stdin = strings.NewReader(command) 63 | 64 | stdout := &ioutil.SynchronizedWriter{W: outputWriter} 65 | execCmd.Stderr = stdout 66 | execCmd.Stdout = stdout 67 | 68 | return execCmd.Run() 69 | } 70 | 71 | func (commandHandler) ActionType() reflect.Type { 72 | return reflect.TypeOf(&v1.Hook_ActionCommand{}) 73 | } 74 | 75 | func init() { 76 | DefaultRegistry().RegisterHandler(&commandHandler{}) 77 | } 78 | -------------------------------------------------------------------------------- /internal/hook/types/discord.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "reflect" 9 | 10 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 11 | "github.com/garethgeorge/backrest/internal/hook/hookutil" 12 | "github.com/garethgeorge/backrest/internal/orchestrator/tasks" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | type discordHandler struct{} 17 | 18 | func (discordHandler) Name() string { 19 | return "discord" 20 | } 21 | 22 | func (discordHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error { 23 | payload, err := hookutil.RenderTemplateOrDefault(h.GetActionDiscord().GetTemplate(), hookutil.DefaultTemplate, vars) 24 | if err != nil { 25 | return fmt.Errorf("template rendering: %w", err) 26 | } 27 | 28 | l := runner.Logger(ctx) 29 | l.Sugar().Infof("Sending discord message to %s", h.GetActionDiscord().GetWebhookUrl()) 30 | l.Debug("Sending discord message", zap.String("payload", payload)) 31 | 32 | type Message struct { 33 | Content string `json:"content"` 34 | } 35 | 36 | request := Message{ 37 | Content: payload, // leading newline looks better in discord. 38 | } 39 | 40 | requestBytes, _ := json.Marshal(request) 41 | body, err := hookutil.PostRequest(h.GetActionDiscord().GetWebhookUrl(), "application/json", bytes.NewReader(requestBytes)) 42 | if err != nil { 43 | return fmt.Errorf("sending discord message to %q: %w", h.GetActionDiscord().GetWebhookUrl(), err) 44 | } 45 | zap.S().Debug("Discord response", zap.String("body", body)) 46 | return nil 47 | } 48 | 49 | func (discordHandler) ActionType() reflect.Type { 50 | return reflect.TypeOf(&v1.Hook_ActionDiscord{}) 51 | } 52 | 53 | func init() { 54 | DefaultRegistry().RegisterHandler(&discordHandler{}) 55 | } 56 | -------------------------------------------------------------------------------- /internal/hook/types/gotify.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/url" 9 | "reflect" 10 | "strings" 11 | 12 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 13 | "github.com/garethgeorge/backrest/internal/hook/hookutil" 14 | "github.com/garethgeorge/backrest/internal/orchestrator/tasks" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | type gotifyHandler struct{} 19 | 20 | func (gotifyHandler) Name() string { 21 | return "gotify" 22 | } 23 | 24 | func (gotifyHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error { 25 | g := h.GetActionGotify() 26 | 27 | payload, err := hookutil.RenderTemplateOrDefault(g.GetTemplate(), hookutil.DefaultTemplate, vars) 28 | if err != nil { 29 | return fmt.Errorf("template rendering: %w", err) 30 | } 31 | 32 | title, err := hookutil.RenderTemplateOrDefault(g.GetTitleTemplate(), "Backrest Event", vars) 33 | if err != nil { 34 | return fmt.Errorf("title template rendering: %w", err) 35 | } 36 | 37 | priority := int(g.Priority) 38 | 39 | l := runner.Logger(ctx) 40 | 41 | message := struct { 42 | Message string `json:"message"` 43 | Title string `json:"title"` 44 | Priority int `json:"priority"` 45 | }{ 46 | Title: title, 47 | Priority: priority, 48 | Message: payload, 49 | } 50 | 51 | l.Sugar().Infof("Sending gotify message to %s", g.GetBaseUrl()) 52 | l.Debug("Sending gotify message", zap.Any("message", message)) 53 | 54 | b, err := json.Marshal(message) 55 | if err != nil { 56 | return fmt.Errorf("json marshal: %w", err) 57 | } 58 | 59 | baseUrl := strings.Trim(g.GetBaseUrl(), "/") 60 | 61 | postUrl := fmt.Sprintf( 62 | "%s/message?token=%s", 63 | baseUrl, 64 | url.QueryEscape(g.GetToken())) 65 | 66 | body, err := hookutil.PostRequest(postUrl, "application/json", bytes.NewReader(b)) 67 | 68 | if err != nil { 69 | return fmt.Errorf("send gotify message: %w", err) 70 | } 71 | 72 | l.Sugar().Debugf("Gotify response: %s", body) 73 | 74 | return nil 75 | } 76 | 77 | func (gotifyHandler) ActionType() reflect.Type { 78 | return reflect.TypeOf(&v1.Hook_ActionGotify{}) 79 | } 80 | 81 | func init() { 82 | DefaultRegistry().RegisterHandler(&gotifyHandler{}) 83 | } 84 | -------------------------------------------------------------------------------- /internal/hook/types/healthchecks.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "net/url" 8 | "path" 9 | "reflect" 10 | 11 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 12 | "github.com/garethgeorge/backrest/internal/hook/hookutil" 13 | "github.com/garethgeorge/backrest/internal/orchestrator/tasks" 14 | "github.com/garethgeorge/backrest/internal/protoutil" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | type healthchecksHandler struct{} 19 | 20 | func (healthchecksHandler) Name() string { 21 | return "healthchecks" 22 | } 23 | 24 | func (healthchecksHandler) Execute(ctx context.Context, cmd *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error { 25 | payload, err := hookutil.RenderTemplateOrDefault(cmd.GetActionHealthchecks().GetTemplate(), hookutil.DefaultTemplate, vars) 26 | if err != nil { 27 | return fmt.Errorf("template rendering: %w", err) 28 | } 29 | 30 | l := runner.Logger(ctx) 31 | l.Sugar().Infof("Sending healthchecks message to %s", cmd.GetActionHealthchecks().GetWebhookUrl()) 32 | l.Debug("Sending healthchecks message", zap.String("payload", payload)) 33 | baseURL := cmd.GetActionHealthchecks().GetWebhookUrl() 34 | u, err := url.Parse(baseURL) 35 | if err != nil { 36 | return fmt.Errorf("parsing webhook URL: %w", err) 37 | } 38 | 39 | switch { 40 | case protoutil.IsStartCondition(event): 41 | u.Path = path.Join(u.Path, "start") 42 | case protoutil.IsErrorCondition(event): 43 | u.Path = path.Join(u.Path, "fail") 44 | case protoutil.IsLogCondition(event): 45 | u.Path = path.Join(u.Path, "log") 46 | } 47 | 48 | pingUrl := u.String() 49 | 50 | body, err := hookutil.PostRequest(pingUrl, "text/plain", bytes.NewBufferString(payload)) 51 | if err != nil { 52 | return fmt.Errorf("sending healthchecks message to %q: %w", pingUrl, err) 53 | } 54 | 55 | l.Debug("Healthchecks response", zap.String("body", body)) 56 | return nil 57 | } 58 | 59 | func (healthchecksHandler) ActionType() reflect.Type { 60 | return reflect.TypeOf(&v1.Hook_ActionHealthchecks{}) 61 | } 62 | 63 | func init() { 64 | DefaultRegistry().RegisterHandler(&healthchecksHandler{}) 65 | } 66 | -------------------------------------------------------------------------------- /internal/hook/types/registry.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "reflect" 8 | 9 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 10 | "github.com/garethgeorge/backrest/internal/orchestrator/tasks" 11 | ) 12 | 13 | var ErrHandlerNotFound = errors.New("handler not found") 14 | 15 | // defaultRegistry is the default handler registry. 16 | var defaultRegistry = &HandlerRegistry{ 17 | actionHandlers: make(map[reflect.Type]Handler), 18 | } 19 | 20 | func DefaultRegistry() *HandlerRegistry { 21 | return defaultRegistry 22 | } 23 | 24 | type HandlerRegistry struct { 25 | actionHandlers map[reflect.Type]Handler 26 | } 27 | 28 | // RegisterHandler registers a handler with the default registry. 29 | func (r *HandlerRegistry) RegisterHandler(handler Handler) { 30 | r.actionHandlers[handler.ActionType()] = handler 31 | } 32 | 33 | func (r *HandlerRegistry) GetHandler(hook *v1.Hook) (Handler, error) { 34 | handler, ok := r.actionHandlers[reflect.TypeOf(hook.Action)] 35 | if !ok { 36 | return nil, fmt.Errorf("hook type %T: %w", hook.Action, ErrHandlerNotFound) 37 | } 38 | return handler, nil 39 | } 40 | 41 | type Handler interface { 42 | Name() string 43 | Execute(ctx context.Context, hook *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error 44 | ActionType() reflect.Type 45 | } 46 | -------------------------------------------------------------------------------- /internal/hook/types/shoutrrr.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | 8 | "github.com/containrrr/shoutrrr" 9 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 10 | "github.com/garethgeorge/backrest/internal/hook/hookutil" 11 | "github.com/garethgeorge/backrest/internal/orchestrator/tasks" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | type shoutrrrHandler struct{} 16 | 17 | func (shoutrrrHandler) Name() string { 18 | return "shoutrrr" 19 | } 20 | 21 | func (shoutrrrHandler) Execute(ctx context.Context, h *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error { 22 | payload, err := hookutil.RenderTemplateOrDefault(h.GetActionShoutrrr().GetTemplate(), hookutil.DefaultTemplate, vars) 23 | if err != nil { 24 | return fmt.Errorf("template rendering: %w", err) 25 | } 26 | 27 | l := runner.Logger(ctx) 28 | 29 | l.Sugar().Infof("Sending shoutrrr message to %s", h.GetActionShoutrrr().GetShoutrrrUrl()) 30 | l.Debug("Sending shoutrrr message", zap.String("payload", payload)) 31 | 32 | if err := shoutrrr.Send(h.GetActionShoutrrr().GetShoutrrrUrl(), payload); err != nil { 33 | return fmt.Errorf("sending shoutrrr message to %q: %w", h.GetActionShoutrrr().GetShoutrrrUrl(), err) 34 | } 35 | 36 | return nil 37 | } 38 | 39 | func (shoutrrrHandler) ActionType() reflect.Type { 40 | return reflect.TypeOf(&v1.Hook_ActionShoutrrr{}) 41 | } 42 | 43 | func init() { 44 | DefaultRegistry().RegisterHandler(&shoutrrrHandler{}) 45 | } 46 | -------------------------------------------------------------------------------- /internal/hook/types/slack.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "reflect" 9 | 10 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 11 | "github.com/garethgeorge/backrest/internal/hook/hookutil" 12 | "github.com/garethgeorge/backrest/internal/orchestrator/tasks" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | type slackHandler struct{} 17 | 18 | func (slackHandler) Name() string { 19 | return "slack" 20 | } 21 | 22 | func (slackHandler) Execute(ctx context.Context, cmd *v1.Hook, vars interface{}, runner tasks.TaskRunner, event v1.Hook_Condition) error { 23 | 24 | payload, err := hookutil.RenderTemplateOrDefault(cmd.GetActionSlack().GetTemplate(), hookutil.DefaultTemplate, vars) 25 | if err != nil { 26 | return fmt.Errorf("template rendering: %w", err) 27 | } 28 | 29 | l := runner.Logger(ctx) 30 | 31 | var requestBytes []byte 32 | if json.Valid([]byte(payload)) { 33 | l.Sugar().Infof("Sending advanced slack message to %s", cmd.GetActionSlack().GetWebhookUrl()) 34 | l.Sugar().Debugf("Sending advanced slack message: %s", payload) 35 | requestBytes = []byte(payload) 36 | } else { 37 | l.Sugar().Infof("Sending slack message to %s", cmd.GetActionSlack().GetWebhookUrl()) 38 | l.Debug("Sending slack message", zap.String("payload", payload)) 39 | request := struct { 40 | Text string `json:"text"` 41 | }{ 42 | Text: "Backrest Notification\n" + payload, 43 | } 44 | requestBytes, _ = json.Marshal(request) 45 | } 46 | 47 | body, err := hookutil.PostRequest(cmd.GetActionSlack().GetWebhookUrl(), "application/json", bytes.NewReader(requestBytes)) 48 | if err != nil { 49 | return fmt.Errorf("sending slack message to %q: %w", cmd.GetActionSlack().GetWebhookUrl(), err) 50 | } 51 | 52 | l.Debug("Slack response", zap.String("body", body)) 53 | return nil 54 | } 55 | 56 | func (slackHandler) ActionType() reflect.Type { 57 | return reflect.TypeOf(&v1.Hook_ActionSlack{}) 58 | } 59 | 60 | func init() { 61 | DefaultRegistry().RegisterHandler(&slackHandler{}) 62 | } 63 | -------------------------------------------------------------------------------- /internal/ioutil/iobatching.go: -------------------------------------------------------------------------------- 1 | package ioutil 2 | 3 | const DefaultBatchSize = 512 4 | 5 | func Batchify[T any](items []T, batchSize int) [][]T { 6 | var batches [][]T 7 | for i := 0; i < len(items); i += batchSize { 8 | end := i + batchSize 9 | if end > len(items) { 10 | end = len(items) 11 | } 12 | batches = append(batches, items[i:end]) 13 | } 14 | return batches 15 | } 16 | -------------------------------------------------------------------------------- /internal/ioutil/ioutil.go: -------------------------------------------------------------------------------- 1 | package ioutil 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "sync" 8 | "sync/atomic" 9 | ) 10 | 11 | // LimitWriter is a writer that limits the number of bytes written to it. 12 | type LimitWriter struct { 13 | W io.Writer 14 | N int // bytes remaining that can be written 15 | D int // bytes dropped so far 16 | } 17 | 18 | func (l *LimitWriter) Write(p []byte) (rnw int, err error) { 19 | rnw = len(p) 20 | if l.N <= 0 { 21 | l.D += len(p) 22 | return 0, nil 23 | } 24 | if len(p) > l.N { 25 | l.D += len(p) - l.N 26 | p = p[:l.N] 27 | } 28 | _, err = l.W.Write(p) 29 | l.N -= len(p) 30 | return 31 | } 32 | 33 | // LinePrefixer is a writer that prefixes each line written to it with a prefix. 34 | type LinePrefixer struct { 35 | W io.Writer 36 | buf []byte 37 | Prefix []byte 38 | } 39 | 40 | func (l *LinePrefixer) Write(p []byte) (n int, err error) { 41 | n = len(p) 42 | l.buf = append(l.buf, p...) 43 | if !bytes.Contains(p, []byte{'\n'}) { // no newlines in p, short-circuit out 44 | return 45 | } 46 | bufOrig := l.buf 47 | for { 48 | i := bytes.IndexByte(l.buf, '\n') 49 | if i < 0 { 50 | break 51 | } 52 | if _, err := l.W.Write(l.Prefix); err != nil { 53 | return 0, err 54 | } 55 | if _, err := l.W.Write(l.buf[:i+1]); err != nil { 56 | return 0, err 57 | } 58 | l.buf = l.buf[i+1:] 59 | } 60 | l.buf = append(bufOrig[:0], l.buf...) 61 | return 62 | } 63 | 64 | func (l *LinePrefixer) Close() error { 65 | if len(l.buf) > 0 { 66 | if _, err := l.W.Write(l.Prefix); err != nil { 67 | return err 68 | } 69 | if _, err := l.W.Write(l.buf); err != nil { 70 | return err 71 | } 72 | } 73 | return nil 74 | } 75 | 76 | type SynchronizedWriter struct { 77 | Mu sync.Mutex 78 | W io.Writer 79 | } 80 | 81 | var _ io.Writer = &SynchronizedWriter{} 82 | 83 | func (w *SynchronizedWriter) Write(p []byte) (n int, err error) { 84 | w.Mu.Lock() 85 | defer w.Mu.Unlock() 86 | return w.W.Write(p) 87 | } 88 | 89 | type SizeTrackingWriter struct { 90 | size atomic.Uint64 91 | io.Writer 92 | } 93 | 94 | func (w *SizeTrackingWriter) Write(p []byte) (n int, err error) { 95 | n, err = w.Writer.Write(p) 96 | w.size.Add(uint64(n)) 97 | return 98 | } 99 | 100 | // Size returns the number of bytes written to the writer. 101 | // The value is fundamentally racy only consistent if synchronized with the writer or closed. 102 | func (w *SizeTrackingWriter) Size() uint64 { 103 | return w.size.Load() 104 | } 105 | 106 | type SizeLimitedWriter struct { 107 | SizeTrackingWriter 108 | Limit uint64 109 | } 110 | 111 | var _ io.Writer = &SizeLimitedWriter{} 112 | 113 | func (w *SizeLimitedWriter) Write(p []byte) (n int, err error) { 114 | size := w.Size() 115 | if size+uint64(len(p)) > w.Limit { 116 | p = p[:w.Limit-size] 117 | err = fmt.Errorf("size limit exceeded: %d bytes written, limit is %d bytes", size, w.Limit) 118 | } 119 | 120 | var e error 121 | n, e = w.Writer.Write(p) 122 | if e != nil { 123 | err = e 124 | } 125 | return 126 | } 127 | -------------------------------------------------------------------------------- /internal/logstore/shardedmutex.go: -------------------------------------------------------------------------------- 1 | package logstore 2 | 3 | import ( 4 | "hash/fnv" 5 | "sync" 6 | ) 7 | 8 | type shardedRWMutex struct { 9 | mu []sync.RWMutex 10 | } 11 | 12 | func newShardedRWMutex(n int) shardedRWMutex { 13 | mu := make([]sync.RWMutex, n) 14 | return shardedRWMutex{ 15 | mu: mu, 16 | } 17 | } 18 | 19 | func (sm *shardedRWMutex) Lock(key string) { 20 | idx := hash(key) % uint32(len(sm.mu)) 21 | sm.mu[idx].Lock() 22 | } 23 | 24 | func (sm *shardedRWMutex) Unlock(key string) { 25 | idx := hash(key) % uint32(len(sm.mu)) 26 | sm.mu[idx].Unlock() 27 | } 28 | 29 | func (sm *shardedRWMutex) RLock(key string) { 30 | idx := hash(key) % uint32(len(sm.mu)) 31 | sm.mu[idx].RLock() 32 | } 33 | 34 | func (sm *shardedRWMutex) RUnlock(key string) { 35 | idx := hash(key) % uint32(len(sm.mu)) 36 | sm.mu[idx].RUnlock() 37 | } 38 | 39 | func hash(s string) uint32 { 40 | h := fnv.New32a() 41 | h.Write([]byte(s)) 42 | return h.Sum32() 43 | } 44 | -------------------------------------------------------------------------------- /internal/logstore/tarmigrate.go: -------------------------------------------------------------------------------- 1 | package logstore 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "go.uber.org/zap" 15 | ) 16 | 17 | func MigrateTarLogsInDir(ls *LogStore, dir string) { 18 | files, err := os.ReadDir(dir) 19 | if errors.Is(err, os.ErrNotExist) { 20 | return 21 | } else if err != nil { 22 | zap.L().Warn("tarlog migration failed to read directory", zap.String("dir", dir), zap.Error(err)) 23 | } 24 | 25 | for _, file := range files { 26 | if file.IsDir() { 27 | continue 28 | } 29 | 30 | if filepath.Ext(file.Name()) != ".tar" { 31 | continue 32 | } 33 | 34 | if err := MigrateTarLog(ls, filepath.Join(dir, file.Name())); err != nil { 35 | zap.S().Warnf("failed to migrate tar log %q: %v", file.Name(), err) 36 | } else { 37 | if err := os.Remove(filepath.Join(dir, file.Name())); err != nil { 38 | zap.S().Warnf("failed to remove fully migrated tar log %q: %v", file.Name(), err) 39 | } 40 | } 41 | } 42 | } 43 | 44 | func MigrateTarLog(ls *LogStore, logTar string) error { 45 | baseName := filepath.Base(logTar) 46 | 47 | f, err := os.Open(logTar) 48 | if err != nil { 49 | return fmt.Errorf("failed to open tar file: %v", err) 50 | } 51 | 52 | tarReader := tar.NewReader(f) 53 | 54 | var count int64 55 | var bytes int64 56 | for { 57 | header, err := tarReader.Next() 58 | if err != nil { 59 | if err == io.EOF { 60 | break 61 | } 62 | return fmt.Errorf("failed to read tar header: %v", err) 63 | } 64 | 65 | if header.Typeflag != tar.TypeReg { 66 | continue 67 | } 68 | 69 | w, err := ls.Create(baseName+"/"+strings.TrimSuffix(header.Name, ".gz"), 0, 14*24*time.Hour) 70 | if err != nil { 71 | return fmt.Errorf("failed to create log writer: %v", err) 72 | } 73 | 74 | var r io.ReadCloser = io.NopCloser(tarReader) 75 | if strings.HasSuffix(header.Name, ".gz") { 76 | r, err = gzip.NewReader(tarReader) 77 | if err != nil { 78 | return fmt.Errorf("failed to create gzip reader: %v", err) 79 | } 80 | } 81 | 82 | if n, err := io.Copy(w, r); err != nil { 83 | return fmt.Errorf("failed to copy tar entry: %v", err) 84 | } else { 85 | bytes += n 86 | count++ 87 | } 88 | 89 | if err := r.Close(); err != nil { 90 | return fmt.Errorf("failed to close tar entry reader: %v", err) 91 | } 92 | if err := w.Close(); err != nil { 93 | return fmt.Errorf("failed to close log writer: %v", err) 94 | } 95 | } 96 | 97 | if err := f.Close(); err != nil { 98 | return fmt.Errorf("failed to close tar file: %v", err) 99 | } 100 | 101 | zap.L().Info("migrated tar log", zap.String("log", logTar), zap.Int64("entriesCopied", count), zap.Int64("bytesCopied", bytes)) 102 | 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /internal/oplog/query.go: -------------------------------------------------------------------------------- 1 | package oplog 2 | 3 | import v1 "github.com/garethgeorge/backrest/gen/go/v1" 4 | 5 | type Query struct { 6 | // Filter by fields 7 | OpIDs []int64 8 | PlanID *string 9 | RepoGUID *string 10 | DeprecatedRepoID *string // Deprecated: use RepoGUID instead 11 | SnapshotID *string 12 | FlowID *int64 13 | InstanceID *string 14 | OriginalID *int64 15 | OriginalFlowID *int64 16 | 17 | // Pagination 18 | Limit int 19 | Offset int 20 | Reversed bool 21 | 22 | opIDmap map[int64]struct{} 23 | } 24 | 25 | func (q Query) SetOpIDs(opIDs []int64) Query { 26 | q.OpIDs = opIDs 27 | return q 28 | } 29 | 30 | func (q Query) SetPlanID(planID string) Query { 31 | q.PlanID = &planID 32 | return q 33 | } 34 | 35 | func (q Query) SetRepoGUID(repoGUID string) Query { 36 | q.RepoGUID = &repoGUID 37 | return q 38 | } 39 | 40 | func (q Query) SetSnapshotID(snapshotID string) Query { 41 | q.SnapshotID = &snapshotID 42 | return q 43 | } 44 | 45 | func (q Query) SetFlowID(flowID int64) Query { 46 | q.FlowID = &flowID 47 | return q 48 | } 49 | 50 | func (q Query) SetInstanceID(instanceID string) Query { 51 | q.InstanceID = &instanceID 52 | return q 53 | } 54 | 55 | func (q Query) SetOriginalID(originalID int64) Query { 56 | q.OriginalID = &originalID 57 | return q 58 | } 59 | 60 | func (q Query) SetOriginalFlowID(originalFlowID int64) Query { 61 | q.OriginalFlowID = &originalFlowID 62 | return q 63 | } 64 | 65 | func (q Query) SetLimit(limit int) Query { 66 | q.Limit = limit 67 | return q 68 | } 69 | 70 | func (q Query) SetOffset(offset int) Query { 71 | q.Offset = offset 72 | return q 73 | } 74 | 75 | func (q Query) SetReversed(reversed bool) Query { 76 | q.Reversed = reversed 77 | return q 78 | } 79 | 80 | var SelectAll = Query{} 81 | 82 | func (q *Query) buildOpIDMap() { 83 | if len(q.OpIDs) != len(q.opIDmap) { 84 | q.opIDmap = make(map[int64]struct{}, len(q.OpIDs)) 85 | for _, opID := range q.OpIDs { 86 | q.opIDmap[opID] = struct{}{} 87 | } 88 | } 89 | } 90 | 91 | func (q *Query) Match(op *v1.Operation) bool { 92 | if len(q.OpIDs) > 0 { 93 | q.buildOpIDMap() 94 | if _, ok := q.opIDmap[op.Id]; !ok { 95 | return false 96 | } 97 | } 98 | 99 | if q.InstanceID != nil && op.InstanceId != *q.InstanceID { 100 | return false 101 | } 102 | 103 | if q.PlanID != nil && op.PlanId != *q.PlanID { 104 | return false 105 | } 106 | 107 | if q.RepoGUID != nil && op.RepoGuid != *q.RepoGUID { 108 | return false 109 | } 110 | 111 | if q.SnapshotID != nil && op.SnapshotId != *q.SnapshotID { 112 | return false 113 | } 114 | 115 | if q.FlowID != nil && op.FlowId != *q.FlowID { 116 | return false 117 | } 118 | 119 | if q.OriginalID != nil && op.OriginalId != *q.OriginalID { 120 | return false 121 | } 122 | 123 | if q.OriginalFlowID != nil && op.OriginalFlowId != *q.OriginalFlowID { 124 | return false 125 | } 126 | 127 | return true 128 | } 129 | -------------------------------------------------------------------------------- /internal/oplog/randmodno.go: -------------------------------------------------------------------------------- 1 | package oplog 2 | 3 | import ( 4 | rand "math/rand/v2" 5 | "sync" 6 | 7 | "github.com/garethgeorge/backrest/internal/cryptoutil" 8 | ) 9 | 10 | // setup a fast random number generator seeded with cryptographic randomness. 11 | var mu sync.Mutex 12 | var pgcRand = rand.NewPCG(cryptoutil.MustRandomUint64(), cryptoutil.MustRandomUint64()) 13 | var randGen = rand.New(pgcRand) 14 | 15 | func NewRandomModno(lastModno int64) int64 { 16 | mu.Lock() 17 | defer mu.Unlock() 18 | for { 19 | modno := randGen.Int64() 20 | if modno != lastModno { 21 | return modno 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/oplog/sqlitestore/migrations_test.go: -------------------------------------------------------------------------------- 1 | package sqlitestore 2 | 3 | import ( 4 | "testing" 5 | 6 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 7 | "github.com/garethgeorge/backrest/internal/oplog" 8 | "github.com/garethgeorge/backrest/internal/testutil" 9 | "github.com/google/go-cmp/cmp" 10 | "google.golang.org/protobuf/testing/protocmp" 11 | ) 12 | 13 | func TestNewSqliteStore(t *testing.T) { 14 | tempDir := t.TempDir() 15 | store, err := NewSqliteStore(tempDir + "/test.sqlite") 16 | if err != nil { 17 | t.Fatalf("error creating sqlite store: %s", err) 18 | } 19 | t.Cleanup(func() { store.Close() }) 20 | } 21 | 22 | func TestMigrateExisting(t *testing.T) { 23 | tempDir := t.TempDir() 24 | 25 | testOps := []*v1.Operation{} 26 | for i := 0; i < 10; i++ { 27 | testOps = append(testOps, testutil.RandomOperation()) 28 | } 29 | 30 | store, err := NewSqliteStore(tempDir + "/test.sqlite") 31 | if err != nil { 32 | t.Fatalf("error creating sqlite store: %s", err) 33 | } 34 | 35 | // insert some test data 36 | if err := store.Add(testOps...); err != nil { 37 | t.Fatalf("error adding test data: %s", err) 38 | } 39 | 40 | gotOps := make([]*v1.Operation, 0) 41 | if err := store.Query(oplog.Query{}, func(op *v1.Operation) error { 42 | gotOps = append(gotOps, op) 43 | return nil 44 | }); err != nil { 45 | t.Fatalf("error querying sqlite store: %s", err) 46 | } 47 | 48 | if len(gotOps) != len(testOps) { 49 | t.Errorf("first check before migrations, expected %d operations, got %d", len(testOps), len(gotOps)) 50 | } 51 | 52 | if err := store.Close(); err != nil { 53 | t.Fatalf("error closing sqlite store: %s", err) 54 | } 55 | 56 | // re-open the store 57 | store2, err := NewSqliteStore(tempDir + "/test.sqlite") 58 | if err != nil { 59 | t.Fatalf("error creating sqlite store: %s", err) 60 | } 61 | 62 | gotOps = gotOps[:0] 63 | if err := store2.Query(oplog.Query{}, func(op *v1.Operation) error { 64 | gotOps = append(gotOps, op) 65 | return nil 66 | }); err != nil { 67 | t.Fatalf("error querying sqlite store: %s", err) 68 | } 69 | 70 | if len(gotOps) != len(testOps) { 71 | t.Errorf("expected %d operations, got %d", len(testOps), len(gotOps)) 72 | } 73 | 74 | if diff := cmp.Diff( 75 | &v1.OperationList{Operations: gotOps}, 76 | &v1.OperationList{Operations: testOps}, 77 | protocmp.Transform()); diff != "" { 78 | t.Errorf("unexpected diff in operations back after migration: %v", diff) 79 | } 80 | 81 | t.Cleanup(func() { store2.Close() }) 82 | } 83 | -------------------------------------------------------------------------------- /internal/oplog/sqlitestore/sqlutil.go: -------------------------------------------------------------------------------- 1 | package sqlitestore 2 | 3 | import ( 4 | "zombiezen.com/go/sqlite" 5 | "zombiezen.com/go/sqlite/sqlitex" 6 | ) 7 | 8 | // withSqliteTransaction should be used when the function only executes reads 9 | func withSqliteTransaction(conn *sqlite.Conn, f func() error) error { 10 | var err error 11 | endFunc := sqlitex.Transaction(conn) 12 | err = f() 13 | endFunc(&err) 14 | return err 15 | } 16 | 17 | func withImmediateSqliteTransaction(conn *sqlite.Conn, f func() error) error { 18 | var err error 19 | endFunc, err := sqlitex.ImmediateTransaction(conn) 20 | if err != nil { 21 | return err 22 | } 23 | err = f() 24 | endFunc(&err) 25 | return err 26 | } 27 | 28 | func withExclusiveSqliteTransaction(conn *sqlite.Conn, f func() error) error { 29 | var err error 30 | endFunc, err := sqlitex.ExclusiveTransaction(conn) 31 | if err != nil { 32 | return err 33 | } 34 | err = f() 35 | endFunc(&err) 36 | return err 37 | } 38 | -------------------------------------------------------------------------------- /internal/orchestrator/logging/logging.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/garethgeorge/backrest/internal/ioutil" 8 | "go.uber.org/zap" 9 | "go.uber.org/zap/zapcore" 10 | ) 11 | 12 | type contextKey int 13 | 14 | const ( 15 | contextKeyLogWriter contextKey = iota 16 | ) 17 | 18 | func WriterFromContext(ctx context.Context) io.Writer { 19 | writer, ok := ctx.Value(contextKeyLogWriter).(io.Writer) 20 | if !ok { 21 | return nil 22 | } 23 | return writer 24 | } 25 | 26 | func ContextWithWriter(ctx context.Context, logger io.Writer) context.Context { 27 | return context.WithValue(ctx, contextKeyLogWriter, logger) 28 | } 29 | 30 | // Logger returns a logger from the context, or the global logger if none is found. 31 | // this is somewhat expensive, it should be called once per task. 32 | func Logger(ctx context.Context, prefix string) *zap.Logger { 33 | writer := WriterFromContext(ctx) 34 | if writer == nil { 35 | return zap.L() 36 | } 37 | p := zap.NewProductionEncoderConfig() 38 | p.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05.000Z") 39 | fe := zapcore.NewConsoleEncoder(p) 40 | l := zap.New(zapcore.NewTee( 41 | zap.L().Core(), 42 | zapcore.NewCore(fe, zapcore.AddSync(&ioutil.LinePrefixer{W: writer, Prefix: []byte(prefix)}), zapcore.DebugLevel), 43 | )) 44 | return l 45 | } 46 | -------------------------------------------------------------------------------- /internal/orchestrator/orchestrator_test.go: -------------------------------------------------------------------------------- 1 | package orchestrator 2 | 3 | import ( 4 | "testing" 5 | 6 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 7 | "github.com/garethgeorge/backrest/internal/config" 8 | "github.com/garethgeorge/backrest/internal/resticinstaller" 9 | ) 10 | 11 | func TestAutoInitializeRepos(t *testing.T) { 12 | t.Parallel() 13 | 14 | configMgr := &config.ConfigManager{ 15 | Store: &config.MemoryStore{ 16 | Config: &v1.Config{ 17 | Version: 4, 18 | Instance: "test-instance", 19 | Repos: []*v1.Repo{ 20 | { 21 | Id: "test", 22 | Uri: t.TempDir(), 23 | Flags: []string{ 24 | "--no-cache", 25 | "--insecure-no-password", 26 | }, 27 | AutoInitialize: true, 28 | }, 29 | }, 30 | }, 31 | }, 32 | } 33 | 34 | resticBin, err := resticinstaller.FindOrInstallResticBinary() 35 | if err != nil { 36 | t.Fatalf("failed to find or install restic binary: %v", err) 37 | } 38 | 39 | _, err = NewOrchestrator(resticBin, configMgr, nil, nil) 40 | if err != nil { 41 | t.Fatalf("failed to create orchestrator: %v", err) 42 | } 43 | 44 | if err != nil { 45 | t.Fatalf("failed to construct orchestrator: %v", err) 46 | } 47 | 48 | newConfig, _ := configMgr.Get() 49 | 50 | if newConfig.Repos[0].Guid == "" { 51 | t.Fatalf("expected repo guid to be set") 52 | } 53 | if newConfig.Repos[0].AutoInitialize { 54 | t.Fatalf("expected repo auto-initialize to be false") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /internal/orchestrator/repo/command_prefix.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "errors" 5 | "os/exec" 6 | 7 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 8 | "github.com/garethgeorge/backrest/pkg/restic" 9 | ) 10 | 11 | func niceAvailable() bool { 12 | _, err := exec.LookPath("nice") 13 | return err == nil 14 | } 15 | 16 | func ioniceAvailable() bool { 17 | _, err := exec.LookPath("ionice") 18 | return err == nil 19 | } 20 | 21 | // resolveCommandPrefix returns a list of restic.GenericOption that should be applied to a restic command based on the given prefix. 22 | func resolveCommandPrefix(prefix *v1.CommandPrefix) ([]restic.GenericOption, error) { 23 | var opts []restic.GenericOption 24 | 25 | if prefix.GetCpuNice() != v1.CommandPrefix_CPU_DEFAULT { 26 | if !niceAvailable() { 27 | return nil, errors.New("nice not available, cpu_nice cannot be used") 28 | } 29 | switch prefix.GetCpuNice() { 30 | case v1.CommandPrefix_CPU_HIGH: 31 | opts = append(opts, restic.WithPrefixCommand("nice", "-n", "-10")) 32 | case v1.CommandPrefix_CPU_LOW: 33 | opts = append(opts, restic.WithPrefixCommand("nice", "-n", "10")) 34 | } 35 | } 36 | 37 | if prefix.GetIoNice() != v1.CommandPrefix_IO_DEFAULT { 38 | if !ioniceAvailable() { 39 | return nil, errors.New("ionice not available, io_nice cannot be used") 40 | } 41 | switch prefix.GetIoNice() { 42 | case v1.CommandPrefix_IO_IDLE: 43 | opts = append(opts, restic.WithPrefixCommand("ionice", "-c", "3")) // idle priority, only runs when other IO is not queued. 44 | case v1.CommandPrefix_IO_BEST_EFFORT_LOW: 45 | opts = append(opts, restic.WithPrefixCommand("ionice", "-c", "2", "-n", "7")) // best effort, low priority. Default is -n 4. 46 | case v1.CommandPrefix_IO_BEST_EFFORT_HIGH: 47 | opts = append(opts, restic.WithPrefixCommand("ionice", "-c", "2", "-n", "0")) // best effort, high(er) than default priority. Default is -n 4. 48 | } 49 | } 50 | 51 | return opts, nil 52 | } 53 | -------------------------------------------------------------------------------- /internal/orchestrator/repo/env.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "os" 5 | "regexp" 6 | ) 7 | 8 | var ( 9 | envVarSubstRegex = regexp.MustCompile(`\${[^}]*}`) 10 | ) 11 | 12 | // ExpandEnv expands environment variables of the form ${VAR} in a string. 13 | func ExpandEnv(s string) string { 14 | return envVarSubstRegex.ReplaceAllStringFunc(s, func(match string) string { 15 | e, _ := os.LookupEnv(match[2 : len(match)-1]) 16 | return e 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /internal/orchestrator/repo/logging.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/garethgeorge/backrest/internal/ioutil" 8 | "github.com/garethgeorge/backrest/internal/orchestrator/logging" 9 | "github.com/garethgeorge/backrest/pkg/restic" 10 | ) 11 | 12 | // pipeResticLogsToWriter sets the restic logger to write to the provided writer. 13 | // returns a new context with the logger set and a function to flush the logs. 14 | func forwardResticLogs(ctx context.Context) (context.Context, func()) { 15 | writer := logging.WriterFromContext(ctx) 16 | if writer == nil { 17 | return ctx, func() {} 18 | } 19 | limitWriter := &ioutil.LimitWriter{W: writer, N: 64 * 1024} 20 | prefixWriter := &ioutil.LinePrefixer{W: limitWriter, Prefix: []byte("[restic] ")} 21 | return restic.ContextWithLogger(ctx, prefixWriter), func() { 22 | if limitWriter.D > 0 { 23 | fmt.Fprintf(prefixWriter, "... Output truncated, %d bytes dropped\n", limitWriter.D) 24 | } 25 | prefixWriter.Close() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/orchestrator/repo/tags.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // TagForPlan returns a tag for the plan. 9 | func TagForPlan(planId string) string { 10 | return fmt.Sprintf("plan:%s", planId) 11 | } 12 | 13 | // TagForInstance returns a tag for the instance. 14 | func TagForInstance(instanceId string) string { 15 | return fmt.Sprintf("created-by:%s", instanceId) 16 | } 17 | 18 | // InstanceIDFromTags returns the instance ID from the tags, or an empty string if not found. 19 | func InstanceIDFromTags(tags []string) string { 20 | for _, tag := range tags { 21 | if strings.HasPrefix(tag, "created-by:") { 22 | return tag[len("created-by:"):] 23 | } 24 | } 25 | return "" 26 | } 27 | 28 | // PlanFromTags returns the plan ID from the tags, or an empty string if not found. 29 | func PlanFromTags(tags []string) string { 30 | for _, tag := range tags { 31 | if strings.HasPrefix(tag, "plan:") { 32 | return tag[len("plan:"):] 33 | } 34 | } 35 | return "" 36 | } 37 | -------------------------------------------------------------------------------- /internal/orchestrator/tasks/errors.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | // TaskCancelledError is returned when a task is cancelled. 9 | type TaskCancelledError struct { 10 | } 11 | 12 | func (e TaskCancelledError) Error() string { 13 | return "task cancelled" 14 | } 15 | 16 | func (e TaskCancelledError) Is(err error) bool { 17 | _, ok := err.(TaskCancelledError) 18 | return ok 19 | } 20 | 21 | type RetryBackoffPolicy = func(attempt int) time.Duration 22 | 23 | // TaskRetryError is returned when a task should be retried after a specified backoff duration. 24 | type TaskRetryError struct { 25 | Err error 26 | Backoff RetryBackoffPolicy 27 | } 28 | 29 | func (e TaskRetryError) Error() string { 30 | return fmt.Sprintf("retry: %v", e.Err.Error()) 31 | } 32 | 33 | func (e TaskRetryError) Unwrap() error { 34 | return e.Err 35 | } 36 | -------------------------------------------------------------------------------- /internal/orchestrator/tasks/flowidutil.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "fmt" 5 | 6 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 7 | "github.com/garethgeorge/backrest/internal/oplog" 8 | ) 9 | 10 | // FlowIDForSnapshotID returns the flow ID associated with the backup task that created snapshot ID or 0 if not found. 11 | func FlowIDForSnapshotID(runner TaskRunner, repoGUID string, snapshotID string) (int64, error) { 12 | var flowID int64 13 | if err := runner.QueryOperations(oplog.Query{SnapshotID: &snapshotID}, func(op *v1.Operation) error { 14 | if op.RepoGuid != repoGUID { 15 | // ignore operations from other repos, done here instead of in the query 16 | // to encourage sqlite to make the right index choice. SnapshotID is vastly 17 | // more selective than RepoGUID. 18 | return nil 19 | } 20 | if _, ok := op.Op.(*v1.Operation_OperationBackup); !ok { 21 | return nil 22 | } 23 | if flowID != 0 { 24 | return fmt.Errorf("multiple flow IDs found for snapshot %q", snapshotID) 25 | } 26 | flowID = op.FlowId 27 | return nil 28 | }); err != nil { 29 | return 0, fmt.Errorf("get flow id for snapshot %q : %w", snapshotID, err) 30 | } 31 | return flowID, nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/orchestrator/tasks/hookvars_test.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "testing" 5 | 6 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 7 | ) 8 | 9 | func TestHookVarsEventName(t *testing.T) { 10 | // for every value of condition, check that the event name is correct 11 | values := v1.Hook_Condition(0).Descriptor().Values() 12 | for i := 0; i < values.Len(); i++ { 13 | condition := v1.Hook_Condition(values.Get(i).Number()) 14 | if condition == v1.Hook_CONDITION_UNKNOWN { 15 | continue 16 | } 17 | 18 | vars := HookVars{} 19 | if vars.EventName(condition) == "unknown" { 20 | t.Errorf("unexpected event name for condition %v", condition) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/orchestrator/tasks/taskforgetsnapshot.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 9 | ) 10 | 11 | func NewOneoffForgetSnapshotTask(repo *v1.Repo, planID string, flowID int64, at time.Time, snapshotID string) Task { 12 | return &GenericOneoffTask{ 13 | OneoffTask: OneoffTask{ 14 | BaseTask: BaseTask{ 15 | TaskType: "forget_snapshot", 16 | TaskName: fmt.Sprintf("forget snapshot %q for plan %q in repo %q", snapshotID, planID, repo.Id), 17 | TaskRepo: repo, 18 | TaskPlanID: planID, 19 | }, 20 | FlowID: flowID, 21 | RunAt: at, 22 | ProtoOp: &v1.Operation{ 23 | Op: &v1.Operation_OperationForget{}, 24 | }, 25 | }, 26 | Do: func(ctx context.Context, st ScheduledTask, taskRunner TaskRunner) error { 27 | op := st.Op 28 | forgetOp := op.GetOperationForget() 29 | if forgetOp == nil { 30 | panic("forget task with non-forget operation") 31 | } 32 | 33 | if err := forgetSnapshotHelper(ctx, st, taskRunner, snapshotID); err != nil { 34 | taskRunner.ExecuteHooks(ctx, []v1.Hook_Condition{ 35 | v1.Hook_CONDITION_ANY_ERROR, 36 | }, HookVars{ 37 | Error: err.Error(), 38 | }) 39 | return err 40 | } 41 | return nil 42 | }, 43 | } 44 | } 45 | 46 | func forgetSnapshotHelper(ctx context.Context, st ScheduledTask, taskRunner TaskRunner, snapshotID string) error { 47 | t := st.Task 48 | 49 | repo, err := taskRunner.GetRepoOrchestrator(t.RepoID()) 50 | if err != nil { 51 | return fmt.Errorf("get repo %q: %w", t.RepoID(), err) 52 | } 53 | 54 | err = repo.UnlockIfAutoEnabled(ctx) 55 | if err != nil { 56 | return fmt.Errorf("auto unlock repo %q: %w", t.RepoID(), err) 57 | } 58 | 59 | if err := repo.ForgetSnapshot(ctx, snapshotID); err != nil { 60 | return fmt.Errorf("forget %q: %w", snapshotID, err) 61 | } 62 | 63 | taskRunner.ScheduleTask(NewOneoffIndexSnapshotsTask(t.Repo(), time.Now()), TaskPriorityIndexSnapshots) 64 | taskRunner.DeleteOperation(st.Op.Id) 65 | st.Op = nil 66 | return err 67 | } 68 | -------------------------------------------------------------------------------- /internal/orchestrator/tasks/taskrestore.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "sync" 8 | "time" 9 | 10 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | func NewOneoffRestoreTask(repo *v1.Repo, planID string, flowID int64, at time.Time, snapshotID, path, target string) Task { 15 | return &GenericOneoffTask{ 16 | OneoffTask: OneoffTask{ 17 | BaseTask: BaseTask{ 18 | TaskType: "restore", 19 | TaskName: fmt.Sprintf("restore snapshot %q in repo %q", snapshotID, repo.Id), 20 | TaskRepo: repo, 21 | TaskPlanID: planID, 22 | }, 23 | FlowID: flowID, 24 | RunAt: at, 25 | ProtoOp: &v1.Operation{ 26 | SnapshotId: snapshotID, 27 | Op: &v1.Operation_OperationRestore{ 28 | OperationRestore: &v1.OperationRestore{ 29 | Path: path, 30 | Target: target, 31 | }, 32 | }, 33 | }, 34 | }, 35 | Do: func(ctx context.Context, st ScheduledTask, taskRunner TaskRunner) error { 36 | if err := restoreHelper(ctx, st, taskRunner, snapshotID, path, target); err != nil { 37 | taskRunner.ExecuteHooks(ctx, []v1.Hook_Condition{ 38 | v1.Hook_CONDITION_ANY_ERROR, 39 | }, HookVars{ 40 | Task: st.Task.Name(), 41 | Error: err.Error(), 42 | }) 43 | return err 44 | } 45 | return nil 46 | }, 47 | } 48 | } 49 | 50 | func restoreHelper(ctx context.Context, st ScheduledTask, taskRunner TaskRunner, snapshotID, path, target string) error { 51 | t := st.Task 52 | op := st.Op 53 | 54 | if snapshotID == "" || path == "" || target == "" { 55 | return errors.New("snapshotID, path, and target are required") 56 | } 57 | 58 | restoreOp := st.Op.GetOperationRestore() 59 | if restoreOp == nil { 60 | return errors.New("operation is not a restore operation") 61 | } 62 | 63 | repo, err := taskRunner.GetRepoOrchestrator(t.RepoID()) 64 | if err != nil { 65 | return fmt.Errorf("couldn't get repo %q: %w", t.RepoID(), err) 66 | } 67 | 68 | var sendWg sync.WaitGroup 69 | lastSent := time.Now() // debounce progress updates, these can endup being very frequent. 70 | summary, err := repo.Restore(ctx, snapshotID, path, target, func(entry *v1.RestoreProgressEntry) { 71 | sendWg.Wait() 72 | if time.Since(lastSent) < 1*time.Second { 73 | return 74 | } 75 | lastSent = time.Now() 76 | 77 | restoreOp.LastStatus = entry 78 | 79 | sendWg.Add(1) 80 | go func() { 81 | if err := taskRunner.UpdateOperation(op); err != nil { 82 | zap.S().Errorf("failed to update oplog with progress for restore: %v", err) 83 | } 84 | sendWg.Done() 85 | }() 86 | }) 87 | 88 | if err != nil { 89 | return err 90 | } 91 | restoreOp.LastStatus = summary 92 | 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /internal/orchestrator/tasks/taskruncommand.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 9 | "github.com/garethgeorge/backrest/internal/ioutil" 10 | ) 11 | 12 | var DefaultCommandOutputSizeLimit uint64 = 2_000_000 // 2MB 13 | 14 | func NewOneoffRunCommandTask(repo *v1.Repo, planID string, flowID int64, at time.Time, command string) Task { 15 | return &GenericOneoffTask{ 16 | OneoffTask: OneoffTask{ 17 | BaseTask: BaseTask{ 18 | TaskType: "run_command", 19 | TaskName: fmt.Sprintf("run command in repo %q", repo.Id), 20 | TaskRepo: repo, 21 | TaskPlanID: planID, 22 | }, 23 | FlowID: flowID, 24 | RunAt: at, 25 | ProtoOp: &v1.Operation{ 26 | Op: &v1.Operation_OperationRunCommand{ 27 | OperationRunCommand: &v1.OperationRunCommand{ 28 | Command: command, 29 | }, 30 | }, 31 | }, 32 | }, 33 | Do: func(ctx context.Context, st ScheduledTask, taskRunner TaskRunner) error { 34 | op := st.Op 35 | rc := op.GetOperationRunCommand() 36 | if rc == nil { 37 | panic("run command task with non-forget operation") 38 | } 39 | 40 | return runCommandHelper(ctx, st, taskRunner, command) 41 | }, 42 | } 43 | } 44 | 45 | func runCommandHelper(ctx context.Context, st ScheduledTask, taskRunner TaskRunner, command string) error { 46 | t := st.Task 47 | runCmdOp := st.Op.GetOperationRunCommand() 48 | 49 | repo, err := taskRunner.GetRepoOrchestrator(t.RepoID()) 50 | if err != nil { 51 | return fmt.Errorf("get repo %q: %w", t.RepoID(), err) 52 | } 53 | 54 | id, writer, err := taskRunner.LogrefWriter() 55 | if err != nil { 56 | return fmt.Errorf("get logref writer: %w", err) 57 | } 58 | defer writer.Close() 59 | sizeWriter := &ioutil.SizeLimitedWriter{ 60 | SizeTrackingWriter: ioutil.SizeTrackingWriter{Writer: writer}, 61 | Limit: DefaultCommandOutputSizeLimit, // 2 MB max output size 62 | } 63 | defer func() { 64 | size := sizeWriter.Size() 65 | runCmdOp.OutputSizeBytes = int64(size) 66 | }() 67 | 68 | runCmdOp.OutputLogref = id 69 | if err := taskRunner.UpdateOperation(st.Op); err != nil { 70 | return fmt.Errorf("update operation: %w", err) 71 | } 72 | 73 | if err := repo.RunCommand(ctx, command, sizeWriter); err != nil { 74 | return fmt.Errorf("command %q: %w", command, err) 75 | } 76 | 77 | if err := writer.Close(); err != nil { 78 | return fmt.Errorf("close logref writer: %w", err) 79 | } 80 | 81 | return err 82 | } 83 | -------------------------------------------------------------------------------- /internal/orchestrator/tasks/taskstats.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 9 | "github.com/garethgeorge/backrest/internal/oplog" 10 | ) 11 | 12 | type StatsTask struct { 13 | BaseTask 14 | force bool 15 | didRun bool 16 | } 17 | 18 | func NewStatsTask(repo *v1.Repo, planID string, force bool) Task { 19 | return &StatsTask{ 20 | BaseTask: BaseTask{ 21 | TaskType: "stats", 22 | TaskName: fmt.Sprintf("stats for repo %q", repo.Id), 23 | TaskRepo: repo, 24 | TaskPlanID: planID, 25 | }, 26 | force: force, 27 | } 28 | } 29 | 30 | func (t *StatsTask) Next(now time.Time, runner TaskRunner) (ScheduledTask, error) { 31 | if t.force { 32 | if t.didRun { 33 | return NeverScheduledTask, nil 34 | } 35 | t.didRun = true 36 | return ScheduledTask{ 37 | Task: t, 38 | RunAt: now, 39 | Op: &v1.Operation{ 40 | Op: &v1.Operation_OperationStats{}, 41 | }, 42 | }, nil 43 | } 44 | 45 | // check last stats time 46 | var lastRan time.Time 47 | if err := runner.QueryOperations(oplog.Query{}. 48 | SetInstanceID(runner.InstanceID()). // note: this means that stats tasks run by remote instances are ignored. 49 | SetRepoGUID(t.Repo().GetGuid()). 50 | SetReversed(true), func(op *v1.Operation) error { 51 | if op.Status == v1.OperationStatus_STATUS_PENDING || op.Status == v1.OperationStatus_STATUS_SYSTEM_CANCELLED { 52 | return nil 53 | } 54 | if _, ok := op.Op.(*v1.Operation_OperationStats); ok && op.UnixTimeEndMs != 0 { 55 | lastRan = time.Unix(0, op.UnixTimeEndMs*int64(time.Millisecond)) 56 | return oplog.ErrStopIteration 57 | } 58 | return nil 59 | }); err != nil { 60 | return NeverScheduledTask, fmt.Errorf("finding last check run time: %w", err) 61 | } 62 | 63 | // Runs at most once per day. 64 | if time.Since(lastRan) < 24*time.Hour { 65 | return NeverScheduledTask, nil 66 | } 67 | return ScheduledTask{ 68 | Task: t, 69 | RunAt: now, 70 | Op: &v1.Operation{ 71 | Op: &v1.Operation_OperationStats{}, 72 | }, 73 | }, nil 74 | } 75 | 76 | func (t *StatsTask) Run(ctx context.Context, st ScheduledTask, runner TaskRunner) error { 77 | if err := statsHelper(ctx, st, runner); err != nil { 78 | runner.ExecuteHooks(ctx, []v1.Hook_Condition{ 79 | v1.Hook_CONDITION_ANY_ERROR, 80 | }, HookVars{ 81 | Task: st.Task.Name(), 82 | Error: err.Error(), 83 | }) 84 | return err 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func statsHelper(ctx context.Context, st ScheduledTask, taskRunner TaskRunner) error { 91 | t := st.Task 92 | 93 | repo, err := taskRunner.GetRepoOrchestrator(t.RepoID()) 94 | if err != nil { 95 | return fmt.Errorf("get repo %q: %w", t.RepoID(), err) 96 | } 97 | 98 | stats, err := repo.Stats(ctx) 99 | if err != nil { 100 | return fmt.Errorf("get stats: %w", err) 101 | } 102 | 103 | op := st.Op 104 | op.Op = &v1.Operation_OperationStats{ 105 | OperationStats: &v1.OperationStats{ 106 | Stats: stats, 107 | }, 108 | } 109 | 110 | return nil 111 | } 112 | -------------------------------------------------------------------------------- /internal/protoutil/conditions.go: -------------------------------------------------------------------------------- 1 | package protoutil 2 | 3 | import ( 4 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 5 | ) 6 | 7 | var startConditionsMap = map[v1.Hook_Condition]bool{ 8 | v1.Hook_CONDITION_CHECK_START: true, 9 | v1.Hook_CONDITION_PRUNE_START: true, 10 | v1.Hook_CONDITION_SNAPSHOT_START: true, 11 | v1.Hook_CONDITION_FORGET_START: true, 12 | } 13 | 14 | var errorConditionsMap = map[v1.Hook_Condition]bool{ 15 | v1.Hook_CONDITION_ANY_ERROR: true, 16 | v1.Hook_CONDITION_CHECK_ERROR: true, 17 | v1.Hook_CONDITION_PRUNE_ERROR: true, 18 | v1.Hook_CONDITION_SNAPSHOT_ERROR: true, 19 | v1.Hook_CONDITION_FORGET_ERROR: true, 20 | v1.Hook_CONDITION_UNKNOWN: true, 21 | } 22 | 23 | var logConditionsMap = map[v1.Hook_Condition]bool{ 24 | v1.Hook_CONDITION_SNAPSHOT_END: true, 25 | } 26 | 27 | var successConditionsMap = map[v1.Hook_Condition]bool{ 28 | v1.Hook_CONDITION_CHECK_SUCCESS: true, 29 | v1.Hook_CONDITION_PRUNE_SUCCESS: true, 30 | v1.Hook_CONDITION_SNAPSHOT_SUCCESS: true, 31 | v1.Hook_CONDITION_FORGET_SUCCESS: true, 32 | } 33 | 34 | // IsErrorCondition returns true if the event is an error condition. 35 | func IsErrorCondition(event v1.Hook_Condition) bool { 36 | return errorConditionsMap[event] 37 | } 38 | 39 | // IsLogCondition returns true if the event is a log condition. 40 | func IsLogCondition(event v1.Hook_Condition) bool { 41 | return logConditionsMap[event] 42 | } 43 | 44 | // IsStartCondition returns true if the event is a start condition. 45 | func IsStartCondition(event v1.Hook_Condition) bool { 46 | return startConditionsMap[event] 47 | } 48 | 49 | // IsSuccessCondition returns true if the event is a success condition. 50 | func IsSuccessCondition(event v1.Hook_Condition) bool { 51 | return successConditionsMap[event] 52 | } 53 | -------------------------------------------------------------------------------- /internal/protoutil/conditions_test.go: -------------------------------------------------------------------------------- 1 | package protoutil 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 8 | ) 9 | 10 | func TestStartConditionsMap(t *testing.T) { 11 | // Test that all conditions with "_START" in their name are correctly identified by IsStartCondition 12 | for cond := range v1.Hook_Condition_name { 13 | condEnum := v1.Hook_Condition(cond) 14 | condName := condEnum.String() 15 | if strings.Contains(condName, "_START") { 16 | if !IsStartCondition(condEnum) { 17 | t.Errorf("Condition %s contains '_START' but IsStartCondition returned false", condName) 18 | } 19 | } else { 20 | if IsStartCondition(condEnum) { 21 | t.Errorf("Condition %s does not contain '_START' but IsStartCondition returned true", condName) 22 | } 23 | } 24 | } 25 | } 26 | 27 | func TestErrorConditionsMap(t *testing.T) { 28 | // Special case for CONDITION_UNKNOWN which should be identified as an error condition 29 | if !IsErrorCondition(v1.Hook_CONDITION_UNKNOWN) { 30 | t.Errorf("CONDITION_UNKNOWN should be identified as an error condition") 31 | } 32 | 33 | // Special case for ANY_ERROR which should be identified as an error condition 34 | if !IsErrorCondition(v1.Hook_CONDITION_ANY_ERROR) { 35 | t.Errorf("CONDITION_ANY_ERROR should be identified as an error condition") 36 | } 37 | 38 | // Test that all conditions with "_ERROR" in their name are correctly identified by IsErrorCondition 39 | for cond := range v1.Hook_Condition_name { 40 | condEnum := v1.Hook_Condition(cond) 41 | condName := condEnum.String() 42 | 43 | // Skip the special cases we already checked 44 | if condEnum == v1.Hook_CONDITION_UNKNOWN || condEnum == v1.Hook_CONDITION_ANY_ERROR { 45 | continue 46 | } 47 | 48 | if strings.Contains(condName, "_ERROR") { 49 | if !IsErrorCondition(condEnum) { 50 | t.Errorf("Condition %s contains '_ERROR' but IsErrorCondition returned false", condName) 51 | } 52 | } else if IsErrorCondition(condEnum) { 53 | t.Errorf("Condition %s does not contain '_ERROR' but IsErrorCondition returned true", condName) 54 | } 55 | } 56 | } 57 | 58 | func TestSuccessConditionsMap(t *testing.T) { 59 | // Test that all conditions with "_SUCCESS" in their name are correctly identified by IsSuccessCondition 60 | for cond := range v1.Hook_Condition_name { 61 | condEnum := v1.Hook_Condition(cond) 62 | condName := condEnum.String() 63 | if strings.Contains(condName, "_SUCCESS") { 64 | if !IsSuccessCondition(condEnum) { 65 | t.Errorf("Condition %s contains '_SUCCESS' but IsSuccessCondition returned false", condName) 66 | } 67 | } else { 68 | if IsSuccessCondition(condEnum) { 69 | t.Errorf("Condition %s does not contain '_SUCCESS' but IsSuccessCondition returned true", condName) 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/protoutil/opselector.go: -------------------------------------------------------------------------------- 1 | package protoutil 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | 7 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 8 | "github.com/garethgeorge/backrest/internal/oplog" 9 | ) 10 | 11 | func OpSelectorToQuery(sel *v1.OpSelector) (oplog.Query, error) { 12 | if sel == nil { 13 | return oplog.Query{}, errors.New("empty selector") 14 | } 15 | 16 | q := oplog.Query{ 17 | RepoGUID: sel.RepoGuid, 18 | PlanID: sel.PlanId, 19 | SnapshotID: sel.SnapshotId, 20 | FlowID: sel.FlowId, 21 | InstanceID: sel.InstanceId, 22 | } 23 | if len(sel.Ids) > 0 && !reflect.DeepEqual(q, oplog.Query{}) { 24 | return oplog.Query{}, errors.New("cannot specify both query and ids") 25 | } 26 | q.OpIDs = sel.Ids 27 | return q, nil 28 | } 29 | -------------------------------------------------------------------------------- /internal/protoutil/schedule.go: -------------------------------------------------------------------------------- 1 | package protoutil 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 9 | "github.com/gitploy-io/cronexpr" 10 | ) 11 | 12 | var ErrScheduleDisabled = errors.New("never") 13 | 14 | // ResolveSchedule resolves a schedule to the next time it should run based on last execution. 15 | // note that this is different from backup behavior which is always relative to the current time. 16 | func ResolveSchedule(sched *v1.Schedule, lastRan time.Time, curTime time.Time) (time.Time, error) { 17 | var t time.Time 18 | switch sched.GetClock() { 19 | case v1.Schedule_CLOCK_DEFAULT, v1.Schedule_CLOCK_LOCAL: 20 | t = curTime.Local() 21 | case v1.Schedule_CLOCK_UTC: 22 | t = curTime.UTC() 23 | case v1.Schedule_CLOCK_LAST_RUN_TIME: 24 | t = lastRan 25 | default: 26 | return time.Time{}, fmt.Errorf("unknown clock type: %v", sched.GetClock().String()) 27 | } 28 | 29 | switch s := sched.GetSchedule().(type) { 30 | case *v1.Schedule_Disabled, nil: 31 | return time.Time{}, ErrScheduleDisabled 32 | case *v1.Schedule_MaxFrequencyDays: 33 | return t.Add(time.Duration(s.MaxFrequencyDays) * 24 * time.Hour), nil 34 | case *v1.Schedule_MaxFrequencyHours: 35 | return t.Add(time.Duration(s.MaxFrequencyHours) * time.Hour), nil 36 | case *v1.Schedule_Cron: 37 | cron, err := cronexpr.ParseInLocation(s.Cron, time.Now().Location().String()) 38 | if err != nil { 39 | return time.Time{}, fmt.Errorf("parse cron %q: %w", s.Cron, err) 40 | } 41 | return cron.Next(t), nil 42 | default: 43 | return time.Time{}, fmt.Errorf("unknown schedule type: %T", s) 44 | } 45 | } 46 | 47 | func ValidateSchedule(sched *v1.Schedule) error { 48 | switch s := sched.GetSchedule().(type) { 49 | case *v1.Schedule_MaxFrequencyDays: 50 | if s.MaxFrequencyDays < 1 { 51 | return errors.New("invalid max frequency days") 52 | } 53 | case *v1.Schedule_MaxFrequencyHours: 54 | if s.MaxFrequencyHours < 1 { 55 | return errors.New("invalid max frequency hours") 56 | } 57 | case *v1.Schedule_Cron: 58 | if s.Cron == "" { 59 | return errors.New("empty cron expression") 60 | } 61 | _, err := cronexpr.ParseInLocation(s.Cron, time.Now().Location().String()) 62 | if err != nil { 63 | return fmt.Errorf("invalid cron %q: %w", s.Cron, err) 64 | } 65 | case nil: 66 | return nil 67 | case *v1.Schedule_Disabled: 68 | if !s.Disabled { 69 | return errors.New("disabled boolean must be set to true") 70 | } 71 | default: 72 | return fmt.Errorf("unknown schedule type: %T", s) 73 | } 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /internal/protoutil/syncconversion.go: -------------------------------------------------------------------------------- 1 | package protoutil 2 | 3 | import ( 4 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 5 | ) 6 | 7 | func RepoToRemoteRepo(r *v1.Repo) *v1.RemoteRepo { 8 | if r == nil { 9 | return nil 10 | } 11 | return &v1.RemoteRepo{ 12 | Id: r.Id, 13 | Guid: r.Guid, 14 | Uri: r.Uri, 15 | Password: r.Password, 16 | Env: r.Env, 17 | Flags: r.Flags, 18 | } 19 | } 20 | 21 | func RemoteRepoToRepo(r *v1.RemoteRepo) *v1.Repo { 22 | if r == nil { 23 | return nil 24 | } 25 | return &v1.Repo{ 26 | Id: r.Id, 27 | Guid: r.Guid, 28 | Uri: r.Uri, 29 | Password: r.Password, 30 | Env: r.Env, 31 | Flags: r.Flags, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/protoutil/syncconversion_test.go: -------------------------------------------------------------------------------- 1 | package protoutil 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 8 | ) 9 | 10 | func TestRepoToRemoteRepo(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | repo *v1.Repo 14 | want *v1.RemoteRepo 15 | }{ 16 | { 17 | name: "basic conversion", 18 | repo: &v1.Repo{ 19 | Id: "1", 20 | Uri: "http://example.com", 21 | Password: "password", 22 | Env: []string{"FOO=BAR"}, 23 | Flags: []string{"flag1", "flag2"}, 24 | }, 25 | want: &v1.RemoteRepo{ 26 | Id: "1", 27 | Uri: "http://example.com", 28 | Password: "password", 29 | Env: []string{"FOO=BAR"}, 30 | Flags: []string{"flag1", "flag2"}, 31 | }, 32 | }, 33 | { 34 | name: "empty repo", 35 | repo: &v1.Repo{}, 36 | want: &v1.RemoteRepo{}, 37 | }, 38 | { 39 | name: "nil repo", 40 | repo: nil, 41 | want: nil, 42 | }, 43 | } 44 | 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | if got := RepoToRemoteRepo(tt.repo); !reflect.DeepEqual(got, tt.want) { 48 | t.Errorf("RepoToRemoteRepo() = %v, want %v", got, tt.want) 49 | } 50 | }) 51 | } 52 | } 53 | 54 | func TestRemoteRepoToRepo(t *testing.T) { 55 | tests := []struct { 56 | name string 57 | remoteRepo *v1.RemoteRepo 58 | want *v1.Repo 59 | }{ 60 | { 61 | name: "basic conversion", 62 | remoteRepo: &v1.RemoteRepo{ 63 | Id: "1", 64 | Uri: "http://example.com", 65 | Password: "password", 66 | Env: []string{"FOO=BAR"}, 67 | Flags: []string{"flag1", "flag2"}, 68 | }, 69 | want: &v1.Repo{ 70 | Id: "1", 71 | Uri: "http://example.com", 72 | Password: "password", 73 | Env: []string{"FOO=BAR"}, 74 | Flags: []string{"flag1", "flag2"}, 75 | }, 76 | }, 77 | { 78 | name: "empty remote repo", 79 | remoteRepo: &v1.RemoteRepo{}, 80 | want: &v1.Repo{}, 81 | }, 82 | { 83 | name: "nil remote repo", 84 | remoteRepo: nil, 85 | want: nil, 86 | }, 87 | } 88 | 89 | for _, tt := range tests { 90 | t.Run(tt.name, func(t *testing.T) { 91 | if got := RemoteRepoToRepo(tt.remoteRepo); !reflect.DeepEqual(got, tt.want) { 92 | t.Errorf("RemoteRepoToRepo() = %v, want %v", got, tt.want) 93 | } 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /internal/protoutil/validation.go: -------------------------------------------------------------------------------- 1 | package protoutil 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 8 | "github.com/garethgeorge/backrest/pkg/restic" 9 | ) 10 | 11 | var ( 12 | errIDRequired = errors.New("id is required") 13 | errFlowIDRequired = errors.New("flow_id is required") 14 | errRepoIDRequired = errors.New("repo_id is required") 15 | errRepoGUIDRequired = errors.New("repo_guid is required") 16 | errPlanIDRequired = errors.New("plan_id is required") 17 | errInstanceIDRequired = errors.New("instance_id is required") 18 | errUnixTimeStartMsRequired = errors.New("unix_time_start_ms must be non-zero") 19 | ) 20 | 21 | // ValidateOperation verifies critical properties of the operation proto. 22 | func ValidateOperation(op *v1.Operation) error { 23 | if op.Id == 0 { 24 | return errIDRequired 25 | } 26 | if op.RepoGuid == "" { 27 | return errRepoGUIDRequired 28 | } 29 | if op.FlowId == 0 { 30 | return errFlowIDRequired 31 | } 32 | if op.RepoId == "" { 33 | return errRepoIDRequired 34 | } 35 | if op.PlanId == "" { 36 | return errPlanIDRequired 37 | } 38 | if op.InstanceId == "" { 39 | return errInstanceIDRequired 40 | } 41 | if op.UnixTimeStartMs == 0 { 42 | return errUnixTimeStartMsRequired 43 | } 44 | if op.SnapshotId != "" { 45 | if err := restic.ValidateSnapshotId(op.SnapshotId); err != nil { 46 | return fmt.Errorf("operation.snapshot_id is invalid: %w", err) 47 | } 48 | } 49 | return nil 50 | } 51 | 52 | // ValidateSnapshot verifies critical properties of the snapshot proto representation. 53 | func ValidateSnapshot(s *v1.ResticSnapshot) error { 54 | if s.Id == "" { 55 | return errors.New("snapshot.id is required") 56 | } 57 | if s.UnixTimeMs == 0 { 58 | return errors.New("snapshot.unix_time_ms must be non-zero") 59 | } 60 | if err := restic.ValidateSnapshotId(s.Id); err != nil { 61 | return err 62 | } 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/queue/genheap.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | // genericHeap is a generic heap implementation that can be used with any type that satisfies the constraints.Ordered interface. 4 | type GenericHeap[T Comparable[T]] []T 5 | 6 | func (h GenericHeap[T]) Len() int { 7 | return len(h) 8 | } 9 | 10 | func (h GenericHeap[T]) Swap(i, j int) { 11 | h[i], h[j] = h[j], h[i] 12 | } 13 | 14 | // Push pushes an element onto the heap. Do not call directly, use heap.Push 15 | func (h *GenericHeap[T]) Push(x interface{}) { 16 | *h = append(*h, x.(T)) 17 | } 18 | 19 | // Pop pops an element from the heap. Do not call directly, use heap.Pop 20 | func (h *GenericHeap[T]) Pop() interface{} { 21 | old := *h 22 | n := len(old) 23 | x := old[n-1] 24 | *h = old[0 : n-1] 25 | return x 26 | } 27 | 28 | func (h GenericHeap[T]) Peek() T { 29 | if len(h) == 0 { 30 | var zero T 31 | return zero 32 | } 33 | return h[0] 34 | } 35 | 36 | func (h GenericHeap[T]) Less(i, j int) bool { 37 | return h[i].Less(h[j]) 38 | } 39 | 40 | type Comparable[T any] interface { 41 | Less(other T) bool 42 | } 43 | -------------------------------------------------------------------------------- /internal/queue/genheap_test.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "container/heap" 5 | "testing" 6 | ) 7 | 8 | type val struct { 9 | v int 10 | } 11 | 12 | func (v val) Less(other val) bool { 13 | return v.v < other.v 14 | } 15 | 16 | func (v val) Eq(other val) bool { 17 | return v.v == other.v 18 | } 19 | 20 | func TestGenericHeapInit(t *testing.T) { 21 | t.Parallel() 22 | genHeap := GenericHeap[val]{{v: 3}, {v: 2}, {v: 1}} 23 | heap.Init(&genHeap) 24 | 25 | if genHeap.Len() != 3 { 26 | t.Errorf("expected length to be 3, got %d", genHeap.Len()) 27 | } 28 | 29 | for _, i := range []int{1, 2, 3} { 30 | v := heap.Pop(&genHeap).(val) 31 | if v.v != i { 32 | t.Errorf("expected %d, got %d", i, v.v) 33 | } 34 | } 35 | } 36 | 37 | func TestGenericHeapPushPop(t *testing.T) { 38 | t.Parallel() 39 | genHeap := GenericHeap[val]{} // empty heap 40 | heap.Push(&genHeap, val{v: 3}) 41 | heap.Push(&genHeap, val{v: 2}) 42 | heap.Push(&genHeap, val{v: 1}) 43 | 44 | if genHeap.Len() != 3 { 45 | t.Errorf("expected length to be 3, got %d", genHeap.Len()) 46 | } 47 | 48 | for _, i := range []int{1, 2, 3} { 49 | v := heap.Pop(&genHeap).(val) 50 | if v.v != i { 51 | t.Errorf("expected %d, got %d", i, v.v) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/queue/timequeue_test.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "context" 5 | "math/rand" 6 | "slices" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestTimeQueue(t *testing.T) { 12 | t.Parallel() 13 | tqueue := NewTimeQueue[val]() 14 | 15 | for i := 0; i < 100; i++ { 16 | tqueue.Enqueue(time.Now().Add(time.Millisecond*time.Duration(i*10)), val{v: i}) 17 | } 18 | 19 | for i := 0; i < 100; i++ { 20 | v := tqueue.Dequeue(context.Background()) 21 | if v.v != i { 22 | t.Errorf("expected %d, got %d", i, v.v) 23 | } 24 | } 25 | } 26 | 27 | func TestFuzzTimeQueue(t *testing.T) { 28 | t.Parallel() 29 | 30 | // generate random values and enqueue them 31 | values := make([]val, 100) 32 | for i := 0; i < 100; i++ { 33 | values[i] = val{v: rand.Intn(1000) - 500} 34 | } 35 | 36 | tqueue := NewTimeQueue[val]() 37 | now := time.Now() 38 | for _, v := range values { 39 | tqueue.Enqueue(now.Add(time.Millisecond*time.Duration(v.v)), v) 40 | } 41 | 42 | slices.SortFunc(values, func(i, j val) int { 43 | if i.v > j.v { 44 | return 1 45 | } 46 | return -1 47 | }) 48 | 49 | // dequeue the values and check if they are in the correct order 50 | for i := 0; i < 100; i++ { 51 | v := tqueue.Dequeue(context.Background()) 52 | if v.v != values[i].v { 53 | t.Errorf("expected %d, got %d", values[i].v, v.v) 54 | } 55 | } 56 | } 57 | 58 | func TestTimeQueueEnqueueWhileWaiting(t *testing.T) { 59 | t.Parallel() 60 | 61 | tqueue := NewTimeQueue[val]() 62 | ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*500) 63 | defer cancel() 64 | 65 | go func() { 66 | time.Sleep(time.Millisecond * 50) 67 | tqueue.Enqueue(time.Now(), val{v: 1}) 68 | }() 69 | 70 | v := tqueue.Dequeue(ctx) 71 | if v.v != 1 { 72 | t.Errorf("expected 1, got %d", v.v) 73 | } 74 | } 75 | 76 | func TestTimeQueueDequeueTimeout(t *testing.T) { 77 | t.Parallel() 78 | 79 | tqueue := NewTimeQueue[val]() 80 | ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*50) 81 | defer cancel() 82 | 83 | v := tqueue.Dequeue(ctx) 84 | if v.v != 0 { 85 | t.Errorf("expected 0, got %d", v.v) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /internal/resticinstaller/downloadhelper.go: -------------------------------------------------------------------------------- 1 | package resticinstaller 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "compress/bzip2" 7 | "context" 8 | "crypto/sha256" 9 | "encoding/hex" 10 | "fmt" 11 | "io" 12 | "net/http" 13 | "os" 14 | "path/filepath" 15 | "strings" 16 | ) 17 | 18 | // getURL downloads the given url and returns the response body as a string. 19 | func getURL(url string) ([]byte, error) { 20 | resp, err := http.Get(url) 21 | if err != nil { 22 | return nil, fmt.Errorf("http GET %v: %w", url, err) 23 | } 24 | defer resp.Body.Close() 25 | 26 | var body bytes.Buffer 27 | _, err = io.Copy(&body, resp.Body) 28 | if err != nil { 29 | return nil, fmt.Errorf("copy response body to buffer: %w", err) 30 | } 31 | return body.Bytes(), nil 32 | } 33 | 34 | // downloadFile downloads a file from the given url and saves it to the given path. The sha256 checksum of the file is returned on success. 35 | func downloadFile(url string, downloadPath string) (string, error) { 36 | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) 37 | if err != nil { 38 | return "", err 39 | } 40 | req.Header.Set("Accept", "application/octet-stream") 41 | resp, err := http.DefaultClient.Do(req) 42 | if err != nil { 43 | return "", fmt.Errorf("http GET %v: %w", url, err) 44 | } 45 | defer resp.Body.Close() 46 | 47 | if resp.StatusCode != http.StatusOK { 48 | return "", fmt.Errorf("http GET %v: %v", url, resp.Status) 49 | } 50 | 51 | var dst *os.File 52 | if err := os.MkdirAll(filepath.Dir(downloadPath), 0755); err != nil { 53 | return "", fmt.Errorf("create directory %v: %w", filepath.Dir(downloadPath), err) 54 | } 55 | dst, err = os.Create(downloadPath) 56 | if err != nil { 57 | return "", fmt.Errorf("create file %v: %w", downloadPath, err) 58 | } 59 | defer dst.Close() 60 | 61 | shasum := sha256.New() 62 | reader := io.TeeReader(resp.Body, shasum) 63 | 64 | if strings.HasSuffix(url, ".bz2") { 65 | bz2Reader := bzip2.NewReader(reader) 66 | if _, err := io.Copy(dst, bz2Reader); err != nil { 67 | return "", fmt.Errorf("copy bz2 response body to file %v: %w", downloadPath, err) 68 | } 69 | } else if strings.HasSuffix(url, ".zip") { 70 | var bodyBytes []byte 71 | if bodyBytes, err = io.ReadAll(reader); err != nil { 72 | return "", fmt.Errorf("read response body: %w", err) 73 | } 74 | zipReader, err := zip.NewReader(bytes.NewReader(bodyBytes), int64(len(bodyBytes))) 75 | if err != nil { 76 | return "", fmt.Errorf("read zip archive: %w", err) 77 | } 78 | if len(zipReader.File) != 1 { 79 | return "", fmt.Errorf("expected zip archive to contain exactly one file, got %v", len(zipReader.File)) 80 | } 81 | f, err := zipReader.File[0].Open() 82 | if err != nil { 83 | return "", fmt.Errorf("open zip archive file %v: %w", zipReader.File[0].Name, err) 84 | } 85 | if _, err := io.Copy(dst, f); err != nil { 86 | return "", fmt.Errorf("copy zip archive file %v to file %v: %w", zipReader.File[0].Name, downloadPath, err) 87 | } 88 | f.Close() 89 | } else { 90 | if _, err := io.Copy(dst, reader); err != nil { 91 | return "", fmt.Errorf("copy response body to file %v: %w", downloadPath, err) 92 | } 93 | } 94 | hash := shasum.Sum(nil) 95 | 96 | return hex.EncodeToString(hash[:]), nil 97 | } 98 | -------------------------------------------------------------------------------- /internal/resticinstaller/urls.go: -------------------------------------------------------------------------------- 1 | package resticinstaller 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | func resticDownloadURL(version string) string { 9 | if runtime.GOOS == "windows" { 10 | // restic is only built for 386 and amd64 on Windows, default to amd64 for other platforms (e.g. arm64.) 11 | arch := "amd64" 12 | if runtime.GOARCH == "386" || runtime.GOARCH == "amd64" { 13 | arch = runtime.GOARCH 14 | } 15 | return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%v/restic_%v_windows_%v.zip", version, version, arch) 16 | } 17 | return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%v/restic_%v_%v_%v.bz2", version, version, runtime.GOOS, runtime.GOARCH) 18 | } 19 | 20 | func hashDownloadURL(version string) string { 21 | return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%v/SHA256SUMS", version) 22 | } 23 | 24 | func sigDownloadURL(version string) string { 25 | return fmt.Sprintf("https://github.com/restic/restic/releases/download/v%v/SHA256SUMS.asc", version) 26 | } 27 | -------------------------------------------------------------------------------- /internal/resticinstaller/version.go: -------------------------------------------------------------------------------- 1 | package resticinstaller 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "regexp" 9 | 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func getResticVersion(binary string) (string, error) { 14 | cmd := exec.Command(binary, "version") 15 | out, err := cmd.Output() 16 | // check if error is a binary not found error 17 | if err != nil { 18 | if errors.Is(err, exec.ErrNotFound) { 19 | return "", ErrResticNotFound 20 | } 21 | return "", fmt.Errorf("exec %v: %w", cmd.String(), err) 22 | } 23 | match := regexp.MustCompile(`restic\s+((\d+\.\d+\.\d+))`).FindSubmatch(out) 24 | if len(match) < 2 { 25 | return "", fmt.Errorf("could not find restic version in output: %s", out) 26 | } 27 | return string(match[1]), nil 28 | } 29 | 30 | func assertResticVersion(binary string, strict bool) error { 31 | if _, err := os.Stat(binary); err != nil { 32 | return fmt.Errorf("check if restic binary exists: %w", err) 33 | } 34 | 35 | if version, err := getResticVersion(binary); err != nil { 36 | return fmt.Errorf("determine restic version: %w", err) 37 | } else { 38 | cmp := compareSemVer(mustParseSemVer(version), requiredVersionSemver) 39 | if cmp < 0 { 40 | return fmt.Errorf("restic version %v is less than required version %v", version, RequiredResticVersion) 41 | } else if cmp > 0 && strict { 42 | return fmt.Errorf("restic version %v is newer than required version %v, it may not be supported by backrest", version, RequiredResticVersion) 43 | } else if cmp > 0 { 44 | zap.S().Warnf("restic version %v is newer than required version %v, it may not be supported by backrest", version, RequiredResticVersion) 45 | } 46 | } 47 | return nil 48 | } 49 | 50 | func parseSemVer(version string) ([3]int, error) { 51 | var major, minor, patch int 52 | _, err := fmt.Sscanf(version, "%d.%d.%d", &major, &minor, &patch) 53 | if err != nil { 54 | return [3]int{}, fmt.Errorf("invalid semantic version format: %w", err) 55 | } 56 | return [3]int{major, minor, patch}, nil 57 | } 58 | 59 | func mustParseSemVer(version string) [3]int { 60 | v, err := parseSemVer(version) 61 | if err != nil { 62 | panic(err) 63 | } 64 | return v 65 | } 66 | 67 | func compareSemVer(v1 [3]int, v2 [3]int) int { 68 | if v1[0] != v2[0] { 69 | return v1[0] - v2[0] 70 | } 71 | if v1[1] != v2[1] { 72 | return v1[1] - v2[1] 73 | } 74 | return v1[2] - v2[2] 75 | } 76 | -------------------------------------------------------------------------------- /internal/resticinstaller/version_test.go: -------------------------------------------------------------------------------- 1 | package resticinstaller 2 | 3 | import "testing" 4 | 5 | func TestParseSemVer(t *testing.T) { 6 | testCases := []struct { 7 | name string 8 | input string 9 | want [3]int 10 | wantErr bool 11 | }{ 12 | {"Valid version", "0.18.0", [3]int{0, 18, 0}, false}, 13 | {"Invalid version", "1.2", [3]int{}, true}, 14 | {"Empty string", "", [3]int{}, true}, 15 | {"Non-numeric version", "a.b.c", [3]int{}, true}, 16 | {"Version with extra parts", "1.2.3.4", [3]int{1, 2, 3}, false}, 17 | } 18 | 19 | for _, tc := range testCases { 20 | t.Run(tc.name, func(t *testing.T) { 21 | got, err := parseSemVer(tc.input) 22 | if (err != nil) != tc.wantErr { 23 | t.Errorf("parseSemVer(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr) 24 | return 25 | } 26 | if got != tc.want { 27 | t.Errorf("parseSemVer(%q) = %v, want %v", tc.input, got, tc.want) 28 | } 29 | }) 30 | } 31 | } 32 | 33 | func TestCompareSemVer(t *testing.T) { 34 | testCases := []struct { 35 | name string 36 | v1 [3]int 37 | v2 [3]int 38 | want int // 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2 39 | }{ 40 | {"Equal versions", [3]int{1, 2, 3}, [3]int{1, 2, 3}, 0}, 41 | {"v1 major greater", [3]int{2, 0, 0}, [3]int{1, 9, 9}, 1}, 42 | {"v1 major smaller", [3]int{1, 9, 9}, [3]int{2, 0, 0}, -1}, 43 | {"v1 minor greater", [3]int{1, 3, 0}, [3]int{1, 2, 9}, 1}, 44 | {"v1 minor smaller", [3]int{1, 2, 9}, [3]int{1, 3, 0}, -1}, 45 | {"v1 patch greater", [3]int{1, 2, 4}, [3]int{1, 2, 3}, 1}, 46 | {"v1 patch smaller", [3]int{1, 2, 3}, [3]int{1, 2, 4}, -1}, 47 | {"Zero versions equal", [3]int{0, 0, 0}, [3]int{0, 0, 0}, 0}, 48 | {"Mixed zero versions", [3]int{0, 1, 0}, [3]int{0, 0, 9}, 1}, 49 | } 50 | 51 | for _, tc := range testCases { 52 | t.Run(tc.name, func(t *testing.T) { 53 | got := compareSemVer(tc.v1, tc.v2) 54 | if got != tc.want { 55 | t.Errorf("compareSemVer(%v, %v) = %d, want %d", tc.v1, tc.v2, got, tc.want) 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/testutil/deadline.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | var defaultDeadlineMargin = 5 * time.Second 10 | 11 | func WithDeadlineFromTest(t *testing.T, ctx context.Context) (context.Context, context.CancelFunc) { 12 | if deadline, ok := t.Deadline(); ok { 13 | return context.WithDeadline(ctx, deadline.Add(-defaultDeadlineMargin)) 14 | } 15 | return ctx, func() {} 16 | } 17 | -------------------------------------------------------------------------------- /internal/testutil/logging.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | type testLogger struct { 12 | t *testing.T 13 | } 14 | 15 | func (l *testLogger) Write(p []byte) (n int, err error) { 16 | l.t.Log("global log: " + strings.Trim(string(p), "\n")) 17 | return len(p), nil 18 | } 19 | 20 | func InstallZapLogger(t *testing.T) { 21 | t.Helper() 22 | logger := zap.New(zapcore.NewCore( 23 | zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()), 24 | zapcore.AddSync(&testLogger{t: t}), 25 | zapcore.DebugLevel, 26 | )) 27 | zap.ReplaceGlobals(logger) 28 | } 29 | -------------------------------------------------------------------------------- /internal/testutil/operations.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "encoding/binary" 7 | "sync/atomic" 8 | 9 | v1 "github.com/garethgeorge/backrest/gen/go/v1" 10 | "github.com/garethgeorge/backrest/internal/cryptoutil" 11 | "google.golang.org/protobuf/proto" 12 | ) 13 | 14 | var nextRandomOperationTimeMillis atomic.Int64 15 | 16 | func OperationsWithDefaults(op *v1.Operation, ops []*v1.Operation) []*v1.Operation { 17 | var newOps []*v1.Operation 18 | for _, o := range ops { 19 | copy := proto.Clone(o).(*v1.Operation) 20 | proto.Merge(copy, op) 21 | newOps = append(newOps, copy) 22 | } 23 | 24 | return newOps 25 | } 26 | 27 | func RandomOperation() *v1.Operation { 28 | randomPlanID := "plan" + randomString(5) 29 | randomRepoID := "repo" + randomString(5) 30 | randomRepoGUID := cryptoutil.MustRandomID(cryptoutil.DefaultIDBits) 31 | randomInstanceID := "instance" + randomString(5) 32 | 33 | return &v1.Operation{ 34 | UnixTimeStartMs: nextRandomOperationTimeMillis.Add(1000), 35 | PlanId: randomPlanID, 36 | RepoId: randomRepoID, 37 | RepoGuid: randomRepoGUID, 38 | InstanceId: randomInstanceID, 39 | Op: &v1.Operation_OperationBackup{}, 40 | FlowId: randomInt(), 41 | OriginalId: randomInt(), 42 | OriginalFlowId: randomInt(), 43 | Modno: randomInt(), 44 | Status: v1.OperationStatus_STATUS_INPROGRESS, 45 | } 46 | } 47 | 48 | func randomString(length int) string { 49 | randomBytes := make([]byte, length) 50 | _, err := rand.Read(randomBytes) 51 | if err != nil { 52 | panic(err) 53 | } 54 | return base64.URLEncoding.EncodeToString(randomBytes) 55 | } 56 | 57 | func randomInt() int64 { 58 | randBytes := make([]byte, 8) 59 | _, err := rand.Read(randBytes) 60 | if err != nil { 61 | panic(err) 62 | } 63 | return int64(binary.LittleEndian.Uint64(randBytes) & 0x7FFFFFFFFFFFFFFF) 64 | } 65 | -------------------------------------------------------------------------------- /internal/testutil/try.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func tryHelper(t *testing.T, ctx context.Context, f func() error) error { 10 | ctx, cancel := WithDeadlineFromTest(t, ctx) 11 | defer cancel() 12 | 13 | var err error 14 | interval := 10 * time.Millisecond 15 | for { 16 | timer := time.NewTimer(interval) 17 | interval += 10 * time.Millisecond 18 | select { 19 | case <-ctx.Done(): 20 | timer.Stop() 21 | return err 22 | case <-timer.C: 23 | timer.Stop() 24 | err = f() 25 | if err == nil { 26 | return nil 27 | } 28 | } 29 | } 30 | } 31 | 32 | // try is a helper that spins until the condition becomes true OR the context is done. 33 | func Try(t *testing.T, ctx context.Context, f func() error) { 34 | t.Helper() 35 | if err := tryHelper(t, ctx, f); err != nil { 36 | t.Fatalf("timeout before OK: %v", err) 37 | } 38 | } 39 | 40 | func TryNonfatal(t *testing.T, ctx context.Context, f func() error) { 41 | t.Helper() 42 | if err := tryHelper(t, ctx, f); err != nil { 43 | t.Errorf("timeout before OK: %v", err) 44 | } 45 | } 46 | 47 | func Retry(t *testing.T, ctx context.Context, f func() error) error { 48 | t.Helper() 49 | return tryHelper(t, ctx, f) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/restic/error.go: -------------------------------------------------------------------------------- 1 | package restic 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/exec" 7 | ) 8 | 9 | const outputBufferLimit = 1000 10 | 11 | type CmdError struct { 12 | Command string 13 | Err error 14 | } 15 | 16 | func (e *CmdError) Error() string { 17 | m := fmt.Sprintf("command %q failed: %s", e.Command, e.Err.Error()) 18 | return m 19 | } 20 | 21 | func (e *CmdError) Unwrap() error { 22 | return e.Err 23 | } 24 | 25 | func (e *CmdError) Is(target error) bool { 26 | _, ok := target.(*CmdError) 27 | return ok 28 | } 29 | 30 | // newCmdError creates a new error indicating that running a command failed. 31 | func newCmdError(ctx context.Context, cmd *exec.Cmd, err error) *CmdError { 32 | shortCmd := cmd.String() 33 | if len(shortCmd) > 100 { 34 | shortCmd = shortCmd[:100] + "..." 35 | } 36 | 37 | cerr := &CmdError{ 38 | Command: shortCmd, 39 | Err: err, 40 | } 41 | return cerr 42 | } 43 | 44 | type ErrorWithOutput struct { 45 | Err error 46 | Output string 47 | } 48 | 49 | func (e *ErrorWithOutput) Error() string { 50 | return fmt.Sprintf("%v\nOutput:\n%s", e.Err, e.Output) 51 | } 52 | 53 | func (e *ErrorWithOutput) Unwrap() error { 54 | return e.Err 55 | } 56 | 57 | func (e *ErrorWithOutput) Is(target error) bool { 58 | _, ok := target.(*ErrorWithOutput) 59 | return ok 60 | } 61 | 62 | // newErrorWithOutput creates a new error with the given output. 63 | func newErrorWithOutput(err error, output string) error { 64 | if output == "" { 65 | return err 66 | } 67 | 68 | if len(output) > outputBufferLimit { 69 | output = output[:outputBufferLimit] + fmt.Sprintf("\n... %d bytes truncated ...\n", len(output)-outputBufferLimit) 70 | } 71 | 72 | return &ErrorWithOutput{ 73 | Err: err, 74 | Output: output, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/restic/logging.go: -------------------------------------------------------------------------------- 1 | package restic 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | var loggerKey = struct{}{} 9 | 10 | func ContextWithLogger(ctx context.Context, logger io.Writer) context.Context { 11 | return context.WithValue(ctx, loggerKey, logger) 12 | } 13 | 14 | func LoggerFromContext(ctx context.Context) io.Writer { 15 | writer, _ := ctx.Value(loggerKey).(io.Writer) 16 | return writer 17 | } 18 | -------------------------------------------------------------------------------- /pkg/restic/outputs_test.go: -------------------------------------------------------------------------------- 1 | package restic 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestReadBackupProgressEntries(t *testing.T) { 9 | t.Parallel() 10 | testInput := `{"message_type":"status","percent_done":0,"total_files":1,"total_bytes":15} 11 | {"message_type":"summary","files_new":0,"files_changed":0,"files_unmodified":166,"dirs_new":0,"dirs_changed":0,"dirs_unmodified":128,"data_blobs":0,"tree_blobs":0,"data_added":0,"total_files_processed":166,"total_bytes_processed":16754463,"total_duration":0.235433378,"snapshot_id":"d4558b360cc1b7966e416e010382ab8feb49d14da7832266832d69a43af10147"}` 12 | 13 | b := bytes.NewBuffer([]byte(testInput)) 14 | 15 | summary, err := processProgressOutput[*BackupProgressEntry](b, nil, func(event *BackupProgressEntry) { 16 | t.Logf("event: %v", event) 17 | }) 18 | if err != nil { 19 | t.Fatalf("failed to read backup events: %v", err) 20 | } 21 | if summary == nil { 22 | t.Fatalf("wanted summary, got: nil") 23 | } 24 | if summary.TotalFilesProcessed != 166 { 25 | t.Errorf("wanted 166 files processed, got: %d", summary.TotalFilesProcessed) 26 | } 27 | } 28 | 29 | func TestReadLs(t *testing.T) { 30 | testInput := `{"time":"2023-11-10T19:14:17.053824063-08:00","tree":"3e2918b261948e69602ee9504b8f475bcc7cdc4dcec0b3f34ecdb014287d07b2","paths":["/backrest"],"hostname":"pop-os","username":"dontpanic","uid":1000,"gid":1000,"id":"db155169d788e6e432e320aedbdff5a54cc439653093bb56944a67682528aa52","short_id":"db155169","struct_type":"snapshot"} 31 | {"name":".git","type":"dir","path":"/.git","uid":1000,"gid":1000,"mode":2147484157,"mtime":"2023-11-10T18:32:38.156599473-08:00","atime":"2023-11-10T18:32:38.156599473-08:00","ctime":"2023-11-10T18:32:38.156599473-08:00","struct_type":"node"} 32 | {"name":".gitignore","type":"file","path":"/.gitignore","uid":1000,"gid":1000,"size":22,"mode":436,"mtime":"2023-11-10T00:41:26.611346634-08:00","atime":"2023-11-10T00:41:26.611346634-08:00","ctime":"2023-11-10T00:41:26.611346634-08:00","struct_type":"node"} 33 | {"name":"README.md","type":"file","path":"/README.md","uid":1000,"gid":1000,"size":762,"mode":436,"mtime":"2023-11-10T00:59:06.842538768-08:00","atime":"2023-11-10T00:59:06.842538768-08:00","ctime":"2023-11-10T00:59:06.842538768-08:00","struct_type":"node"}` 34 | 35 | b := bytes.NewBuffer([]byte(testInput)) 36 | 37 | snapshot, entries, err := readLs(b) 38 | if err != nil { 39 | t.Fatalf("failed to read ls output: %v", err) 40 | } 41 | if snapshot == nil { 42 | t.Fatalf("wanted snapshot, got: nil") 43 | } 44 | if len(entries) != 3 { 45 | t.Errorf("wanted 3 entries, got: %d", len(entries)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /proto/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | clean: true 3 | plugins: 4 | - local: protoc-gen-go 5 | out: ../gen/go 6 | opt: 7 | - paths=source_relative 8 | - local: protoc-gen-go-grpc 9 | out: ../gen/go 10 | opt: 11 | - paths=source_relative 12 | - local: protoc-gen-connect-go 13 | out: ../gen/go 14 | opt: 15 | - paths=source_relative 16 | - local: protoc-gen-es 17 | out: ../webui/gen/ts 18 | opt: 19 | - target=ts 20 | -------------------------------------------------------------------------------- /proto/buf.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | breaking: 3 | use: 4 | - FILE 5 | lint: 6 | use: 7 | - DEFAULT 8 | -------------------------------------------------------------------------------- /proto/google/api/annotations.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package google.api; 18 | 19 | import "google/api/http.proto"; 20 | import "google/protobuf/descriptor.proto"; 21 | 22 | option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; 23 | option java_multiple_files = true; 24 | option java_outer_classname = "AnnotationsProto"; 25 | option java_package = "com.google.api"; 26 | option objc_class_prefix = "GAPI"; 27 | 28 | extend google.protobuf.MethodOptions { 29 | // See `HttpRule`. 30 | HttpRule http = 72295728; 31 | } 32 | -------------------------------------------------------------------------------- /proto/types/value.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package types; 4 | 5 | option go_package = "github.com/garethgeorge/backrest/gen/go/types"; 6 | 7 | message BoolValue { 8 | bool value = 1; 9 | } 10 | 11 | message StringValue { 12 | string value = 1; 13 | } 14 | 15 | message BytesValue { 16 | bytes value = 1; 17 | } 18 | 19 | message StringList { 20 | repeated string values = 1; 21 | } 22 | 23 | message Int64Value { 24 | int64 value = 1; 25 | } 26 | 27 | message Int64List { 28 | repeated int64 values = 1; 29 | } 30 | 31 | message Empty {} -------------------------------------------------------------------------------- /proto/update.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | buf generate 4 | -------------------------------------------------------------------------------- /proto/v1/authentication.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package v1; 4 | 5 | option go_package = "github.com/garethgeorge/backrest/gen/go/v1"; 6 | 7 | import "v1/config.proto"; 8 | import "types/value.proto"; 9 | import "google/protobuf/empty.proto"; 10 | import "google/api/annotations.proto"; 11 | 12 | service Authentication { 13 | rpc Login(LoginRequest) returns (LoginResponse) {} 14 | rpc HashPassword(types.StringValue) returns (types.StringValue) {} 15 | } 16 | 17 | message LoginRequest { 18 | string username = 1; 19 | string password = 2; 20 | } 21 | 22 | message LoginResponse { 23 | string token = 1; // JWT token 24 | } 25 | -------------------------------------------------------------------------------- /proto/v1/crypto.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package v1; 4 | 5 | option go_package = "github.com/garethgeorge/backrest/gen/go/v1"; 6 | 7 | message SignedMessage { 8 | string keyid = 1; // a unique identifier generated as the SHA256 of the public key used to sign the message. 9 | bytes payload = 2; // the payload 10 | bytes signature = 3; // the signature of the payload 11 | } 12 | 13 | message EncryptedMessage { 14 | bytes payload = 1; 15 | } 16 | 17 | message PublicKey { 18 | string keyid = 1 [json_name="keyid"]; // a unique identifier generated as the SHA256 of the public key. 19 | string ed25519 = 2 [json_name="ed25519pub"]; // base64 encoded public key 20 | } 21 | 22 | message PrivateKey { 23 | string keyid = 1 [json_name="keyid"]; // a unique identifier generated as the SHA256 of the public key. 24 | string ed25519 = 2 [json_name="ed25519priv"]; // base64 encoded private key 25 | } 26 | -------------------------------------------------------------------------------- /proto/v1/restic.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package v1; 4 | 5 | option go_package = "github.com/garethgeorge/backrest/gen/go/v1"; 6 | 7 | // ResticSnapshot represents a restic snapshot. 8 | message ResticSnapshot { 9 | string id = 1; 10 | int64 unix_time_ms = 2; 11 | string hostname = 3; 12 | string username = 4; 13 | string tree = 5; // tree hash 14 | string parent = 6; // parent snapshot's id 15 | repeated string paths = 7; 16 | repeated string tags = 8; 17 | SnapshotSummary summary = 9; // added in 0.17.0 restic outputs the summary in the snapshot 18 | } 19 | 20 | message SnapshotSummary { 21 | int64 files_new = 1; 22 | int64 files_changed = 2; 23 | int64 files_unmodified = 3; 24 | int64 dirs_new = 4; 25 | int64 dirs_changed = 5; 26 | int64 dirs_unmodified = 6; 27 | int64 data_blobs = 7; 28 | int64 tree_blobs = 8; 29 | int64 data_added = 9; 30 | int64 total_files_processed = 10; 31 | int64 total_bytes_processed = 11; 32 | double total_duration = 12; 33 | } 34 | 35 | // ResticSnapshotList represents a list of restic snapshots. 36 | message ResticSnapshotList { 37 | repeated ResticSnapshot snapshots = 1; 38 | } 39 | 40 | // BackupProgressEntriy represents a single entry in the backup progress stream. 41 | message BackupProgressEntry { 42 | oneof entry { 43 | BackupProgressStatusEntry status = 1; 44 | BackupProgressSummary summary = 2; 45 | } 46 | } 47 | 48 | // BackupProgressStatusEntry represents a single status entry in the backup progress stream. 49 | message BackupProgressStatusEntry { 50 | // See https://restic.readthedocs.io/en/stable/075_scripting.html#id1 51 | double percent_done = 1; // 0.0 - 1.0 52 | int64 total_files = 2; 53 | int64 total_bytes = 3; 54 | int64 files_done = 4; 55 | int64 bytes_done = 5; 56 | repeated string current_file = 6; 57 | } 58 | 59 | // BackupProgressSummary represents a the summary event emitted at the end of a backup stream. 60 | message BackupProgressSummary { 61 | // See https://restic.readthedocs.io/en/stable/075_scripting.html#summary 62 | int64 files_new = 1; 63 | int64 files_changed = 2; 64 | int64 files_unmodified = 3; 65 | int64 dirs_new = 4; 66 | int64 dirs_changed = 5; 67 | int64 dirs_unmodified = 6; 68 | int64 data_blobs = 7; 69 | int64 tree_blobs = 8; 70 | int64 data_added = 9; 71 | int64 total_files_processed = 10; 72 | int64 total_bytes_processed = 11; 73 | double total_duration = 12; 74 | string snapshot_id = 13; 75 | } 76 | 77 | message BackupProgressError { 78 | // See https://restic.readthedocs.io/en/stable/075_scripting.html#error 79 | string item = 1; 80 | string during = 2; 81 | string message = 3; 82 | } 83 | 84 | // RestoreProgressEvent represents a single entry in the restore progress stream. 85 | message RestoreProgressEntry { 86 | string message_type = 1; // "summary" or "status" 87 | double seconds_elapsed = 2; 88 | int64 total_bytes = 3; 89 | int64 bytes_restored = 4; 90 | int64 total_files = 5; 91 | int64 files_restored = 6; 92 | double percent_done = 7; // 0.0 - 1.0 93 | } 94 | 95 | message RepoStats { 96 | int64 total_size = 1; 97 | int64 total_uncompressed_size = 2; 98 | double compression_ratio = 3; 99 | int64 total_blob_count = 5; 100 | int64 snapshot_count = 6; 101 | } -------------------------------------------------------------------------------- /scripts/generate-installers.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | outdir=$(realpath $1) # output directory for the installer binaries 4 | srcdir=$(realpath $(dirname $0)/..) # source directory 5 | 6 | # for each supported windows architecture 7 | for arch in x86_64 arm64; do 8 | cd $(mktemp -d) 9 | unzip $srcdir/dist/backrest_Windows_${arch}.zip 10 | 11 | cp -rl $srcdir/build/windows/* . 12 | 13 | if [ "$arch" == "x86_64" ]; then 14 | docker run --rm -v $(pwd):/build binfalse/nsis install.nsi 15 | else 16 | docker run --rm -e TARGET_ARCH=arm64 -v $(pwd):/build binfalse/nsis install.nsi 17 | fi 18 | 19 | cp Backrest-setup.exe $outdir/Backrest-setup-${arch}.exe 20 | done 21 | -------------------------------------------------------------------------------- /scripts/latest-restic-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | curl -s https://api.github.com/repos/restic/restic/releases/latest \ 4 | | grep "https://api.github.com/repos/restic/restic/tarball/" \ 5 | | sed -E 's/.*v([0-9]+\.[0-9]+\.[0-9]+).*/\1/' # extract just the version number 6 | -------------------------------------------------------------------------------- /scripts/testing/ramdisk-mount.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # Check that the script must be sourced 4 | ( 5 | [[ -n $ZSH_VERSION && $ZSH_EVAL_CONTEXT =~ :file$ ]] || 6 | [[ -n $KSH_VERSION && "$(cd -- "$(dirname -- "$0")" && pwd -P)/$(basename -- "$0")" != "$(cd -- "$(dirname -- "${.sh.file}")" && pwd -P)/$(basename -- "${.sh.file}")" ]] || 7 | [[ -n $BASH_VERSION ]] && (return 0 2>/dev/null) 8 | ) && sourced=1 || sourced=0 9 | 10 | if [ $sourced -eq 0 ]; then 11 | echo "This script should be sourced instead of executed." 12 | echo "Usage: . $0" 13 | exit 1 14 | fi 15 | 16 | # Check if MacOS 17 | if [ "$(uname)" = "Darwin" ]; then 18 | if [ -d "/Volumes/RAM_Disk_1GB" ]; then 19 | echo "RAM disk /Volumes/RAM_Disk_1GB already exists." 20 | else 21 | sudo diskutil erasevolume HFS+ RAM_Disk_1GB $(hdiutil attach -nomount ram://2048000) 22 | fi 23 | export TMPDIR="/Volumes/RAM_Disk_1GB" 24 | export RESTIC_CACHE_DIR="$TMPDIR/.cache" 25 | echo "Created 512MB RAM disk at /Volumes/RAM_Disk_1GB" 26 | echo "TMPDIR=$TMPDIR" 27 | echo "RESTIC_CACHE_DIR=$RESTIC_CACHE_DIR" 28 | elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then 29 | # Create ramdisk 30 | sudo mkdir -p /mnt/ramdisk 31 | sudo mount -t tmpfs -o size=1024M tmpfs /mnt/ramdisk 32 | export TMPDIR="/mnt/ramdisk" 33 | export RESTIC_CACHE_DIR="$TMPDIR/.cache" 34 | fi 35 | -------------------------------------------------------------------------------- /scripts/testing/ramdisk-unmount.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # Check that the script must be sourced 4 | ( 5 | [[ -n $ZSH_VERSION && $ZSH_EVAL_CONTEXT =~ :file$ ]] || 6 | [[ -n $KSH_VERSION && "$(cd -- "$(dirname -- "$0")" && pwd -P)/$(basename -- "$0")" != "$(cd -- "$(dirname -- "${.sh.file}")" && pwd -P)/$(basename -- "${.sh.file}")" ]] || 7 | [[ -n $BASH_VERSION ]] && (return 0 2>/dev/null) 8 | ) && sourced=1 || sourced=0 9 | 10 | if [ $sourced -eq 0 ]; then 11 | echo "This script should be sourced instead of executed." 12 | echo "Usage: . $0" 13 | exit 1 14 | fi 15 | 16 | # Check if MacOS 17 | if [ "$(uname)" = "Darwin" ]; then 18 | sudo diskutil unmount /Volumes/RAM_Disk_1GB 19 | hdiutil detach /Volumes/RAM_Disk_1GB 20 | elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then 21 | sudo umount /mnt/ramdisk 22 | fi 23 | 24 | unset TMPDIR 25 | unset XDG_CACHE_HOME 26 | -------------------------------------------------------------------------------- /scripts/testing/run-fresh.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | BASEDIR=$(dirname "$0") 4 | TEMPDIR=$(mktemp -d) 5 | 6 | function cleanup { 7 | echo "Removing temp dir: $TEMPDIR" 8 | rm -rf $TEMPDIR 9 | } 10 | 11 | trap cleanup EXIT 12 | 13 | echo "Temp dir: $TEMPDIR" 14 | 15 | go run $BASEDIR/../../cmd/backrest --config-file=$TEMPDIR/config.json --data-dir=$TEMPDIR/data 16 | -------------------------------------------------------------------------------- /scripts/testing/run-in-dir.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | BASEDIR=$(dirname "$0") 4 | RUNDIR=$1 5 | 6 | if [ -z "$RUNDIR" ]; then 7 | echo "Usage: $0 " 8 | exit 1 9 | fi 10 | 11 | go run $BASEDIR/../../cmd/backrest --config-file=$RUNDIR/config.json --data-dir=$RUNDIR 12 | 13 | -------------------------------------------------------------------------------- /scripts/update-restic-version.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | latest_restic_version=$(./scripts/latest-restic-version.sh) 4 | 5 | if [ -z "$latest_restic_version" ]; then 6 | echo "Failed to get latest restic version" 7 | exit 1 8 | fi 9 | 10 | echo "Latest restic version: $latest_restic_version" 11 | 12 | sed -i -E "s/^.*RequiredResticVersion\ =\ .*$/ RequiredResticVersion\ =\ \"$latest_restic_version\"/g" internal/resticinstaller/resticinstaller.go -------------------------------------------------------------------------------- /test/helpers/installrestic.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/garethgeorge/backrest/internal/resticinstaller" 7 | ) 8 | 9 | func ResticBinary(t testing.TB) string { 10 | binPath, err := resticinstaller.FindOrInstallResticBinary() 11 | if err != nil { 12 | t.Fatalf("find restic binary: %v", err) 13 | } 14 | return binPath 15 | } 16 | -------------------------------------------------------------------------------- /test/helpers/testdata.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "testing" 8 | 9 | acl "github.com/hectane/go-acl" 10 | ) 11 | 12 | func CreateTestData(t *testing.T) string { 13 | t.Helper() 14 | dir := t.TempDir() 15 | 16 | for i := 0; i < 100; i++ { 17 | err := os.WriteFile(path.Join(dir, fmt.Sprintf("file%2d", i)), []byte(fmt.Sprintf("test data %d", i)), 0644) 18 | if err != nil { 19 | t.Fatalf("failed to create test data: %v", err) 20 | } 21 | } 22 | return dir 23 | } 24 | 25 | func CreateUnreadable(t *testing.T, path string) { 26 | t.Helper() 27 | 28 | // Create a file that can be written but can't be read by the current user 29 | err := os.WriteFile(path, []byte("test data"), 0200) 30 | if err != nil { 31 | t.Fatalf("failed to create unreadable file: %v", err) 32 | } 33 | 34 | if err := acl.Chmod(path, 0200); err != nil { 35 | t.Fatalf("failed to set file ACL: %v", err) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /uninstall.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | cd "$(dirname "$0")" # cd to the directory of this script 4 | 5 | uninstall_unix() { 6 | echo "Uninstalling backrest from /usr/local/bin/backrest" 7 | sudo rm -f /usr/local/bin/backrest 8 | } 9 | 10 | remove_systemd_service() { 11 | if [ ! -d /etc/systemd/system ]; then 12 | echo "Systemd not found. This script is only for systemd based systems." 13 | exit 1 14 | fi 15 | 16 | echo "Removing systemd service at /etc/systemd/system/backrest.service" 17 | sudo systemctl stop backrest 18 | sudo systemctl disable backrest 19 | sudo rm -f /etc/systemd/system/backrest.service 20 | 21 | echo "Reloading systemd daemon" 22 | sudo systemctl daemon-reload 23 | } 24 | 25 | remove_launchd_plist() { 26 | echo "Removing launchd plist at /Library/LaunchAgents/com.backrest.plist" 27 | 28 | launchctl unload /Library/LaunchAgents/com.backrest.plist || true 29 | sudo rm /Library/LaunchAgents/com.backrest.plist 30 | } 31 | 32 | OS=$(uname -s) 33 | if [ "$OS" = "Darwin" ]; then 34 | echo "Uninstalling on Darwin" 35 | uninstall_unix 36 | remove_launchd_plist 37 | 38 | echo "Done -- run 'launchctl list | grep backrest' to check the service installation." 39 | elif [ "$OS" = "Linux" ]; then 40 | echo "Unnstalling on Linux" 41 | uninstall_unix 42 | remove_systemd_service 43 | 44 | echo "Done -- run 'systemctl status backrest' to check the status of the service." 45 | else 46 | echo "Unknown OS: $OS. This script only supports Darwin and Linux." 47 | exit 1 48 | fi 49 | -------------------------------------------------------------------------------- /webui/.gitignore: -------------------------------------------------------------------------------- 1 | .parcel-cache 2 | node_modules 3 | dist 4 | dist-windows 5 | -------------------------------------------------------------------------------- /webui/.proxyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "/v1.Backrest": { 3 | "target": "http://localhost:9898", 4 | "secure": false 5 | }, 6 | "/v1.Authentication": { 7 | "target": "http://localhost:9898", 8 | "secure": false 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /webui/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/garethgeorge/backrest/9a4e0c5c74c5cdfa3ac4d78d910fcf061f560e4c/webui/assets/favicon.png -------------------------------------------------------------------------------- /webui/gen/ts/google/api/annotations_pb.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Google LLC 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // @generated by protoc-gen-es v2.2.2 with parameter "target=ts" 16 | // @generated from file google/api/annotations.proto (package google.api, syntax proto3) 17 | /* eslint-disable */ 18 | 19 | import type { GenExtension, GenFile } from "@bufbuild/protobuf/codegenv1"; 20 | import { extDesc, fileDesc } from "@bufbuild/protobuf/codegenv1"; 21 | import type { HttpRule } from "./http_pb"; 22 | import { file_google_api_http } from "./http_pb"; 23 | import type { MethodOptions } from "@bufbuild/protobuf/wkt"; 24 | import { file_google_protobuf_descriptor } from "@bufbuild/protobuf/wkt"; 25 | 26 | /** 27 | * Describes the file google/api/annotations.proto. 28 | */ 29 | export const file_google_api_annotations: GenFile = /*@__PURE__*/ 30 | fileDesc("Chxnb29nbGUvYXBpL2Fubm90YXRpb25zLnByb3RvEgpnb29nbGUuYXBpOksKBGh0dHASHi5nb29nbGUucHJvdG9idWYuTWV0aG9kT3B0aW9ucxiwyrwiIAEoCzIULmdvb2dsZS5hcGkuSHR0cFJ1bGVSBGh0dHBCbgoOY29tLmdvb2dsZS5hcGlCEEFubm90YXRpb25zUHJvdG9QAVpBZ29vZ2xlLmdvbGFuZy5vcmcvZ2VucHJvdG8vZ29vZ2xlYXBpcy9hcGkvYW5ub3RhdGlvbnM7YW5ub3RhdGlvbnOiAgRHQVBJYgZwcm90bzM", [file_google_api_http, file_google_protobuf_descriptor]); 31 | 32 | /** 33 | * See `HttpRule`. 34 | * 35 | * @generated from extension: google.api.HttpRule http = 72295728; 36 | */ 37 | export const http: GenExtension = /*@__PURE__*/ 38 | extDesc(file_google_api_annotations, 0); 39 | 40 | -------------------------------------------------------------------------------- /webui/gen/ts/v1/authentication_pb.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es v2.2.2 with parameter "target=ts" 2 | // @generated from file v1/authentication.proto (package v1, syntax proto3) 3 | /* eslint-disable */ 4 | 5 | import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv1"; 6 | import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv1"; 7 | import { file_v1_config } from "./config_pb"; 8 | import type { StringValueSchema } from "../types/value_pb"; 9 | import { file_types_value } from "../types/value_pb"; 10 | import { file_google_protobuf_empty } from "@bufbuild/protobuf/wkt"; 11 | import { file_google_api_annotations } from "../google/api/annotations_pb"; 12 | import type { Message } from "@bufbuild/protobuf"; 13 | 14 | /** 15 | * Describes the file v1/authentication.proto. 16 | */ 17 | export const file_v1_authentication: GenFile = /*@__PURE__*/ 18 | fileDesc("Chd2MS9hdXRoZW50aWNhdGlvbi5wcm90bxICdjEiMgoMTG9naW5SZXF1ZXN0EhAKCHVzZXJuYW1lGAEgASgJEhAKCHBhc3N3b3JkGAIgASgJIh4KDUxvZ2luUmVzcG9uc2USDQoFdG9rZW4YASABKAkyegoOQXV0aGVudGljYXRpb24SLgoFTG9naW4SEC52MS5Mb2dpblJlcXVlc3QaES52MS5Mb2dpblJlc3BvbnNlIgASOAoMSGFzaFBhc3N3b3JkEhIudHlwZXMuU3RyaW5nVmFsdWUaEi50eXBlcy5TdHJpbmdWYWx1ZSIAQixaKmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MWIGcHJvdG8z", [file_v1_config, file_types_value, file_google_protobuf_empty, file_google_api_annotations]); 19 | 20 | /** 21 | * @generated from message v1.LoginRequest 22 | */ 23 | export type LoginRequest = Message<"v1.LoginRequest"> & { 24 | /** 25 | * @generated from field: string username = 1; 26 | */ 27 | username: string; 28 | 29 | /** 30 | * @generated from field: string password = 2; 31 | */ 32 | password: string; 33 | }; 34 | 35 | /** 36 | * Describes the message v1.LoginRequest. 37 | * Use `create(LoginRequestSchema)` to create a new message. 38 | */ 39 | export const LoginRequestSchema: GenMessage = /*@__PURE__*/ 40 | messageDesc(file_v1_authentication, 0); 41 | 42 | /** 43 | * @generated from message v1.LoginResponse 44 | */ 45 | export type LoginResponse = Message<"v1.LoginResponse"> & { 46 | /** 47 | * JWT token 48 | * 49 | * @generated from field: string token = 1; 50 | */ 51 | token: string; 52 | }; 53 | 54 | /** 55 | * Describes the message v1.LoginResponse. 56 | * Use `create(LoginResponseSchema)` to create a new message. 57 | */ 58 | export const LoginResponseSchema: GenMessage = /*@__PURE__*/ 59 | messageDesc(file_v1_authentication, 1); 60 | 61 | /** 62 | * @generated from service v1.Authentication 63 | */ 64 | export const Authentication: GenService<{ 65 | /** 66 | * @generated from rpc v1.Authentication.Login 67 | */ 68 | login: { 69 | methodKind: "unary"; 70 | input: typeof LoginRequestSchema; 71 | output: typeof LoginResponseSchema; 72 | }, 73 | /** 74 | * @generated from rpc v1.Authentication.HashPassword 75 | */ 76 | hashPassword: { 77 | methodKind: "unary"; 78 | input: typeof StringValueSchema; 79 | output: typeof StringValueSchema; 80 | }, 81 | }> = /*@__PURE__*/ 82 | serviceDesc(file_v1_authentication, 0); 83 | 84 | -------------------------------------------------------------------------------- /webui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backrest", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "parcel serve src/index.html", 7 | "clean": "rimraf dist", 8 | "clean-windows": "rimraf dist-windows", 9 | "build": "cross-env UI_OS=unix parcel build src/index.html --public-url ./", 10 | "build-windows": "cross-env UI_OS=windows parcel build src/index.html --dist-dir dist-windows --public-url ./", 11 | "check": "tsc --noEmit" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@ant-design/icons": "^5.5.1", 17 | "@bufbuild/protobuf": "^2.2.2", 18 | "@connectrpc/connect": "^2.0.0", 19 | "@connectrpc/connect-web": "^2.0.0", 20 | "@parcel/transformer-sass": "^2.13.2", 21 | "@types/lodash": "^4.17.13", 22 | "@types/node": "^20.17.8", 23 | "@types/react": "^18.3.12", 24 | "@types/react-dom": "^18.3.1", 25 | "@types/react-virtualized": "^9.22.0", 26 | "antd": "^5.22.2", 27 | "buffer": "^6.0.3", 28 | "cross-env": "^7.0.3", 29 | "events": "^3.3.0", 30 | "lodash": "^4.17.21", 31 | "parcel": "^2.13.2", 32 | "process": "^0.11.10", 33 | "react": "^18.3.1", 34 | "react-dom": "^18.3.1", 35 | "react-js-cron": "^5.2.0", 36 | "react-router": "^6.28.0", 37 | "react-router-dom": "^6.28.0", 38 | "react-virtualized": "^9.22.5", 39 | "recharts": "^2.13.3", 40 | "rimraf": "^5.0.10", 41 | "svgo": "^3.3.2", 42 | "typescript": "^5.7.2" 43 | }, 44 | "@parcel/resolver-default": { 45 | "packageExports": true 46 | }, 47 | "devDependencies": { 48 | "rc-collapse": "^3.9.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /webui/src/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /webui/src/api.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { createConnectTransport } from "@connectrpc/connect-web"; 3 | import { createClient } from "@connectrpc/connect"; 4 | import { Authentication } from "../gen/ts/v1/authentication_pb"; 5 | import { Backrest } from "../gen/ts/v1/service_pb"; 6 | 7 | const tokenKey = "backrest-ui-authToken"; 8 | 9 | export const setAuthToken = (token: string) => { 10 | localStorage.setItem(tokenKey, token); 11 | }; 12 | 13 | const fetch = ( 14 | input: RequestInfo | URL, 15 | init?: RequestInit, 16 | ): Promise => { 17 | const headers = new Headers(init?.headers); 18 | let token = localStorage.getItem(tokenKey); 19 | if (token && token !== "") { 20 | headers.set("Authorization", "Bearer " + token); 21 | } 22 | init = { ...init, headers }; 23 | return window.fetch(input, init); 24 | }; 25 | 26 | const transport = createConnectTransport({ 27 | baseUrl: "./", 28 | useBinaryFormat: true, 29 | fetch: fetch as typeof globalThis.fetch, 30 | }); 31 | 32 | export const authenticationService = createClient( 33 | Authentication, 34 | transport, 35 | ); 36 | export const backrestService = createClient(Backrest, transport); 37 | -------------------------------------------------------------------------------- /webui/src/components/ActivityBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | subscribeToOperations, 4 | unsubscribeFromOperations, 5 | } from "../state/oplog"; 6 | import { formatDuration } from "../lib/formatting"; 7 | import { 8 | Operation, 9 | OperationEvent, 10 | OperationEventType, 11 | OperationStatus, 12 | } from "../../gen/ts/v1/operations_pb"; 13 | import { 14 | displayTypeToString, 15 | getTypeForDisplay, 16 | } from "../state/flowdisplayaggregator"; 17 | 18 | export const ActivityBar = () => { 19 | const [activeOperations, setActiveOperations] = useState([]); 20 | const setRefresh = useState(0)[1]; 21 | 22 | useEffect(() => { 23 | const callback = (event?: OperationEvent, err?: Error) => { 24 | if (!event || !event.event) { 25 | return; 26 | } 27 | 28 | switch (event.event.case) { 29 | case "createdOperations": 30 | case "updatedOperations": 31 | const ops = event.event.value.operations; 32 | setActiveOperations((oldOps) => { 33 | oldOps = oldOps.filter( 34 | (op) => !ops.find((newOp) => newOp.id === op.id) 35 | ); 36 | const newOps = ops.filter( 37 | (newOp) => newOp.status === OperationStatus.STATUS_INPROGRESS 38 | ); 39 | return [...oldOps, ...newOps]; 40 | }); 41 | break; 42 | case "deletedOperations": 43 | const opIDs = event.event.value.values; 44 | setActiveOperations((ops) => 45 | ops.filter((op) => !opIDs.includes(op.id)) 46 | ); 47 | break; 48 | } 49 | }; 50 | 51 | subscribeToOperations(callback); 52 | 53 | setInterval(() => { 54 | setRefresh((r) => r + 1); 55 | }, 500); 56 | 57 | return () => { 58 | unsubscribeFromOperations(callback); 59 | }; 60 | }, []); 61 | 62 | return ( 63 | 64 | {activeOperations.map((op, idx) => { 65 | const displayName = displayTypeToString(getTypeForDisplay(op)); 66 | 67 | return ( 68 | 69 | {displayName} in progress for plan {op.planId} to {op.repoId} for{" "} 70 | {formatDuration(Date.now() - Number(op.unixTimeStartMs))} 71 | 72 | ); 73 | })} 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /webui/src/components/Alerts.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | 3 | import { message } from "antd"; 4 | import { MessageInstance } from "antd/es/message/interface"; 5 | 6 | const MessageContext = React.createContext(null); 7 | 8 | export const AlertContextProvider = ({ 9 | children, 10 | }: { 11 | children: React.ReactNode; 12 | }) => { 13 | const [messageApi, contextHolder] = message.useMessage(); 14 | 15 | return ( 16 | <> 17 | {contextHolder} 18 | 19 | {children} 20 | 21 | 22 | ); 23 | }; 24 | 25 | export const useAlertApi = () => { 26 | return useContext(MessageContext); 27 | }; 28 | 29 | export const formatErrorAlert = (error: any, prefix?: string) => { 30 | prefix = prefix ? prefix.trim() + " " : "Error: "; 31 | const contents = (error.message || "" + error) as string; 32 | if (contents.includes("\n")) { 33 | return ( 34 | <> 35 | {prefix} 36 |
37 |           {contents}
38 |         
39 | 40 | ); 41 | } 42 | return `${prefix}: ${contents}`; 43 | }; 44 | -------------------------------------------------------------------------------- /webui/src/components/ConfigProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from "react"; 2 | import { Config, Repo } from "../../gen/ts/v1/config_pb"; 3 | 4 | type ConfigCtx = [Config | null, (config: Config) => void]; 5 | 6 | const ConfigContext = React.createContext([null, () => {}]); 7 | 8 | export const ConfigContextProvider = ({ 9 | children, 10 | }: { 11 | children: React.ReactNode; 12 | }) => { 13 | const [config, setConfig] = useState(null); 14 | return ( 15 | <> 16 | 17 | {children} 18 | 19 | 20 | ); 21 | }; 22 | 23 | export const useConfig = (): ConfigCtx => { 24 | const context = useContext(ConfigContext); 25 | return context; 26 | }; 27 | -------------------------------------------------------------------------------- /webui/src/components/LogView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | LogDataRequest, 4 | LogDataRequestSchema, 5 | } from "../../gen/ts/v1/service_pb"; 6 | import { backrestService } from "../api"; 7 | import { Button } from "antd"; 8 | import { create } from "@bufbuild/protobuf"; 9 | 10 | // TODO: refactor this to use the provider pattern 11 | export const LogView = ({ logref }: { logref: string }) => { 12 | const [lines, setLines] = useState([""]); 13 | const [limit, setLimit] = useState(100); 14 | 15 | useEffect(() => { 16 | if (!logref) { 17 | return; 18 | } 19 | 20 | const controller = new AbortController(); 21 | 22 | (async () => { 23 | try { 24 | for await (const log of backrestService.getLogs( 25 | create(LogDataRequestSchema, { 26 | ref: logref, 27 | }), 28 | { signal: controller.signal } 29 | )) { 30 | const text = new TextDecoder("utf-8").decode(log.value); 31 | const lines = text.split("\n"); 32 | setLines((prev) => { 33 | const copy = [...prev]; 34 | copy[copy.length - 1] += lines[0]; 35 | copy.push(...lines.slice(1)); 36 | return copy; 37 | }); 38 | } 39 | } catch (e) { 40 | // setLines((prev) => [...prev, `Fetch log error: ${e}`]); 41 | } 42 | })(); 43 | 44 | return () => { 45 | setLines([]); 46 | controller.abort(); 47 | }; 48 | }, [logref]); 49 | 50 | let displayLines = lines; 51 | if (lines.length > limit) { 52 | displayLines = lines.slice(0, limit); 53 | } 54 | 55 | return ( 56 |
62 | {displayLines.map((line, i) => ( 63 |
67 |           {line}
68 |         
69 | ))} 70 | {lines.length > limit ? ( 71 | <> 72 | 80 | 81 | ) : null} 82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /webui/src/components/ModalManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { createContext } from "react"; 3 | 4 | const ModalContext = createContext<{ 5 | model: React.ReactNode | null; 6 | setModel: (model: React.ReactNode | null) => void; 7 | }>({ 8 | model: null, 9 | setModel: () => { 10 | throw new Error("add a ModelContextProvider to your hierarchy"); 11 | }, 12 | }); 13 | 14 | export const ModalContextProvider = ({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) => { 19 | const [modal, setModals] = React.useState([]); 20 | 21 | return ( 22 | 28 | {modal} 29 | {children} 30 | 31 | ); 32 | }; 33 | 34 | export const useShowModal = () => { 35 | const context = useContext(ModalContext); 36 | return context.setModel; 37 | }; 38 | -------------------------------------------------------------------------------- /webui/src/components/OperationIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DisplayType, colorForStatus } from "../state/flowdisplayaggregator"; 3 | import { 4 | CodeOutlined, 5 | DeleteOutlined, 6 | DownloadOutlined, 7 | FileSearchOutlined, 8 | InfoCircleOutlined, 9 | PaperClipOutlined, 10 | RobotOutlined, 11 | SaveOutlined, 12 | } from "@ant-design/icons"; 13 | import { OperationStatus } from "../../gen/ts/v1/operations_pb"; 14 | 15 | export const OperationIcon = ({ 16 | type, 17 | status, 18 | }: { 19 | type: DisplayType; 20 | status: OperationStatus; 21 | }) => { 22 | const color = colorForStatus(status); 23 | 24 | let avatar: React.ReactNode; 25 | switch (type) { 26 | case DisplayType.BACKUP: 27 | avatar = ; 28 | break; 29 | case DisplayType.FORGET: 30 | avatar = ; 31 | break; 32 | case DisplayType.SNAPSHOT: 33 | avatar = ; 34 | break; 35 | case DisplayType.RESTORE: 36 | avatar = ; 37 | break; 38 | case DisplayType.PRUNE: 39 | avatar = ; 40 | break; 41 | case DisplayType.CHECK: 42 | avatar = ; 43 | case DisplayType.RUNHOOK: 44 | avatar = ; 45 | break; 46 | case DisplayType.STATS: 47 | avatar = ; 48 | break; 49 | case DisplayType.RUNCOMMAND: 50 | avatar = ; 51 | break; 52 | } 53 | 54 | return avatar; 55 | }; 56 | -------------------------------------------------------------------------------- /webui/src/components/SpinButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, ButtonProps } from "antd"; 3 | import { useState } from "react"; 4 | 5 | export const SpinButton: React.FC< 6 | ButtonProps & { 7 | onClickAsync: () => Promise; 8 | } 9 | > = ({ onClickAsync, ...props }) => { 10 | const [loading, setLoading] = useState(false); 11 | 12 | const onClick = async () => { 13 | if (loading) { 14 | return; 15 | } 16 | try { 17 | setLoading(true); 18 | await onClickAsync(); 19 | } finally { 20 | setLoading(false); 21 | } 22 | }; 23 | 24 | return 88 | 89 | 90 | 91 | 92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /webui/src/views/MainContentArea.tsx: -------------------------------------------------------------------------------- 1 | import { Breadcrumb, Layout, Spin, theme } from "antd"; 2 | import { Content } from "antd/es/layout/layout"; 3 | import React from "react"; 4 | 5 | interface Breadcrumb { 6 | title: string; 7 | onClick?: () => void; 8 | } 9 | 10 | export const MainContentAreaTemplate = ({ 11 | breadcrumbs, 12 | children, 13 | }: { 14 | breadcrumbs: Breadcrumb[]; 15 | children: React.ReactNode; 16 | }) => { 17 | const { 18 | token: { colorBgContainer }, 19 | } = theme.useToken(); 20 | 21 | return ( 22 | 23 | 27 | 35 | {children} 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /webui/src/views/RunCommandModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input, Modal, Space } from "antd"; 2 | import React from "react"; 3 | import { useShowModal } from "../components/ModalManager"; 4 | import { backrestService } from "../api"; 5 | import { SpinButton } from "../components/SpinButton"; 6 | import { ConnectError } from "@connectrpc/connect"; 7 | import { useAlertApi } from "../components/Alerts"; 8 | import { 9 | GetOperationsRequest, 10 | GetOperationsRequestSchema, 11 | RunCommandRequest, 12 | RunCommandRequestSchema, 13 | } from "../../gen/ts/v1/service_pb"; 14 | import { Repo } from "../../gen/ts/v1/config_pb"; 15 | import { OperationListView } from "../components/OperationListView"; 16 | import { create } from "@bufbuild/protobuf"; 17 | import { useConfig } from "../components/ConfigProvider"; 18 | 19 | interface Invocation { 20 | command: string; 21 | output: string; 22 | error: string; 23 | } 24 | 25 | export const RunCommandModal = ({ repo }: { repo: Repo }) => { 26 | const [config, _] = useConfig(); 27 | const showModal = useShowModal(); 28 | const alertApi = useAlertApi()!; 29 | const [command, setCommand] = React.useState(""); 30 | const [running, setRunning] = React.useState(false); 31 | 32 | const handleCancel = () => { 33 | showModal(null); 34 | }; 35 | 36 | const doExecute = async () => { 37 | if (!command) return; 38 | setRunning(true); 39 | 40 | const toRun = command.trim(); 41 | setCommand(""); 42 | 43 | try { 44 | const opID = await backrestService.runCommand( 45 | create(RunCommandRequestSchema, { 46 | repoId: repo.id!, 47 | command: toRun, 48 | }) 49 | ); 50 | } catch (e: any) { 51 | alertApi.error("Command failed: " + e.message); 52 | } finally { 53 | setRunning(false); 54 | } 55 | }; 56 | 57 | return ( 58 | 65 | 66 | setCommand(e.target.value)} 70 | onKeyUp={(e) => { 71 | if (e.key === "Enter") { 72 | doExecute(); 73 | } 74 | }} 75 | /> 76 | 77 | Execute 78 | 79 | 80 | {running && command ? ( 81 | 82 | Warning: another command is already running. Wait for it to finish 83 | before running another operation that requires the repo lock. 84 | 85 | ) : null} 86 | op.op.case === "operationRunCommand"} 95 | /> 96 | 97 | ); 98 | }; 99 | -------------------------------------------------------------------------------- /webui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "target": "es2016", 5 | "module": "commonjs", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true 10 | }, 11 | "include": [ 12 | "src/**/*" 13 | ], 14 | } -------------------------------------------------------------------------------- /webui/webui.go: -------------------------------------------------------------------------------- 1 | package webui 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/vearutop/statigz" 7 | ) 8 | 9 | func Handler() http.Handler { 10 | return statigz.FileServer(content, statigz.FSPrefix(contentPrefix)) 11 | } 12 | -------------------------------------------------------------------------------- /webui/webui_test.go: -------------------------------------------------------------------------------- 1 | package webui 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "runtime" 7 | "testing" 8 | ) 9 | 10 | func TestEmbedNotEmpty(t *testing.T) { 11 | files, err := content.ReadDir(contentPrefix) 12 | if err != nil { 13 | t.Fatalf("expected embedded files for WebUI, got error: %v", err) 14 | } 15 | 16 | if len(files) == 0 { 17 | t.Fatalf("expected >0 embedded files for WebUI, got %d", len(files)) 18 | } 19 | } 20 | 21 | func TestServeIndexNoGzip(t *testing.T) { 22 | handler := Handler() 23 | 24 | req, err := http.NewRequest("GET", "/", nil) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | 29 | req.Header.Set("Accept-Encoding", "") 30 | 31 | rr := httptest.NewRecorder() 32 | handler.ServeHTTP(rr, req) 33 | 34 | if status := rr.Code; status != http.StatusOK { 35 | t.Errorf("handler returned wrong status code: got %v want %v", 36 | status, http.StatusOK) 37 | } 38 | 39 | // Windows doesn't have the gzip binary, so we skip compression during the 40 | // go:generate step on Windows 41 | if runtime.GOOS != "windows" && rr.Header().Get("Content-Encoding") != "" { 42 | t.Errorf("handler returned wrong content encoding: got %v want %v", 43 | rr.Header().Get("Content-Encoding"), "") 44 | } 45 | } 46 | 47 | func TestServeIndexGzip(t *testing.T) { 48 | handler := Handler() 49 | 50 | req, err := http.NewRequest("GET", "/", nil) 51 | if err != nil { 52 | t.Fatal(err) 53 | } 54 | 55 | req.Header.Set("Accept-Encoding", "gzip") 56 | 57 | rr := httptest.NewRecorder() 58 | handler.ServeHTTP(rr, req) 59 | 60 | if status := rr.Code; status != http.StatusOK { 61 | t.Errorf("handler returned wrong status code: got %v want %v", 62 | status, http.StatusOK) 63 | } 64 | 65 | // Windows doesn't have the gzip binary, so we skip compression during the 66 | // go:generate step on Windows 67 | if runtime.GOOS != "windows" && rr.Header().Get("Content-Encoding") != "gzip" { 68 | t.Errorf("handler returned wrong content encoding: got %v want %v", 69 | rr.Header().Get("Content-Encoding"), "gzip") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /webui/webuinix.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin || freebsd 2 | // +build linux darwin freebsd 3 | 4 | //go:generate npm run clean 5 | //go:generate npm run build 6 | //go:generate gzip -r dist 7 | 8 | package webui 9 | 10 | import ( 11 | "embed" 12 | ) 13 | 14 | //go:embed dist 15 | var content embed.FS 16 | var contentPrefix = "dist" 17 | -------------------------------------------------------------------------------- /webui/webuiwin.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | //go:generate npm run clean-windows 5 | //go:generate npm run build-windows 6 | 7 | package webui 8 | 9 | import "embed" 10 | 11 | //go:embed dist-windows/* 12 | var content embed.FS 13 | var contentPrefix = "dist-windows" 14 | --------------------------------------------------------------------------------