Compare commits
582 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5138681c13 | |||
| d6304f4f1d | |||
| 082335df5d | |||
| be506661a5 | |||
| 38385adf22 | |||
| bbcfb47d28 | |||
| 32dd456ac6 | |||
| 4d2765e5f2 | |||
| 000d8bba66 | |||
| 5f909dab76 | |||
| 56e63ff551 | |||
| f31b54582f | |||
| 5b256a92b3 | |||
| cd829bd79a | |||
| 5b97953bfb | |||
| afed762774 | |||
| 5324f29693 | |||
| b96a287a2f | |||
| 0ab62f197a | |||
| 9a2d1b3074 | |||
| e809dbf45e | |||
| 644d6f8ffb | |||
| e37bb73b4e | |||
| 26ec29d8b2 | |||
| c0028c1c44 | |||
| ab38f24198 | |||
| 7d9a1b7eae | |||
| 2107483588 | |||
| c2b05954e9 | |||
| 7756c9a55c | |||
| 3e71542c78 | |||
| 2f2e6e6a00 | |||
| 6f7467e8d8 | |||
| f2a6193735 | |||
| 84eca86e14 | |||
| 86fdcfc437 | |||
| 25eeedbce8 | |||
| 2ba1ff15b7 | |||
| 5aefc47f71 | |||
| 7633c1cdf1 | |||
| 283c2821cd | |||
| fb999c8617 | |||
| 53dc0c3c5e | |||
| 84019238df | |||
| cef865f5ce | |||
| bdf99f6bb4 | |||
| 7424423d9a | |||
| c9b812570d | |||
| e62d157aac | |||
| 99b3337b1c | |||
| dd673bf923 | |||
| 158d1175e3 | |||
| b25da7b9b0 | |||
| 5cd2593f52 | |||
| d8d9e34914 | |||
| e8c0d206ea | |||
| 17818439a0 | |||
| 056be1185d | |||
| b0898ec8bc | |||
| 133b7013a0 | |||
| dabf7e3ec9 | |||
| da3ba50a2c | |||
| 5eababe0b0 | |||
| b2c5ffcaea | |||
| 4ee00091d6 | |||
| c655622bf7 | |||
| 71d331af16 | |||
| 34052d98d6 | |||
| 3543fbbb65 | |||
| aff639e87a | |||
| 5dfe24eeee | |||
| 78ec5aa716 | |||
| 457d5bad9a | |||
| a0f301d700 | |||
| fc311a9fd6 | |||
| a7993d18c6 | |||
| 5595cff6a6 | |||
| 2c3b3c093d | |||
| ab3e783168 | |||
| 7584c66bdb | |||
| d78d20e2d0 | |||
| b85ec52387 | |||
| edcd258cc9 | |||
| d5250bbf51 | |||
| d50e553ebb | |||
| 1eb0f12a61 | |||
| 8129ed91f1 | |||
| c87759affe | |||
| 07b43bffa4 | |||
| 0230c6321f | |||
| 796c5ad42d | |||
| d281fb00e3 | |||
| 76a6c623f3 | |||
| 25c974f0b5 | |||
| ea719f5b5a | |||
| 3105fa4ff8 | |||
| f724480c7b | |||
| 5c5e8c0baf | |||
| bb5278fe9b | |||
| 38b0bd76f5 | |||
| c1aab0bee9 | |||
| ef3283638a | |||
| 68b62a5c00 | |||
| 7a75a4d384 | |||
| 1aae7b222f | |||
| 3535b251ab | |||
| 707f070a3c | |||
| 160a3b6d32 | |||
| d977796f18 | |||
| 3cef76d463 | |||
| e9564a7e5c | |||
| b79ad3ddc3 | |||
| d0f898751f | |||
| 22d9830baa | |||
| c4e23862cd | |||
| e10dc129de | |||
| dd11fc96db | |||
| 7de3c0a09a | |||
| 141e49727c | |||
| 442d61918b | |||
| 58dcc07430 | |||
| 19d0916e34 | |||
| 1265cc7934 | |||
| 3d2688e959 | |||
| 7ab51fad0d | |||
| 3472145df6 | |||
| 5e1ea21f85 | |||
| 2db3977408 | |||
| ca6d5d3bb5 | |||
| 163c2bc533 | |||
| dd286aa12e | |||
| 80babe3ab4 | |||
| ca0cab0a15 | |||
| ad8be226fd | |||
| 9b3e5c38e2 | |||
| 7e1c5f5d1f | |||
| 98bc8d1717 | |||
| 2889a5d5ff | |||
| 0b8c19c620 | |||
| a62b475f46 | |||
| 1691839c6b | |||
| 36265ff349 | |||
| 94427d334b | |||
| b2844569c8 | |||
| ae5e60b1ae | |||
| fffda09f67 | |||
| 5ec7a5c078 | |||
| 190c7270d9 | |||
| f704b28cad | |||
| 5efeec1819 | |||
| 438f950be3 | |||
| 50c1549865 | |||
| 6e21a67473 | |||
| 17ab39d646 | |||
| 44d2057df8 | |||
| 3e359aaeda | |||
| 8cfeeee23a | |||
| f535b2327f | |||
| e63ac7d1bb | |||
| 359ab2ca0c | |||
| 45373168ad | |||
| 69bfcb7bed | |||
| 4ede99b08c | |||
| 814fec60f0 | |||
| ace4f3d892 | |||
| 82f0060eec | |||
| 53ac853cb4 | |||
| a5376913fd | |||
| 6fcf0635a5 | |||
| 1f072936c5 | |||
| 3c06235dca | |||
| f13749380d | |||
| eadb6f823d | |||
| e7497ab3d0 | |||
| 59a4e2db5f | |||
| 2c994e879c | |||
| de2768c91d | |||
| fb6f58b735 | |||
| be4e244f1f | |||
| 074f989547 | |||
| 87c8957889 | |||
| f6cf23779f | |||
| 92a1a6d2ca | |||
| 63169c51b7 | |||
| 5c3c83b2cb | |||
| aeb87ffbcf | |||
| a85abe8bae | |||
| d584e1ac54 | |||
| af3d96dab8 | |||
| 89e0103abd | |||
| 025fbda984 | |||
| 9bb7b19c93 | |||
| cb83a26c90 | |||
| d17ecc3b5c | |||
| f9d24d15ed | |||
| 1fb59c953a | |||
| be962998ba | |||
| a97684923a | |||
| 1643198713 | |||
| 125b561df3 | |||
| 0c3a694470 | |||
| 525a875825 | |||
| 4e16952ad9 | |||
| 14febbeb4c | |||
| 93c90cd705 | |||
| 01668a989e | |||
| 976c1f3929 | |||
| 75e42fd860 | |||
| 2990193512 | |||
| 75ba5874a5 | |||
| 9e941d5be5 | |||
| 328ac687f6 | |||
| 1ce0c69e4f | |||
| f3e8eda04d | |||
| cf5d63ee88 | |||
| 4e04f8d3d2 | |||
| d04b9713c4 | |||
| 6889f441b2 | |||
| 9c86e03384 | |||
| ffe33aff3d | |||
| e3be120d88 | |||
| 9446b51a1f | |||
| 4a6160baf3 | |||
| 9dd662df08 | |||
| 7f9eb62640 | |||
| 6c3f442f13 | |||
| fa47667f31 | |||
| ab9446137f | |||
| 0c2ac3a168 | |||
| bf5099baac | |||
| 7836ea36fc | |||
| 1edf020ce7 | |||
| c60ed78bc5 | |||
| 82d51cd0dc | |||
| 0f75677e59 | |||
| 2c25f28972 | |||
| 1323a30a0c | |||
| 975fdee217 | |||
| acd2418803 | |||
| 9eca269ad8 | |||
| c21222ef13 | |||
| 6c988d0ebb | |||
| 4b39a18178 | |||
| 6c31c66562 | |||
| adab06405b | |||
| 52f0c0c33b | |||
| bfc631f6a6 | |||
| 1e27720b99 | |||
| 8eade3d20a | |||
| 308e5e35fb | |||
| e161347e47 | |||
| 754b253043 | |||
| be57d6ce8e | |||
| 4336af295f | |||
| 0c4a3f8dc4 | |||
| 8799108b55 | |||
| 03f9f81cb5 | |||
| 2da0e51fda | |||
| f0860767f5 | |||
| ad28fd1ecb | |||
| 3d8e552c6a | |||
| d55a271ac7 | |||
| b95e983d0d | |||
| f387e23214 | |||
| 32391adf4f | |||
| c0a3e1f121 | |||
| 5442d8e7d5 | |||
| 48feaff054 | |||
| acd2a68cfb | |||
| 1690339440 | |||
| b5957aca37 | |||
| 47d8a5a7cd | |||
| 583773025f | |||
| 98f3f3dfd8 | |||
| 6ca11a7391 | |||
| 42056f7a32 | |||
| b2f01e454f | |||
| 6cedd0a2e8 | |||
| e495824834 | |||
| 7dd49b835a | |||
| 22ca80eb7e | |||
| 12dd240b5f | |||
| d684302864 | |||
| a2aa8e7bd7 | |||
| 0de17fd051 | |||
| fb0a8d8b40 | |||
| c69ebd3806 | |||
| df6e1bc64d | |||
| 89dc267780 | |||
| c7237cb09d | |||
| 4073c5dfb0 | |||
| c2b04d10c5 | |||
| b0d17803f0 | |||
| 16e1396261 | |||
| b955d308d9 | |||
| ecd9db4ac6 | |||
| b4ea5f56b2 | |||
| 5e51595d7f | |||
| 48a08e4f4d | |||
| e2bf557d17 | |||
| fbb8a25320 | |||
| add1b69809 | |||
| cd9a428841 | |||
| 4ec5e23457 | |||
| 13dc63bd23 | |||
| 38bb78b642 | |||
| 6425c26b1b | |||
| 428339e1ad | |||
| ee5445777d | |||
| a0f72b585b | |||
| 7ff72fb6d9 | |||
| f6be5b776b | |||
| 666ead2646 | |||
| cf01086df5 | |||
| ea0e3a29e4 | |||
| 71203ab325 | |||
| 88de8ba8bb | |||
| 861e5f8313 | |||
| e30ae972f4 | |||
| e082816de5 | |||
| b56e236874 | |||
| f46d1bc338 | |||
| 51cfcfe79b | |||
| d677cde6d0 | |||
| 873c64ecc3 | |||
| 54c9dcb7c1 | |||
| 9b537ec704 | |||
| 70aad0940f | |||
| 7b3a84e2a3 | |||
| 301c7b2dd5 | |||
| 2f205d205b | |||
| 38532abf45 | |||
| c7d28fee8f | |||
| bf1e626b9a | |||
| 93929b61e4 | |||
| 5479463783 | |||
| 094eced2c7 | |||
| 991f13a3da | |||
| 7303d91abf | |||
| c1f3076eb3 | |||
| 74445a0017 | |||
| a0b320bfeb | |||
| 1826535e6f | |||
| 1a8d2c5ebe | |||
| 8cca0a8547 | |||
| 302d77cf4a | |||
| c7f0f779b1 | |||
| e6426db8da | |||
| fa6426f803 | |||
| 0918ef6815 | |||
| 181875a89b | |||
| 67743d2a66 | |||
| 386d3b19b2 | |||
| 9d7110eaad | |||
| 82ea535692 | |||
| a4da029105 | |||
| aa3865d0a3 | |||
| 0895e02844 | |||
| 0764f8824e | |||
| 30057d4e59 | |||
| 75fd21e454 | |||
| 0d2f73d728 | |||
| 9b803aad2d | |||
| fb74addc1e | |||
| 7f89372cfa | |||
| 26b436463d | |||
| 61af396fdd | |||
| 792c1a23b2 | |||
| 7464e967c8 | |||
| 921deb43f5 | |||
| 4cf910874d | |||
| 3ba3648f0f | |||
| aa92261ea7 | |||
| 8e34843eb1 | |||
| bfaf6fdfbb | |||
| f992fed968 | |||
| 94967b596c | |||
| 759dae4552 | |||
| f3d3a7f5f8 | |||
| ccd062e238 | |||
| fc1c46ffd7 | |||
| 6b5bd0a43c | |||
| 921bccb960 | |||
| 24b29a0865 | |||
| 5f2c14adab | |||
| 787675aee8 | |||
| 6f68c98c16 | |||
| 01e0519b7b | |||
| 8742b7f763 | |||
| 710bf79f7e | |||
| a622ef1487 | |||
| 56f13741f9 | |||
| 746fb4ff88 | |||
| 8473462adf | |||
| 61842988b9 | |||
| be7176bee7 | |||
| c403e56835 | |||
| 839eccf513 | |||
| 9a19615fd4 | |||
| 03ba2bb7c8 | |||
| 7ac2821147 | |||
| e6554b77c0 | |||
| acd9aedb98 | |||
| 835cf6b12f | |||
| 4b3d83ec1f | |||
| d13c6e3fc3 | |||
| 63fa7a67e9 | |||
| 7d89d1fb00 | |||
| b69089a527 | |||
| 59e34942d1 | |||
| 91c4a97f72 | |||
| 13562b48ed | |||
| 58ee796394 | |||
| ce351146cf | |||
| 14b8d69a63 | |||
| 8c7e373049 | |||
| 2c87b15e83 | |||
| d7f3fb93ae | |||
| ebf209427a | |||
| 51377a8ffb | |||
| f14dc3107f | |||
| 261d095108 | |||
| 583a63a68f | |||
| 2716c75ad5 | |||
| e8facfe9fa | |||
| d424e800c8 | |||
| 896f31bbf3 | |||
| d59426a8cc | |||
| 903fbff710 | |||
| bc4b129536 | |||
| b4b1bebb5c | |||
| 445a280aa2 | |||
| b276787dd4 | |||
| bfbab9382e | |||
| 2bfcfa5be8 | |||
| a7f70a566d | |||
| f5ead68586 | |||
| bf6c58425a | |||
| 8249a5d1b7 | |||
| b576253ea8 | |||
| 476348689a | |||
| ed68bffea5 | |||
| 51af880701 | |||
| cef63ac176 | |||
| 9e48c70f58 | |||
| fe40352f8e | |||
| fd123fc6c4 | |||
| 961500cc2c | |||
| 47726ad877 | |||
| 3b6cede108 | |||
| 2cad13f27b | |||
| b5475921a8 | |||
| 88c684e1d0 | |||
| a4d28701d9 | |||
| 000c8b4186 | |||
| ae4b12c9ba | |||
| 8cc31bd76a | |||
| 43871a9211 | |||
| 77576164ae | |||
| 4c450a640c | |||
| 36d2cb115a | |||
| 7ba5c14679 | |||
| ab7eb47483 | |||
| 707a189144 | |||
| 4f37dccb76 | |||
| 69084fb08f | |||
| 0a77be0ff2 | |||
| 597302de3f | |||
| 828c2982f3 | |||
| 6f7b8ca1af | |||
| e0f40de69f | |||
| 2fe7f29d31 | |||
| df181108b4 | |||
| c3af267f48 | |||
| a15a73e884 | |||
| ff10a71f3a | |||
| 1f266d02ce | |||
| 4efff312ea | |||
| 0ed5c01473 | |||
| 63bc27e6e1 | |||
| e72d274e88 | |||
| 007da664ea | |||
| f3433df617 | |||
| 493a01c4fe | |||
| 161ce3b3c3 | |||
| 83bb97a845 | |||
| 40919d06c2 | |||
| c48069ce88 | |||
| 4b489ad2c7 | |||
| 72d288567e | |||
| b5d46e2ec3 | |||
| 83d450b1f2 | |||
| 3e647c14c0 | |||
| b95260f4b5 | |||
| 2764c95952 | |||
| 678b4a6f4b | |||
| 79ab43ebca | |||
| a4963cecb7 | |||
| e6d08149ef | |||
| ea6f02368b | |||
| d891b2b119 | |||
| 5c6c9485a8 | |||
| b4d8c93fae | |||
| 3f394a8960 | |||
| 164f77d80c | |||
| 775bc5caf6 | |||
| 45670a7e5d | |||
| 8934d7cb64 | |||
| aab43af27c | |||
| 0cbee78b40 | |||
| 6199595602 | |||
| 0517b746a1 | |||
| 4b23648e6e | |||
| 7c86628f3b | |||
| 03955196a9 | |||
| 54b391f77c | |||
| c1a5b04065 | |||
| 0cb5ffb48b | |||
| 40920c4ea5 | |||
| 4e4bf3dbd8 | |||
| 1c3e4e2f07 | |||
| 8dabfe08c7 | |||
| a99e82f490 | |||
| 5c7d658143 | |||
| d5df354e64 | |||
| fae34f5927 | |||
| c4c38d5b23 | |||
| e0e362c4ff | |||
| c31ec8c646 | |||
| 83edcb3889 | |||
| 9465b593f9 | |||
| 3ff9e6d2f7 | |||
| bfcba7b281 | |||
| a459f0a0f6 | |||
| eaff4fb963 | |||
| e87ce3f7cf | |||
| af2ff08940 | |||
| 458c28e1d0 | |||
| eaed271a00 | |||
| 3eaba92025 | |||
| aea62a9d85 | |||
| 1a62d635a6 | |||
| 9cd83399d3 | |||
| 4ac7e78e2b | |||
| 71650819d3 | |||
| 3fd9726719 | |||
| 6a05e101cf | |||
| b168274048 | |||
| cd4e09ebb3 | |||
| e3ec093d53 | |||
| dc3378e890 | |||
| 36b66f6fd7 | |||
| e70b6f3071 | |||
| c6ca1efaae | |||
| 0acb2e9ec0 | |||
| 5faafbb1b4 | |||
| e2149de840 | |||
| 1deaa459c8 | |||
| 93a58a36bf | |||
| d76d3db0b3 | |||
| 0a53b2415f | |||
| ce25c01c7e | |||
| f3689563b5 | |||
| fa4d2098a8 | |||
| 73c9033583 | |||
| 8bf3516003 | |||
| 0df47b0b0a | |||
| b2f3645bfd | |||
| 98fc59dbd5 | |||
| 64b4901eb0 | |||
| 7e442ea0ea | |||
| 70258fbada | |||
| 287e0620ba | |||
| 8f03e97d47 | |||
| 4a00bef40b | |||
| fee2071514 | |||
| e048386cd5 | |||
| 8a96267ef4 | |||
| f8979d6e83 | |||
| 582ae83da3 | |||
| 3070d156a3 | |||
| 6ab05ee1fa |
@@ -0,0 +1,102 @@
|
|||||||
|
version: 2.0
|
||||||
|
workflows:
|
||||||
|
version: 2
|
||||||
|
build:
|
||||||
|
jobs:
|
||||||
|
- build-1.11
|
||||||
|
- build-1.12
|
||||||
|
- build-latest
|
||||||
|
jobs:
|
||||||
|
# build-latest serves as the template
|
||||||
|
# we use YAML anchors & aliases to exchange the docker image (and hence Go version used for the build)
|
||||||
|
build-latest: &build-latest
|
||||||
|
description: Builds zrepl
|
||||||
|
parameters:
|
||||||
|
image:
|
||||||
|
description: "the docker image that the job should use"
|
||||||
|
type: string
|
||||||
|
docker:
|
||||||
|
- image: circleci/golang:latest
|
||||||
|
environment:
|
||||||
|
# required by lazy.sh
|
||||||
|
TERM: xterm
|
||||||
|
working_directory: /go/src/github.com/zrepl/zrepl
|
||||||
|
steps:
|
||||||
|
- run:
|
||||||
|
name: Setup environment variables
|
||||||
|
command: |
|
||||||
|
# used by pip (for docs)
|
||||||
|
echo 'export PATH="$HOME/.local/bin:$PATH"' >> $BASH_ENV
|
||||||
|
|
||||||
|
- restore_cache:
|
||||||
|
keys:
|
||||||
|
- source
|
||||||
|
- vendor
|
||||||
|
- protobuf
|
||||||
|
|
||||||
|
- checkout
|
||||||
|
|
||||||
|
- save_cache:
|
||||||
|
key: source
|
||||||
|
paths:
|
||||||
|
- ".git"
|
||||||
|
|
||||||
|
# install deps
|
||||||
|
- run: wget https://github.com/protocolbuffers/protobuf/releases/download/v3.6.1/protoc-3.6.1-linux-x86_64.zip
|
||||||
|
- run: echo "6003de742ea3fcf703cfec1cd4a3380fd143081a2eb0e559065563496af27807 protoc-3.6.1-linux-x86_64.zip" | sha256sum -c
|
||||||
|
- run: sudo unzip -d /usr protoc-3.6.1-linux-x86_64.zip
|
||||||
|
- save_cache:
|
||||||
|
key: protobuf
|
||||||
|
paths:
|
||||||
|
- "/usr/include/google/protobuf"
|
||||||
|
|
||||||
|
- run: sudo apt install python3 python3-pip libgirepository1.0-dev
|
||||||
|
- run: ./lazy.sh devsetup
|
||||||
|
|
||||||
|
- run:
|
||||||
|
name: Install minio client, prepare minio
|
||||||
|
command: |
|
||||||
|
curl -sSL https://dl.minio.io/client/mc/release/linux-amd64/mc -o ${GOPATH}/bin/mc
|
||||||
|
chmod +x ${GOPATH}/bin/mc
|
||||||
|
mc config host add --api s3v4 zrepl-minio https://minio.cschwarz.com ${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY}
|
||||||
|
|
||||||
|
- run: make vendordeps
|
||||||
|
- save_cache:
|
||||||
|
key: vendor
|
||||||
|
paths:
|
||||||
|
- "./vendor"
|
||||||
|
|
||||||
|
- run: make
|
||||||
|
- run: make vet
|
||||||
|
- run: make test
|
||||||
|
- run: make lint
|
||||||
|
- run: make release
|
||||||
|
|
||||||
|
|
||||||
|
- store_artifacts:
|
||||||
|
path: ./artifacts/release
|
||||||
|
when: always
|
||||||
|
|
||||||
|
- run:
|
||||||
|
shell: /bin/bash -euo pipefail
|
||||||
|
when: always
|
||||||
|
command: |
|
||||||
|
echo "$CIRCLE_BUILD_URL" > ./artifacts/release/cirlceci_build_url
|
||||||
|
mc cp -r artifacts/release "zrepl-minio/zrepl-ci-artifacts/${CIRCLE_SHA1}/${CIRCLE_JOB}/"
|
||||||
|
REPO="zrepl/zrepl"
|
||||||
|
COMMIT="${CIRCLE_SHA1}"
|
||||||
|
JOB_NAME="${CIRCLE_JOB}"
|
||||||
|
curl "https://api.github.com/repos/$REPO/statuses/$COMMIT?access_token=$GITHUB_COMMIT_STATUS_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST \
|
||||||
|
-d '{"context":"zrepl/publish-ci-artifacts", "state": "success", "description":"CI Build Artifacts for '"$JOB_NAME"'", "target_url":"https://minio.cschwarz.com/minio/zrepl-ci-artifacts/'"$COMMIT"'/"}'
|
||||||
|
|
||||||
|
|
||||||
|
build-1.11:
|
||||||
|
<<: *build-latest
|
||||||
|
docker:
|
||||||
|
- image: circleci/golang:1.11
|
||||||
|
build-1.12:
|
||||||
|
<<: *build-latest
|
||||||
|
docker:
|
||||||
|
- image: circleci/golang:1.12
|
||||||
+10
@@ -1,3 +1,13 @@
|
|||||||
|
# Build
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# Golang
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||||
*.o
|
*.o
|
||||||
*.a
|
*.a
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
[submodule "docs/themes/docdock"]
|
|
||||||
path = docs/themes/docdock
|
|
||||||
url = git@github.com:zrepl/hugo-theme-docdock.git
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- goimports
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-rules:
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- errcheck
|
||||||
|
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
dist: xenial
|
||||||
|
services:
|
||||||
|
- docker
|
||||||
|
|
||||||
|
env: # for allow_failures: https://docs.travis-ci.com/user/customizing-the-build/
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
|
||||||
|
- language: go
|
||||||
|
name: "Build in Docker (docs/installation.rst)"
|
||||||
|
script:
|
||||||
|
- sudo docker build -t zrepl_build -f build.Dockerfile .
|
||||||
|
- |
|
||||||
|
sudo docker run -it --rm \
|
||||||
|
-v "${PWD}:/go/src/github.com/zrepl/zrepl" \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
zrepl_build make vendordeps release
|
||||||
|
|
||||||
|
- &zrepl_build_template
|
||||||
|
language: go
|
||||||
|
go_import_path: github.com/zrepl/zrepl
|
||||||
|
before_install:
|
||||||
|
- wget https://github.com/protocolbuffers/protobuf/releases/download/v3.6.1/protoc-3.6.1-linux-x86_64.zip
|
||||||
|
- echo "6003de742ea3fcf703cfec1cd4a3380fd143081a2eb0e559065563496af27807 protoc-3.6.1-linux-x86_64.zip" | sha256sum -c
|
||||||
|
- sudo unzip -d /usr protoc-3.6.1-linux-x86_64.zip
|
||||||
|
- ./lazy.sh godep
|
||||||
|
- make vendordeps
|
||||||
|
script:
|
||||||
|
- make
|
||||||
|
- make vet
|
||||||
|
- make test
|
||||||
|
- make lint
|
||||||
|
- make artifacts/zrepl-freebsd-amd64
|
||||||
|
- make artifacts/zrepl-linux-amd64
|
||||||
|
- make artifacts/zrepl-darwin-amd64
|
||||||
|
go:
|
||||||
|
- "1.11"
|
||||||
|
|
||||||
|
- <<: *zrepl_build_template
|
||||||
|
go:
|
||||||
|
- "1.12"
|
||||||
|
|
||||||
|
- <<: *zrepl_build_template
|
||||||
|
go:
|
||||||
|
- "master"
|
||||||
|
|
||||||
|
- &zrepl_docs_template
|
||||||
|
language: python
|
||||||
|
python:
|
||||||
|
- "3.4"
|
||||||
|
install:
|
||||||
|
- sudo apt-get install libgirepository1.0-dev
|
||||||
|
- pip install -r docs/requirements.txt
|
||||||
|
script:
|
||||||
|
- make docs
|
||||||
|
- <<: *zrepl_docs_template
|
||||||
|
python:
|
||||||
|
- "3.5"
|
||||||
|
- <<: *zrepl_docs_template
|
||||||
|
python:
|
||||||
|
- "3.6"
|
||||||
|
- <<: *zrepl_docs_template
|
||||||
|
python:
|
||||||
|
- "3.7"
|
||||||
|
|
||||||
|
|
||||||
|
allow_failures:
|
||||||
|
- <<: *zrepl_build_template
|
||||||
|
go:
|
||||||
|
- "master"
|
||||||
Generated
+1038
File diff suppressed because it is too large
Load Diff
+67
@@ -0,0 +1,67 @@
|
|||||||
|
ignored = [
|
||||||
|
"github.com/inconshreveable/mousetrap",
|
||||||
|
]
|
||||||
|
|
||||||
|
required = [
|
||||||
|
"golang.org/x/tools/cmd/stringer",
|
||||||
|
"github.com/alvaroloes/enumer",
|
||||||
|
"github.com/golangci/golangci-lint/cmd/golangci-lint",
|
||||||
|
"golang.org/x/tools/cmd/goimports",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
branch = "master"
|
||||||
|
name = "github.com/jinzhu/copier"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
branch = "master"
|
||||||
|
name = "github.com/kr/pretty"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/pkg/errors"
|
||||||
|
version = "0.8.0"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
branch = "master"
|
||||||
|
name = "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/stretchr/testify"
|
||||||
|
version = "1.1.4"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
branch = "v2"
|
||||||
|
name = "github.com/zrepl/yaml-config"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/go-logfmt/logfmt"
|
||||||
|
version = "*"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/problame/go-netssh"
|
||||||
|
branch = "master"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/prometheus/client_golang"
|
||||||
|
branch = "master"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/golang/protobuf"
|
||||||
|
version = "1"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/fatih/color"
|
||||||
|
version = "1.7.0"
|
||||||
|
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "github.com/gdamore/tcell"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
name = "google.golang.org/grpc"
|
||||||
|
version = "1"
|
||||||
|
|
||||||
|
[[constraint]]
|
||||||
|
version = "1.1.0"
|
||||||
|
name = "github.com/google/uuid"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2017 Christian Schwarz
|
Copyright (c) 2017-2018 Christian Schwarz
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
.PHONY: generate build test vet cover release docs docs-clean clean vendordeps format lint
|
||||||
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
|
ARTIFACTDIR := artifacts
|
||||||
|
|
||||||
|
ifdef ZREPL_VERSION
|
||||||
|
_ZREPL_VERSION := $(ZREPL_VERSION)
|
||||||
|
endif
|
||||||
|
ifndef _ZREPL_VERSION
|
||||||
|
_ZREPL_VERSION := $(shell git describe --always --dirty 2>/dev/null || echo "ZREPL_BUILD_INVALID_VERSION" )
|
||||||
|
ifeq ($(_ZREPL_VERSION),ZREPL_BUILD_INVALID_VERSION) # can't use .SHELLSTATUS because Debian Stretch is still on gmake 4.1
|
||||||
|
$(error cannot infer variable ZREPL_VERSION using git and variable is not overriden by make invocation)
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
GO_LDFLAGS := "-X github.com/zrepl/zrepl/version.zreplVersion=$(_ZREPL_VERSION)"
|
||||||
|
|
||||||
|
GO_BUILD := go build -ldflags $(GO_LDFLAGS)
|
||||||
|
|
||||||
|
RELEASE_BINS := $(ARTIFACTDIR)/zrepl-freebsd-amd64
|
||||||
|
RELEASE_BINS += $(ARTIFACTDIR)/zrepl-linux-amd64
|
||||||
|
RELEASE_BINS += $(ARTIFACTDIR)/zrepl-darwin-amd64
|
||||||
|
|
||||||
|
RELEASE_NOARCH := $(ARTIFACTDIR)/zrepl-noarch.tar
|
||||||
|
THIS_PLATFORM_RELEASE_BIN := $(shell bash -c 'source <(go env) && echo "zrepl-$${GOOS}-$${GOARCH}"' )
|
||||||
|
|
||||||
|
vendordeps:
|
||||||
|
dep ensure -v -vendor-only
|
||||||
|
|
||||||
|
generate: #not part of the build, must do that manually
|
||||||
|
protoc -I=replication/logic/pdu --go_out=plugins=grpc:replication/logic/pdu replication/logic/pdu/pdu.proto
|
||||||
|
go generate -x ./...
|
||||||
|
|
||||||
|
format:
|
||||||
|
goimports -srcdir . -local 'github.com/zrepl/zrepl' -w $(shell find . -type f -name '*.go' -not -path "./vendor/*" -not -name '*.pb.go' -not -name '*_enumer.go')
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
build:
|
||||||
|
@echo "INFO: In case of missing dependencies, run 'make vendordeps'"
|
||||||
|
$(GO_BUILD) -o "$(ARTIFACTDIR)/zrepl"
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
# for each supported platform to cover conditional compilation
|
||||||
|
GOOS=linux go vet ./...
|
||||||
|
GOOS=darwin go vet ./...
|
||||||
|
GOOS=freebsd go vet ./...
|
||||||
|
|
||||||
|
$(ARTIFACTDIR):
|
||||||
|
mkdir -p "$@"
|
||||||
|
|
||||||
|
$(ARTIFACTDIR)/docs: $(ARTIFACTDIR)
|
||||||
|
mkdir -p "$@"
|
||||||
|
|
||||||
|
$(ARTIFACTDIR)/bash_completion: $(RELEASE_BINS)
|
||||||
|
artifacts/$(THIS_PLATFORM_RELEASE_BIN) bashcomp "$@"
|
||||||
|
|
||||||
|
$(ARTIFACTDIR)/go_version.txt:
|
||||||
|
go version > $@
|
||||||
|
|
||||||
|
docs: $(ARTIFACTDIR)/docs
|
||||||
|
make -C docs \
|
||||||
|
html \
|
||||||
|
BUILDDIR=../artifacts/docs \
|
||||||
|
|
||||||
|
docs-clean:
|
||||||
|
make -C docs \
|
||||||
|
clean \
|
||||||
|
BUILDDIR=../artifacts/docs
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: $(RELEASE_BINS)
|
||||||
|
# TODO: two wildcards possible
|
||||||
|
$(RELEASE_BINS): $(ARTIFACTDIR)/zrepl-%-amd64: generate $(ARTIFACTDIR) vet test lint
|
||||||
|
@echo "INFO: In case of missing dependencies, run 'make vendordeps'"
|
||||||
|
GOOS=$* GOARCH=amd64 $(GO_BUILD) -o "$(ARTIFACTDIR)/zrepl-$*-amd64"
|
||||||
|
|
||||||
|
$(RELEASE_NOARCH): docs $(ARTIFACTDIR)/bash_completion $(ARTIFACTDIR)/go_version.txt
|
||||||
|
tar --mtime='1970-01-01' --sort=name \
|
||||||
|
--transform 's/$(ARTIFACTDIR)/zrepl-$(_ZREPL_VERSION)-noarch/' \
|
||||||
|
-acf $@ \
|
||||||
|
$(ARTIFACTDIR)/docs/html \
|
||||||
|
$(ARTIFACTDIR)/bash_completion \
|
||||||
|
$(ARTIFACTDIR)/go_version.txt
|
||||||
|
|
||||||
|
release: $(RELEASE_BINS) $(RELEASE_NOARCH)
|
||||||
|
rm -rf "$(ARTIFACTDIR)/release"
|
||||||
|
mkdir -p "$(ARTIFACTDIR)/release"
|
||||||
|
cp $^ "$(ARTIFACTDIR)/release"
|
||||||
|
cd "$(ARTIFACTDIR)/release" && sha512sum $$(ls | sort) > sha512sum.txt
|
||||||
|
@# note that we use ZREPL_VERSION and not _ZREPL_VERSION because we want to detect the override
|
||||||
|
@if git describe --always --dirty 2>/dev/null | grep dirty >/dev/null; then \
|
||||||
|
echo '[INFO] either git reports checkout is dirty or git is not installed or this is not a git checkout'; \
|
||||||
|
if [ "$(ZREPL_VERSION)" = "" ]; then \
|
||||||
|
echo '[WARN] git checkout is dirty and make variable ZREPL_VERSION was not used to override'; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
fi;
|
||||||
|
|
||||||
|
clean: docs-clean
|
||||||
|
rm -rf "$(ARTIFACTDIR)"
|
||||||
@@ -1,2 +1,135 @@
|
|||||||
|
[](https://github.com/zrepl/zrepl/blob/master/LICENSE)
|
||||||
|
[](https://golang.org/)
|
||||||
|
[](https://zrepl.github.io)
|
||||||
|
[](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=R5QSXJVYHGX96)
|
||||||
|
[](https://liberapay.com/zrepl/donate)
|
||||||
|
[](https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fzrepl%2Fzrepl)
|
||||||
|
|
||||||
# zrepl
|
# zrepl
|
||||||
ZFS dataset replication. Not a shell script.
|
zrepl is a one-stop ZFS backup & replication solution.
|
||||||
|
|
||||||
|
## User Documentation
|
||||||
|
|
||||||
|
**User Documentation** can be found at [zrepl.github.io](https://zrepl.github.io).
|
||||||
|
|
||||||
|
## Bug Reports
|
||||||
|
|
||||||
|
1. If the issue is reproducible, enable debug logging, reproduce and capture the log.
|
||||||
|
2. Open an issue on GitHub, with logs pasted as GitHub gists / inline.
|
||||||
|
|
||||||
|
## Feature Requests
|
||||||
|
|
||||||
|
1. Does you feature request require default values / some kind of configuration?
|
||||||
|
If so, think of an expressive configuration example.
|
||||||
|
2. Think of at least one use case that generalizes from your concrete application.
|
||||||
|
3. Open an issue on GitHub with example conf & use case attached.
|
||||||
|
|
||||||
|
The above does not apply if you already implemented everything.
|
||||||
|
Check out the *Coding Workflow* section below for details.
|
||||||
|
|
||||||
|
## Package Maintainer Information
|
||||||
|
|
||||||
|
* Follow the steps in `docs/installation.rst -> Compiling from Source` and read the Makefile / shell scripts used in this process.
|
||||||
|
* Make sure your distro is compatible with the paths in `docs/installation.rst`.
|
||||||
|
* Ship a default config that adheres to your distro's `hier` and logging system.
|
||||||
|
* Ship a service manager file and _please_ try to upstream it to this repository.
|
||||||
|
* `dist/systemd` contains a Systemd unit template.
|
||||||
|
* Ship other material provided in `./dist`, e.g. in `/usr/share/zrepl/`.
|
||||||
|
* Use `make release ZREPL_VERSION='mydistro-1.2.3_1'`
|
||||||
|
* Your distro's name and any versioning supplemental to zrepl's (e.g. package revision) should be in this string
|
||||||
|
* Make sure you are informed about new zrepl versions, e.g. by subscribing to GitHub's release RSS feed.
|
||||||
|
|
||||||
|
## Developer Documentation
|
||||||
|
|
||||||
|
zrepl is written in [Go](https://golang.org) and uses [`dep`](https://github.com/golang/dep) to manage dependencies.
|
||||||
|
The documentation is written in [ReStructured Text](http://docutils.sourceforge.net/rst.html) using the [Sphinx](https://www.sphinx-doc.org) framework.
|
||||||
|
|
||||||
|
To get started, run `./lazy.sh devsetup` to easily install build dependencies and read `docs/installation.rst -> Compiling from Source`.
|
||||||
|
|
||||||
|
### Overall Architecture
|
||||||
|
|
||||||
|
The application architecture is documented as part of the user docs in the *Implementation* section (`docs/content/impl`).
|
||||||
|
Make sure to develop an understanding how zrepl is typically used by studying the user docs first.
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── artifacts # build artifcats generate by make
|
||||||
|
├── cli # wrapper around CLI package cobra
|
||||||
|
├── client # all subcommands that are not `daemon`
|
||||||
|
├── config # config data types (=> package yaml-config)
|
||||||
|
│ └── samples
|
||||||
|
├── daemon # the implementation of `zrepl daemon` subcommand
|
||||||
|
│ ├── filters
|
||||||
|
│ ├── job # job implementations
|
||||||
|
│ ├── logging # logging outlets + formatters
|
||||||
|
│ ├── nethelpers
|
||||||
|
│ ├── prometheus
|
||||||
|
│ ├── pruner # pruner implementation
|
||||||
|
│ ├── snapper # snapshotter implementation
|
||||||
|
├── docs # sphinx-based documentation
|
||||||
|
├── dist # supplemental material for users & package maintainers
|
||||||
|
│ ├── **/*.rst # documentation in reStructuredText
|
||||||
|
│ ├── sphinxconf
|
||||||
|
│ │ └── conf.py # sphinx config (see commit 445a280 why its not in docs/)
|
||||||
|
│ ├── requirements.txt # pip3 requirements to build documentation
|
||||||
|
│ ├── publish.sh # shell script for automated rendering & deploy to zrepl.github.io repo
|
||||||
|
│ └── public_git # checkout of zrepl.github.io managed by above shell script
|
||||||
|
├── endpoint # implementation of replication endpoints (=> package replication)
|
||||||
|
├── logger # our own logger package
|
||||||
|
├── pruning # pruning rules (the logic, not the actual execution)
|
||||||
|
│ └── retentiongrid
|
||||||
|
├── replication
|
||||||
|
│ ├── driver # the driver of the replication logic (status reporting, error handling)
|
||||||
|
│ ├── logic # planning & executing replication steps via rpc
|
||||||
|
| | └── pdu # the generated gRPC & protobuf code used in replication (and endpoints)
|
||||||
|
│ └── report # the JSON-serializable report datastructures exposed to the client
|
||||||
|
├── rpc # the hybrid gRPC + ./dataconn RPC client: connects to a remote replication.Endpoint
|
||||||
|
│ ├── dataconn # Bulk data-transfer RPC protocol
|
||||||
|
│ ├── grpcclientidentity # adaptor to inject package transport's 'client identity' concept into gRPC contexts
|
||||||
|
│ ├── netadaptor # adaptor to convert a package transport's Connecter and Listener into net.* primitives
|
||||||
|
│ ├── transportmux # TCP connecter and listener used to split control & data traffic
|
||||||
|
│ └── versionhandshake # replication protocol version handshake perfomed on newly established connections
|
||||||
|
├── tlsconf # abstraction for Go TLS server + client config
|
||||||
|
├── transport # transports implementation
|
||||||
|
│ ├── fromconfig
|
||||||
|
│ ├── local
|
||||||
|
│ ├── ssh
|
||||||
|
│ ├── tcp
|
||||||
|
│ └── tls
|
||||||
|
├── util
|
||||||
|
├── vendor # managed by dep
|
||||||
|
├── version # abstraction for versions (filled during build by Makefile)
|
||||||
|
└── zfs # zfs(8) wrappers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coding Workflow
|
||||||
|
|
||||||
|
* Open an issue when starting to hack on a new feature
|
||||||
|
* Commits should reference the issue they are related to
|
||||||
|
* Docs improvements not documenting new features do not require an issue.
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
Backward-incompatible changes must be documented in the git commit message and are listed in `docs/changelog.rst`.
|
||||||
|
|
||||||
|
* Config-breaking changes must contain a line `BREAK CONFIG` in the commit message
|
||||||
|
* Other breaking changes must contain a line `BREAK` in the commit message
|
||||||
|
|
||||||
|
### Glossary & Naming Inconsistencies
|
||||||
|
|
||||||
|
In ZFS, *dataset* refers to the objects *filesystem*, *ZVOL* and *snapshot*. <br />
|
||||||
|
However, we need a word for *filesystem* & *ZVOL* but not a snapshot, bookmark, etc.
|
||||||
|
|
||||||
|
Toward the user, the following terminology is used:
|
||||||
|
|
||||||
|
* **filesystem**: a ZFS filesystem or a ZVOL
|
||||||
|
* **filesystem version**: a ZFS snapshot or a bookmark
|
||||||
|
|
||||||
|
Sadly, the zrepl implementation is inconsistent in its use of these words:
|
||||||
|
variables and types are often named *dataset* when they in fact refer to a *filesystem*.
|
||||||
|
|
||||||
|
There will not be a big refactoring (an attempt was made, but it's destroying too much history without much gain).
|
||||||
|
|
||||||
|
However, new contributions & patches should fix naming without further notice in the commit message.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
FROM golang:latest
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3-pip \
|
||||||
|
unzip
|
||||||
|
|
||||||
|
RUN wget https://github.com/protocolbuffers/protobuf/releases/download/v3.6.1/protoc-3.6.1-linux-x86_64.zip
|
||||||
|
RUN echo "6003de742ea3fcf703cfec1cd4a3380fd143081a2eb0e559065563496af27807 protoc-3.6.1-linux-x86_64.zip" | sha256sum -c
|
||||||
|
RUN unzip -d /usr protoc-3.6.1-linux-x86_64.zip
|
||||||
|
|
||||||
|
ADD lazy.sh /tmp/lazy.sh
|
||||||
|
ADD docs/requirements.txt /tmp/requirements.txt
|
||||||
|
ENV ZREPL_LAZY_DOCS_REQPATH=/tmp/requirements.txt
|
||||||
|
RUN /tmp/lazy.sh docdep
|
||||||
|
|
||||||
|
# prepare volume mount of git checkout to /zrepl
|
||||||
|
RUN mkdir -p /go/src/github.com/zrepl/zrepl
|
||||||
|
RUN mkdir -p /.cache && chmod -R 0777 /.cache
|
||||||
|
WORKDIR /go/src/github.com/zrepl/zrepl
|
||||||
|
|
||||||
|
ADD Gopkg.toml Gopkg.lock ./
|
||||||
|
|
||||||
|
# godep will install the Go dependencies to vendor in order to then build and install
|
||||||
|
# build dependencies like stringer to $GOPATH/bin.
|
||||||
|
# However, since users volume-mount their Git checkout into /go/src/github.com/zrepl/zrepl
|
||||||
|
# the vendor directory will be empty at build time, allowing them to experiment with
|
||||||
|
# new checkouts, etc.
|
||||||
|
# Thus, we only use the vendored deps for building dependencies.
|
||||||
|
RUN /tmp/lazy.sh godep
|
||||||
|
RUN chmod -R 0777 /go
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//+build windows
|
||||||
|
|
||||||
|
// This package cannot actually be built, and since the Travis
|
||||||
|
// tests run go test ./..., we have to avoid a build attempt.
|
||||||
|
// Windows is not supported atm, so this works ¯\_(ツ)_/¯
|
||||||
|
|
||||||
|
// This package is a pseudo-user of build-time dependencies for zrepl,
|
||||||
|
// mostly for various code-generation tools.
|
||||||
|
//
|
||||||
|
// The imports are necessary to satisfy go dep
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "github.com/alvaroloes/enumer"
|
||||||
|
_ "github.com/golang/protobuf/protoc-gen-go"
|
||||||
|
_ "golang.org/x/tools/cmd/stringer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("just a placeholder")
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootArgs struct {
|
||||||
|
configPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "zrepl",
|
||||||
|
Short: "One-stop ZFS replication solution",
|
||||||
|
}
|
||||||
|
|
||||||
|
var bashcompCmd = &cobra.Command{
|
||||||
|
Use: "bashcomp path/to/out/file",
|
||||||
|
Short: "generate bash completions",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if len(args) != 1 {
|
||||||
|
fmt.Fprintf(os.Stderr, "specify exactly one positional agument\n")
|
||||||
|
err := cmd.Usage()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := rootCmd.GenBashCompletionFile(args[0]); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error generating bash completion: %s", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Hidden: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.PersistentFlags().StringVar(&rootArgs.configPath, "config", "", "config file path")
|
||||||
|
rootCmd.AddCommand(bashcompCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subcommand struct {
|
||||||
|
Use string
|
||||||
|
Short string
|
||||||
|
Example string
|
||||||
|
NoRequireConfig bool
|
||||||
|
Run func(subcommand *Subcommand, args []string) error
|
||||||
|
SetupFlags func(f *pflag.FlagSet)
|
||||||
|
SetupSubcommands func() []*Subcommand
|
||||||
|
|
||||||
|
config *config.Config
|
||||||
|
configErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subcommand) ConfigParsingError() error {
|
||||||
|
return s.configErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subcommand) Config() *config.Config {
|
||||||
|
if !s.NoRequireConfig && s.config == nil {
|
||||||
|
panic("command that requires config is running and has no config set")
|
||||||
|
}
|
||||||
|
return s.config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subcommand) run(cmd *cobra.Command, args []string) {
|
||||||
|
s.tryParseConfig()
|
||||||
|
err := s.Run(s, args)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subcommand) tryParseConfig() {
|
||||||
|
config, err := config.ParseConfig(rootArgs.configPath)
|
||||||
|
s.configErr = err
|
||||||
|
if err != nil {
|
||||||
|
if s.NoRequireConfig {
|
||||||
|
// doesn't matter
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "could not parse config: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddSubcommand(s *Subcommand) {
|
||||||
|
addSubcommandToCobraCmd(rootCmd, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSubcommandToCobraCmd(c *cobra.Command, s *Subcommand) {
|
||||||
|
cmd := cobra.Command{
|
||||||
|
Use: s.Use,
|
||||||
|
Short: s.Short,
|
||||||
|
Example: s.Example,
|
||||||
|
}
|
||||||
|
if s.SetupSubcommands == nil {
|
||||||
|
cmd.Run = s.run
|
||||||
|
} else {
|
||||||
|
for _, sub := range s.SetupSubcommands() {
|
||||||
|
addSubcommandToCobraCmd(&cmd, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.SetupFlags != nil {
|
||||||
|
s.SetupFlags(cmd.Flags())
|
||||||
|
}
|
||||||
|
c.AddCommand(&cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/kr/pretty"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
"github.com/zrepl/yaml-config"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/cli"
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon/job"
|
||||||
|
"github.com/zrepl/zrepl/daemon/logging"
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var configcheckArgs struct {
|
||||||
|
format string
|
||||||
|
what string
|
||||||
|
}
|
||||||
|
|
||||||
|
var ConfigcheckCmd = &cli.Subcommand{
|
||||||
|
Use: "configcheck",
|
||||||
|
Short: "check if config can be parsed without errors",
|
||||||
|
SetupFlags: func(f *pflag.FlagSet) {
|
||||||
|
f.StringVar(&configcheckArgs.format, "format", "", "dump parsed config object [pretty|yaml|json]")
|
||||||
|
f.StringVar(&configcheckArgs.what, "what", "all", "what to print [all|config|jobs|logging]")
|
||||||
|
},
|
||||||
|
Run: func(subcommand *cli.Subcommand, args []string) error {
|
||||||
|
formatMap := map[string]func(interface{}){
|
||||||
|
"": func(i interface{}) {},
|
||||||
|
"pretty": func(i interface{}) {
|
||||||
|
if _, err := pretty.Println(i); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": func(i interface{}) {
|
||||||
|
if err := json.NewEncoder(os.Stdout).Encode(subcommand.Config()); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"yaml": func(i interface{}) {
|
||||||
|
if err := yaml.NewEncoder(os.Stdout).Encode(subcommand.Config()); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
formatter, ok := formatMap[configcheckArgs.format]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unsupported --format %q", configcheckArgs.format)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hadErr bool
|
||||||
|
// further: try to build jobs
|
||||||
|
confJobs, err := job.JobsFromConfig(subcommand.Config())
|
||||||
|
if err != nil {
|
||||||
|
err := errors.Wrap(err, "cannot build jobs from config")
|
||||||
|
if configcheckArgs.what == "jobs" {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
confJobs = nil
|
||||||
|
hadErr = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// further: try to build logging outlets
|
||||||
|
outlets, err := logging.OutletsFromConfig(*subcommand.Config().Global.Logging)
|
||||||
|
if err != nil {
|
||||||
|
err := errors.Wrap(err, "cannot build logging from config")
|
||||||
|
if configcheckArgs.what == "logging" {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
outlets = nil
|
||||||
|
hadErr = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
whatMap := map[string]func(){
|
||||||
|
"all": func() {
|
||||||
|
o := struct {
|
||||||
|
config *config.Config
|
||||||
|
jobs []job.Job
|
||||||
|
logging *logger.Outlets
|
||||||
|
}{
|
||||||
|
subcommand.Config(),
|
||||||
|
confJobs,
|
||||||
|
outlets,
|
||||||
|
}
|
||||||
|
formatter(o)
|
||||||
|
},
|
||||||
|
"config": func() {
|
||||||
|
formatter(subcommand.Config())
|
||||||
|
},
|
||||||
|
"jobs": func() {
|
||||||
|
formatter(confJobs)
|
||||||
|
},
|
||||||
|
"logging": func() {
|
||||||
|
formatter(outlets)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
wf, ok := whatMap[configcheckArgs.what]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unsupported --format %q", configcheckArgs.what)
|
||||||
|
}
|
||||||
|
wf()
|
||||||
|
|
||||||
|
if hadErr {
|
||||||
|
return fmt.Errorf("config parsing failed")
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func controlHttpClient(sockpath string) (client http.Client, err error) {
|
||||||
|
return http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial("unix", sockpath)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonRequestResponse(c http.Client, endpoint string, req interface{}, res interface{}) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
encodeErr := json.NewEncoder(&buf).Encode(req)
|
||||||
|
if encodeErr != nil {
|
||||||
|
return encodeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.Post("http://unix"+endpoint, "application/json", &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var msg bytes.Buffer
|
||||||
|
_, _ = io.CopyN(&msg, resp.Body, 4096) // ignore error, just display what we got
|
||||||
|
return errors.Errorf("%s", msg.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
decodeError := json.NewDecoder(resp.Body).Decode(&res)
|
||||||
|
if decodeError != nil {
|
||||||
|
return decodeError
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/cli"
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
MigrateCmd = &cli.Subcommand{
|
||||||
|
Use: "migrate",
|
||||||
|
Short: "perform migration of the on-disk / zfs properties",
|
||||||
|
SetupSubcommands: func() []*cli.Subcommand {
|
||||||
|
return migrations
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var migrations = []*cli.Subcommand{
|
||||||
|
&cli.Subcommand{
|
||||||
|
Use: "0.0.X:0.1:placeholder",
|
||||||
|
Run: doMigratePlaceholder0_1,
|
||||||
|
SetupFlags: func(f *pflag.FlagSet) {
|
||||||
|
f.BoolVar(&migratePlaceholder0_1Args.dryRun, "dry-run", false, "dry run")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var migratePlaceholder0_1Args struct {
|
||||||
|
dryRun bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func doMigratePlaceholder0_1(sc *cli.Subcommand, args []string) error {
|
||||||
|
if len(args) != 0 {
|
||||||
|
return fmt.Errorf("migration does not take arguments, got %v", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := sc.Config()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
allFSS, err := zfs.ZFSListMapping(ctx, zfs.NoFilter())
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "cannot list filesystems")
|
||||||
|
}
|
||||||
|
|
||||||
|
type workItem struct {
|
||||||
|
jobName string
|
||||||
|
rootFS *zfs.DatasetPath
|
||||||
|
fss []*zfs.DatasetPath
|
||||||
|
}
|
||||||
|
var wis []workItem
|
||||||
|
for i, j := range cfg.Jobs {
|
||||||
|
var rfsS string
|
||||||
|
switch job := j.Ret.(type) {
|
||||||
|
case *config.SinkJob:
|
||||||
|
rfsS = job.RootFS
|
||||||
|
case *config.PullJob:
|
||||||
|
rfsS = job.RootFS
|
||||||
|
default:
|
||||||
|
fmt.Printf("ignoring job %q (%d/%d, type %T)\n", j.Name(), i, len(cfg.Jobs), j.Ret)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rfs, err := zfs.NewDatasetPath(rfsS)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "root fs for job %q is not a valid dataset path", j.Name())
|
||||||
|
}
|
||||||
|
var fss []*zfs.DatasetPath
|
||||||
|
for _, fs := range allFSS {
|
||||||
|
if fs.HasPrefix(rfs) {
|
||||||
|
fss = append(fss, fs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wis = append(wis, workItem{j.Name(), rfs, fss})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, wi := range wis {
|
||||||
|
fmt.Printf("job %q => migrate filesystems below root_fs %q\n", wi.jobName, wi.rootFS.ToString())
|
||||||
|
if len(wi.fss) == 0 {
|
||||||
|
fmt.Printf("\tno filesystems\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, fs := range wi.fss {
|
||||||
|
fmt.Printf("\t%q ... ", fs.ToString())
|
||||||
|
r, err := zfs.ZFSMigrateHashBasedPlaceholderToCurrent(fs, migratePlaceholder0_1Args.dryRun)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("error: %s\n", err)
|
||||||
|
} else if !r.NeedsModification {
|
||||||
|
fmt.Printf("unchanged (placeholder=%v)\n", r.OriginalState.IsPlaceholder)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("migrate (placeholder=%v) (old value = %q)\n",
|
||||||
|
r.OriginalState.IsPlaceholder, r.OriginalState.RawLocalPropertyValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMigrationsUnambiguousNames(t *testing.T) {
|
||||||
|
names := make(map[string]bool)
|
||||||
|
for _, mig := range migrations {
|
||||||
|
if _, ok := names[mig.Use]; ok {
|
||||||
|
t.Errorf("duplicate migration name %q", mig.Use)
|
||||||
|
t.FailNow()
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
names[mig.Use] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/cli"
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pprofArgs struct {
|
||||||
|
daemon.PprofServerControlMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
var PprofCmd = &cli.Subcommand{
|
||||||
|
Use: "pprof off | [on TCP_LISTEN_ADDRESS]",
|
||||||
|
Short: "start a http server exposing go-tool-compatible profiling endpoints at TCP_LISTEN_ADDRESS",
|
||||||
|
Run: func(subcommand *cli.Subcommand, args []string) error {
|
||||||
|
if len(args) < 1 {
|
||||||
|
goto enargs
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "on":
|
||||||
|
pprofArgs.Run = true
|
||||||
|
if len(args) != 2 {
|
||||||
|
return errors.New("must specify TCP_LISTEN_ADDRESS as second positional argument")
|
||||||
|
}
|
||||||
|
pprofArgs.HttpListenAddress = args[1]
|
||||||
|
case "off":
|
||||||
|
if len(args) != 1 {
|
||||||
|
goto enargs
|
||||||
|
}
|
||||||
|
pprofArgs.Run = false
|
||||||
|
}
|
||||||
|
|
||||||
|
RunPProf(subcommand.Config())
|
||||||
|
return nil
|
||||||
|
enargs:
|
||||||
|
return errors.New("invalid number of positional arguments")
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunPProf(conf *config.Config) {
|
||||||
|
log := log.New(os.Stderr, "", 0)
|
||||||
|
|
||||||
|
die := func() {
|
||||||
|
log.Printf("exiting after error")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("connecting to zrepl daemon")
|
||||||
|
|
||||||
|
httpc, err := controlHttpClient(conf.Global.Control.SockPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error creating http client: %s", err)
|
||||||
|
die()
|
||||||
|
}
|
||||||
|
err = jsonRequestResponse(httpc, daemon.ControlJobEndpointPProf, pprofArgs.PprofServerControlMsg, struct{}{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error sending control message: %s", err)
|
||||||
|
die()
|
||||||
|
}
|
||||||
|
log.Printf("finished")
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/cli"
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon"
|
||||||
|
)
|
||||||
|
|
||||||
|
var SignalCmd = &cli.Subcommand{
|
||||||
|
Use: "signal [wakeup|reset] JOB",
|
||||||
|
Short: "wake up a job from wait state or abort its current invocation",
|
||||||
|
Run: func(subcommand *cli.Subcommand, args []string) error {
|
||||||
|
return runSignalCmd(subcommand.Config(), args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSignalCmd(config *config.Config, args []string) error {
|
||||||
|
if len(args) != 2 {
|
||||||
|
return errors.Errorf("Expected 2 arguments: [wakeup|reset] JOB")
|
||||||
|
}
|
||||||
|
|
||||||
|
httpc, err := controlHttpClient(config.Global.Control.SockPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = jsonRequestResponse(httpc, daemon.ControlJobEndpointSignal,
|
||||||
|
struct {
|
||||||
|
Name string
|
||||||
|
Op string
|
||||||
|
}{
|
||||||
|
Name: args[1],
|
||||||
|
Op: args[0],
|
||||||
|
},
|
||||||
|
struct{}{},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/termbox"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
"github.com/zrepl/yaml-config"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/cli"
|
||||||
|
"github.com/zrepl/zrepl/daemon"
|
||||||
|
"github.com/zrepl/zrepl/daemon/job"
|
||||||
|
"github.com/zrepl/zrepl/daemon/pruner"
|
||||||
|
"github.com/zrepl/zrepl/replication/report"
|
||||||
|
)
|
||||||
|
|
||||||
|
type byteProgressMeasurement struct {
|
||||||
|
time time.Time
|
||||||
|
val int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type bytesProgressHistory struct {
|
||||||
|
last *byteProgressMeasurement // pointer as poor man's optional
|
||||||
|
changeCount int
|
||||||
|
lastChange time.Time
|
||||||
|
bpsAvg float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *bytesProgressHistory) Update(currentVal int64) (bytesPerSecondAvg int64, changeCount int) {
|
||||||
|
|
||||||
|
if p.last == nil {
|
||||||
|
p.last = &byteProgressMeasurement{
|
||||||
|
time: time.Now(),
|
||||||
|
val: currentVal,
|
||||||
|
}
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.last.val != currentVal {
|
||||||
|
p.changeCount++
|
||||||
|
p.lastChange = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(p.lastChange) > 3*time.Second {
|
||||||
|
p.last = nil
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
deltaV := currentVal - p.last.val
|
||||||
|
deltaT := time.Since(p.last.time)
|
||||||
|
rate := float64(deltaV) / deltaT.Seconds()
|
||||||
|
|
||||||
|
factor := 0.3
|
||||||
|
p.bpsAvg = (1-factor)*p.bpsAvg + factor*rate
|
||||||
|
|
||||||
|
p.last.time = time.Now()
|
||||||
|
p.last.val = currentVal
|
||||||
|
|
||||||
|
return int64(p.bpsAvg), p.changeCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type tui struct {
|
||||||
|
x, y int
|
||||||
|
indent int
|
||||||
|
|
||||||
|
lock sync.Mutex //For report and error
|
||||||
|
report map[string]job.Status
|
||||||
|
err error
|
||||||
|
|
||||||
|
replicationProgress map[string]*bytesProgressHistory // by job name
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTui() tui {
|
||||||
|
return tui{
|
||||||
|
replicationProgress: make(map[string]*bytesProgressHistory),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const INDENT_MULTIPLIER = 4
|
||||||
|
|
||||||
|
func (t *tui) moveLine(dl int, col int) {
|
||||||
|
t.y += dl
|
||||||
|
t.x = t.indent*INDENT_MULTIPLIER + col
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) write(text string) {
|
||||||
|
for _, c := range text {
|
||||||
|
if c == '\n' {
|
||||||
|
t.newline()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
termbox.SetCell(t.x, t.y, c, termbox.ColorDefault, termbox.ColorDefault)
|
||||||
|
t.x += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) printf(text string, a ...interface{}) {
|
||||||
|
t.write(fmt.Sprintf(text, a...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrap(s string, width int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for len(s) > 0 {
|
||||||
|
rem := width
|
||||||
|
if rem > len(s) {
|
||||||
|
rem = len(s)
|
||||||
|
}
|
||||||
|
if idx := strings.IndexAny(s, "\n\r"); idx != -1 && idx < rem {
|
||||||
|
rem = idx + 1
|
||||||
|
}
|
||||||
|
untilNewline := strings.TrimRight(s[:rem], "\n\r")
|
||||||
|
s = s[rem:]
|
||||||
|
if len(untilNewline) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString(untilNewline)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n\r")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) printfDrawIndentedAndWrappedIfMultiline(format string, a ...interface{}) {
|
||||||
|
whole := fmt.Sprintf(format, a...)
|
||||||
|
width, _ := termbox.Size()
|
||||||
|
if !strings.ContainsAny(whole, "\n\r") && t.x+len(whole) <= width {
|
||||||
|
t.printf(format, a...)
|
||||||
|
} else {
|
||||||
|
t.addIndent(1)
|
||||||
|
t.newline()
|
||||||
|
t.write(wrap(whole, width-INDENT_MULTIPLIER*t.indent))
|
||||||
|
t.addIndent(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) newline() {
|
||||||
|
t.moveLine(1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) setIndent(indent int) {
|
||||||
|
t.indent = indent
|
||||||
|
t.moveLine(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) addIndent(indent int) {
|
||||||
|
t.indent += indent
|
||||||
|
t.moveLine(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusFlags struct {
|
||||||
|
Raw bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var StatusCmd = &cli.Subcommand{
|
||||||
|
Use: "status",
|
||||||
|
Short: "show job activity or dump as JSON for monitoring",
|
||||||
|
SetupFlags: func(f *pflag.FlagSet) {
|
||||||
|
f.BoolVar(&statusFlags.Raw, "raw", false, "dump raw status description from zrepl daemon")
|
||||||
|
},
|
||||||
|
Run: runStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStatus(s *cli.Subcommand, args []string) error {
|
||||||
|
httpc, err := controlHttpClient(s.Config().Global.Control.SockPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusFlags.Raw {
|
||||||
|
resp, err := httpc.Get("http://unix" + daemon.ControlJobEndpointStatus)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
fmt.Fprintf(os.Stderr, "Received error response:\n")
|
||||||
|
_, err := io.CopyN(os.Stderr, resp.Body, 4096)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errors.Errorf("exit")
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(os.Stdout, resp.Body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t := newTui()
|
||||||
|
t.lock.Lock()
|
||||||
|
t.err = errors.New("Got no report yet")
|
||||||
|
t.lock.Unlock()
|
||||||
|
|
||||||
|
err = termbox.Init()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer termbox.Close()
|
||||||
|
|
||||||
|
update := func() {
|
||||||
|
m := make(map[string]job.Status)
|
||||||
|
|
||||||
|
err2 := jsonRequestResponse(httpc, daemon.ControlJobEndpointStatus,
|
||||||
|
struct{}{},
|
||||||
|
&m,
|
||||||
|
)
|
||||||
|
|
||||||
|
t.lock.Lock()
|
||||||
|
t.err = err2
|
||||||
|
t.report = m
|
||||||
|
t.lock.Unlock()
|
||||||
|
t.draw()
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
go func() {
|
||||||
|
for range ticker.C {
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
termbox.HideCursor()
|
||||||
|
termbox.Clear(termbox.ColorDefault, termbox.ColorDefault)
|
||||||
|
|
||||||
|
loop:
|
||||||
|
for {
|
||||||
|
switch ev := termbox.PollEvent(); ev.Type {
|
||||||
|
case termbox.EventKey:
|
||||||
|
switch ev.Key {
|
||||||
|
case termbox.KeyEsc:
|
||||||
|
break loop
|
||||||
|
case termbox.KeyCtrlC:
|
||||||
|
break loop
|
||||||
|
}
|
||||||
|
case termbox.EventResize:
|
||||||
|
t.draw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) getReplicationProgresHistory(jobName string) *bytesProgressHistory {
|
||||||
|
p, ok := t.replicationProgress[jobName]
|
||||||
|
if !ok {
|
||||||
|
p = &bytesProgressHistory{}
|
||||||
|
t.replicationProgress[jobName] = p
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) draw() {
|
||||||
|
t.lock.Lock()
|
||||||
|
defer t.lock.Unlock()
|
||||||
|
|
||||||
|
termbox.Clear(termbox.ColorDefault, termbox.ColorDefault)
|
||||||
|
t.x = 0
|
||||||
|
t.y = 0
|
||||||
|
t.indent = 0
|
||||||
|
|
||||||
|
if t.err != nil {
|
||||||
|
t.write(t.err.Error())
|
||||||
|
} else {
|
||||||
|
//Iterate over map in alphabetical order
|
||||||
|
keys := make([]string, len(t.report))
|
||||||
|
i := 0
|
||||||
|
for k := range t.report {
|
||||||
|
keys[i] = k
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
for _, k := range keys {
|
||||||
|
v := t.report[k]
|
||||||
|
if len(k) == 0 || daemon.IsInternalJobName(k) { //Internal job
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.setIndent(0)
|
||||||
|
|
||||||
|
t.printf("Job: %s", k)
|
||||||
|
t.setIndent(1)
|
||||||
|
t.newline()
|
||||||
|
t.printf("Type: %s", v.Type)
|
||||||
|
t.setIndent(1)
|
||||||
|
t.newline()
|
||||||
|
|
||||||
|
if v.Type == job.TypePush || v.Type == job.TypePull {
|
||||||
|
activeStatus, ok := v.JobSpecific.(*job.ActiveSideStatus)
|
||||||
|
if !ok || activeStatus == nil {
|
||||||
|
t.printf("ActiveSideStatus is null")
|
||||||
|
t.newline()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t.printf("Replication:")
|
||||||
|
t.newline()
|
||||||
|
t.addIndent(1)
|
||||||
|
t.renderReplicationReport(activeStatus.Replication, t.getReplicationProgresHistory(k))
|
||||||
|
t.addIndent(-1)
|
||||||
|
|
||||||
|
t.printf("Pruning Sender:")
|
||||||
|
t.newline()
|
||||||
|
t.addIndent(1)
|
||||||
|
t.renderPrunerReport(activeStatus.PruningSender)
|
||||||
|
t.addIndent(-1)
|
||||||
|
|
||||||
|
t.printf("Pruning Receiver:")
|
||||||
|
t.newline()
|
||||||
|
t.addIndent(1)
|
||||||
|
t.renderPrunerReport(activeStatus.PruningReceiver)
|
||||||
|
t.addIndent(-1)
|
||||||
|
} else if v.Type == job.TypeSnap {
|
||||||
|
snapStatus, ok := v.JobSpecific.(*job.SnapJobStatus)
|
||||||
|
if !ok || snapStatus == nil {
|
||||||
|
t.printf("SnapJobStatus is null")
|
||||||
|
t.newline()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.printf("Pruning snapshots:")
|
||||||
|
t.newline()
|
||||||
|
t.addIndent(1)
|
||||||
|
t.renderPrunerReport(snapStatus.Pruning)
|
||||||
|
t.addIndent(-1)
|
||||||
|
} else {
|
||||||
|
t.printf("No status representation for job type '%s', dumping as YAML", v.Type)
|
||||||
|
t.newline()
|
||||||
|
asYaml, err := yaml.Marshal(v.JobSpecific)
|
||||||
|
if err != nil {
|
||||||
|
t.printf("Error marshaling status to YAML: %s", err)
|
||||||
|
t.newline()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.write(string(asYaml))
|
||||||
|
t.newline()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
termbox.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) renderReplicationReport(rep *report.Report, history *bytesProgressHistory) {
|
||||||
|
if rep == nil {
|
||||||
|
t.printf("...\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rep.WaitReconnectError != nil {
|
||||||
|
t.printfDrawIndentedAndWrappedIfMultiline("Connectivity: %s", rep.WaitReconnectError)
|
||||||
|
t.newline()
|
||||||
|
}
|
||||||
|
if !rep.WaitReconnectSince.IsZero() {
|
||||||
|
delta := time.Until(rep.WaitReconnectUntil).Round(time.Second)
|
||||||
|
if rep.WaitReconnectUntil.IsZero() || delta > 0 {
|
||||||
|
var until string
|
||||||
|
if rep.WaitReconnectUntil.IsZero() {
|
||||||
|
until = "waiting indefinitely"
|
||||||
|
} else {
|
||||||
|
until = fmt.Sprintf("hard fail in %s @ %s", delta, rep.WaitReconnectUntil)
|
||||||
|
}
|
||||||
|
t.printfDrawIndentedAndWrappedIfMultiline("Connectivity: reconnecting with exponential backoff (since %s) (%s)",
|
||||||
|
rep.WaitReconnectSince, until)
|
||||||
|
} else {
|
||||||
|
t.printfDrawIndentedAndWrappedIfMultiline("Connectivity: reconnects reached hard-fail timeout @ %s", rep.WaitReconnectUntil)
|
||||||
|
}
|
||||||
|
t.newline()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO visualize more than the latest attempt by folding all attempts into one
|
||||||
|
if len(rep.Attempts) == 0 {
|
||||||
|
t.printf("no attempts made yet")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
t.printf("Attempt #%d", len(rep.Attempts))
|
||||||
|
if len(rep.Attempts) > 1 {
|
||||||
|
t.printf(". Previous attempts failed with the follwing statuses:")
|
||||||
|
t.newline()
|
||||||
|
t.addIndent(1)
|
||||||
|
for i, a := range rep.Attempts[:len(rep.Attempts)-1] {
|
||||||
|
t.printfDrawIndentedAndWrappedIfMultiline("#%d: %s (failed at %s) (ran %s)", i+1, a.State, a.FinishAt, a.FinishAt.Sub(a.StartAt))
|
||||||
|
t.newline()
|
||||||
|
}
|
||||||
|
t.addIndent(-1)
|
||||||
|
} else {
|
||||||
|
t.newline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latest := rep.Attempts[len(rep.Attempts)-1]
|
||||||
|
sort.Slice(latest.Filesystems, func(i, j int) bool {
|
||||||
|
return latest.Filesystems[i].Info.Name < latest.Filesystems[j].Info.Name
|
||||||
|
})
|
||||||
|
|
||||||
|
t.printf("Status: %s", latest.State)
|
||||||
|
t.newline()
|
||||||
|
if latest.State == report.AttemptPlanningError {
|
||||||
|
t.printf("Problem: ")
|
||||||
|
t.printfDrawIndentedAndWrappedIfMultiline("%s", latest.PlanError)
|
||||||
|
t.newline()
|
||||||
|
} else if latest.State == report.AttemptFanOutError {
|
||||||
|
t.printf("Problem: one or more of the filesystems encountered errors")
|
||||||
|
t.newline()
|
||||||
|
}
|
||||||
|
|
||||||
|
if latest.State != report.AttemptPlanning && latest.State != report.AttemptPlanningError {
|
||||||
|
// Draw global progress bar
|
||||||
|
// Progress: [---------------]
|
||||||
|
expected, replicated := latest.BytesSum()
|
||||||
|
rate, changeCount := history.Update(replicated)
|
||||||
|
t.write("Progress: ")
|
||||||
|
t.drawBar(50, replicated, expected, changeCount)
|
||||||
|
t.write(fmt.Sprintf(" %s / %s @ %s/s", ByteCountBinary(replicated), ByteCountBinary(expected), ByteCountBinary(rate)))
|
||||||
|
t.newline()
|
||||||
|
|
||||||
|
var maxFSLen int
|
||||||
|
for _, fs := range latest.Filesystems {
|
||||||
|
if len(fs.Info.Name) > maxFSLen {
|
||||||
|
maxFSLen = len(fs.Info.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, fs := range latest.Filesystems {
|
||||||
|
t.printFilesystemStatus(fs, false, maxFSLen) // FIXME bring 'active' flag back
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) renderPrunerReport(r *pruner.Report) {
|
||||||
|
if r == nil {
|
||||||
|
t.printf("...\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := pruner.StateString(r.State)
|
||||||
|
if err != nil {
|
||||||
|
t.printf("Status: %q (parse error: %q)\n", r.State, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.printf("Status: %s", state)
|
||||||
|
t.newline()
|
||||||
|
|
||||||
|
if r.Error != "" {
|
||||||
|
t.printf("Error: %s\n", r.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type commonFS struct {
|
||||||
|
*pruner.FSReport
|
||||||
|
completed bool
|
||||||
|
}
|
||||||
|
all := make([]commonFS, 0, len(r.Pending)+len(r.Completed))
|
||||||
|
for i := range r.Pending {
|
||||||
|
all = append(all, commonFS{&r.Pending[i], false})
|
||||||
|
}
|
||||||
|
for i := range r.Completed {
|
||||||
|
all = append(all, commonFS{&r.Completed[i], true})
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case pruner.Plan:
|
||||||
|
fallthrough
|
||||||
|
case pruner.PlanErr:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(all) == 0 {
|
||||||
|
t.printf("nothing to do\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalDestroyCount, completedDestroyCount int
|
||||||
|
var maxFSname int
|
||||||
|
for _, fs := range all {
|
||||||
|
totalDestroyCount += len(fs.DestroyList)
|
||||||
|
if fs.completed {
|
||||||
|
completedDestroyCount += len(fs.DestroyList)
|
||||||
|
}
|
||||||
|
if maxFSname < len(fs.Filesystem) {
|
||||||
|
maxFSname = len(fs.Filesystem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// global progress bar
|
||||||
|
progress := int(math.Round(80 * float64(completedDestroyCount) / float64(totalDestroyCount)))
|
||||||
|
t.write("Progress: ")
|
||||||
|
t.write("[")
|
||||||
|
t.write(times("=", progress))
|
||||||
|
t.write(">")
|
||||||
|
t.write(times("-", 80-progress))
|
||||||
|
t.write("]")
|
||||||
|
t.printf(" %d/%d snapshots", completedDestroyCount, totalDestroyCount)
|
||||||
|
t.newline()
|
||||||
|
|
||||||
|
sort.SliceStable(all, func(i, j int) bool {
|
||||||
|
return strings.Compare(all[i].Filesystem, all[j].Filesystem) == -1
|
||||||
|
})
|
||||||
|
|
||||||
|
// Draw a table-like representation of 'all'
|
||||||
|
for _, fs := range all {
|
||||||
|
t.write(rightPad(fs.Filesystem, maxFSname, " "))
|
||||||
|
t.write(" ")
|
||||||
|
if !fs.SkipReason.NotSkipped() {
|
||||||
|
t.printf("skipped: %s\n", fs.SkipReason)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fs.LastError != "" {
|
||||||
|
if strings.ContainsAny(fs.LastError, "\r\n") {
|
||||||
|
t.printf("ERROR:")
|
||||||
|
t.printfDrawIndentedAndWrappedIfMultiline("%s\n", fs.LastError)
|
||||||
|
} else {
|
||||||
|
t.printfDrawIndentedAndWrappedIfMultiline("ERROR: %s\n", fs.LastError)
|
||||||
|
}
|
||||||
|
t.newline()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneRuleActionStr := fmt.Sprintf("(destroy %d of %d snapshots)",
|
||||||
|
len(fs.DestroyList), len(fs.SnapshotList))
|
||||||
|
|
||||||
|
if fs.completed {
|
||||||
|
t.printf("Completed %s\n", pruneRuleActionStr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t.write("Pending ") // whitespace is padding 10
|
||||||
|
if len(fs.DestroyList) == 1 {
|
||||||
|
t.write(fs.DestroyList[0].Name)
|
||||||
|
} else {
|
||||||
|
t.write(pruneRuleActionStr)
|
||||||
|
}
|
||||||
|
t.newline()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func times(str string, n int) (out string) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out += str
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func rightPad(str string, length int, pad string) string {
|
||||||
|
if len(str) > length {
|
||||||
|
return str[:length]
|
||||||
|
}
|
||||||
|
return str + times(pad, length-len(str))
|
||||||
|
}
|
||||||
|
|
||||||
|
var arrowPositions = `>\|/`
|
||||||
|
|
||||||
|
// changeCount = 0 indicates stall / no progresss
|
||||||
|
func (t *tui) drawBar(length int, bytes, totalBytes int64, changeCount int) {
|
||||||
|
var completedLength int
|
||||||
|
if totalBytes > 0 {
|
||||||
|
completedLength = int(int64(length) * bytes / totalBytes)
|
||||||
|
if completedLength > length {
|
||||||
|
completedLength = length
|
||||||
|
}
|
||||||
|
} else if totalBytes == bytes {
|
||||||
|
completedLength = length
|
||||||
|
}
|
||||||
|
|
||||||
|
t.write("[")
|
||||||
|
t.write(times("=", completedLength))
|
||||||
|
t.write(string(arrowPositions[changeCount%len(arrowPositions)]))
|
||||||
|
t.write(times("-", length-completedLength))
|
||||||
|
t.write("]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tui) printFilesystemStatus(rep *report.FilesystemReport, active bool, maxFS int) {
|
||||||
|
|
||||||
|
expected, replicated := rep.BytesSum()
|
||||||
|
status := fmt.Sprintf("%s (step %d/%d, %s/%s)",
|
||||||
|
strings.ToUpper(string(rep.State)),
|
||||||
|
rep.CurrentStep, len(rep.Steps),
|
||||||
|
ByteCountBinary(replicated), ByteCountBinary(expected),
|
||||||
|
)
|
||||||
|
|
||||||
|
activeIndicator := " "
|
||||||
|
if active {
|
||||||
|
activeIndicator = "*"
|
||||||
|
}
|
||||||
|
t.printf("%s %s %s ",
|
||||||
|
activeIndicator,
|
||||||
|
rightPad(rep.Info.Name, maxFS, " "),
|
||||||
|
status)
|
||||||
|
|
||||||
|
next := ""
|
||||||
|
if err := rep.Error(); err != nil {
|
||||||
|
next = err.Err
|
||||||
|
} else if rep.State != report.FilesystemDone {
|
||||||
|
if nextStep := rep.NextStep(); nextStep != nil {
|
||||||
|
if nextStep.IsIncremental() {
|
||||||
|
next = fmt.Sprintf("next: %s => %s", nextStep.Info.From, nextStep.Info.To)
|
||||||
|
} else {
|
||||||
|
next = fmt.Sprintf("next: %s (full)", nextStep.Info.To)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next = "" // individual FSes may still be in planning state
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
t.printfDrawIndentedAndWrappedIfMultiline("%s", next)
|
||||||
|
|
||||||
|
t.newline()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ByteCountBinary(b int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if b < unit {
|
||||||
|
return fmt.Sprintf("%d B", b)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := b / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/problame/go-netssh"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/cli"
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
var StdinserverCmd = &cli.Subcommand{
|
||||||
|
Use: "stdinserver CLIENT_IDENTITY",
|
||||||
|
Short: "stdinserver transport mode (started from authorized_keys file as forced command)",
|
||||||
|
Run: func(subcommand *cli.Subcommand, args []string) error {
|
||||||
|
return runStdinserver(subcommand.Config(), args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStdinserver(config *config.Config, args []string) error {
|
||||||
|
|
||||||
|
// NOTE: the netssh proxying protocol requires exiting with non-zero status if anything goes wrong
|
||||||
|
defer os.Exit(1)
|
||||||
|
|
||||||
|
log := log.New(os.Stderr, "", log.LUTC|log.Ldate|log.Ltime)
|
||||||
|
|
||||||
|
if len(args) != 1 || args[0] == "" {
|
||||||
|
err := errors.New("must specify client_identity as positional argument")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
identity := args[0]
|
||||||
|
unixaddr := path.Join(config.Global.Serve.StdinServer.SockDir, identity)
|
||||||
|
|
||||||
|
log.Printf("proxying client identity '%s' to zrepl daemon '%s'", identity, unixaddr)
|
||||||
|
|
||||||
|
ctx := netssh.ContextWithLog(context.TODO(), log)
|
||||||
|
|
||||||
|
err := netssh.Proxy(ctx, unixaddr)
|
||||||
|
if err == nil {
|
||||||
|
log.Print("proxying finished successfully, exiting with status 0")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
log.Printf("error proxying: %s", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/cli"
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon/filters"
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TestCmd = &cli.Subcommand{
|
||||||
|
Use: "test",
|
||||||
|
SetupSubcommands: func() []*cli.Subcommand {
|
||||||
|
return []*cli.Subcommand{testFilter, testPlaceholder}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var testFilterArgs struct {
|
||||||
|
job string
|
||||||
|
all bool
|
||||||
|
input string
|
||||||
|
}
|
||||||
|
|
||||||
|
var testFilter = &cli.Subcommand{
|
||||||
|
Use: "filesystems --job JOB [--all | --input INPUT]",
|
||||||
|
Short: "test filesystems filter specified in push or source job",
|
||||||
|
SetupFlags: func(f *pflag.FlagSet) {
|
||||||
|
f.StringVar(&testFilterArgs.job, "job", "", "the name of the push or source job")
|
||||||
|
f.StringVar(&testFilterArgs.input, "input", "", "a filesystem name to test against the job's filters")
|
||||||
|
f.BoolVar(&testFilterArgs.all, "all", false, "test all local filesystems")
|
||||||
|
},
|
||||||
|
Run: runTestFilterCmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTestFilterCmd(subcommand *cli.Subcommand, args []string) error {
|
||||||
|
|
||||||
|
if testFilterArgs.job == "" {
|
||||||
|
return fmt.Errorf("must specify --job flag")
|
||||||
|
}
|
||||||
|
if !(testFilterArgs.all != (testFilterArgs.input != "")) { // xor
|
||||||
|
return fmt.Errorf("must set one: --all or --input")
|
||||||
|
}
|
||||||
|
|
||||||
|
conf := subcommand.Config()
|
||||||
|
|
||||||
|
var confFilter config.FilesystemsFilter
|
||||||
|
job, err := conf.Job(testFilterArgs.job)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch j := job.Ret.(type) {
|
||||||
|
case *config.SourceJob:
|
||||||
|
confFilter = j.Filesystems
|
||||||
|
case *config.PushJob:
|
||||||
|
confFilter = j.Filesystems
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("job type %T does not have filesystems filter", j)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := filters.DatasetMapFilterFromConfig(confFilter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("filter invalid: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fsnames []string
|
||||||
|
if testFilterArgs.input != "" {
|
||||||
|
fsnames = []string{testFilterArgs.input}
|
||||||
|
} else {
|
||||||
|
out, err := zfs.ZFSList([]string{"name"})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not list ZFS filesystems: %s", err)
|
||||||
|
}
|
||||||
|
for _, row := range out {
|
||||||
|
|
||||||
|
fsnames = append(fsnames, row[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fspaths := make([]*zfs.DatasetPath, len(fsnames))
|
||||||
|
for i, fsname := range fsnames {
|
||||||
|
path, err := zfs.NewDatasetPath(fsname)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fspaths[i] = path
|
||||||
|
}
|
||||||
|
|
||||||
|
hadFilterErr := false
|
||||||
|
for _, in := range fspaths {
|
||||||
|
var res string
|
||||||
|
var errStr string
|
||||||
|
pass, err := f.Filter(in)
|
||||||
|
if err != nil {
|
||||||
|
res = "ERROR"
|
||||||
|
errStr = err.Error()
|
||||||
|
hadFilterErr = true
|
||||||
|
} else if pass {
|
||||||
|
res = "ACCEPT"
|
||||||
|
} else {
|
||||||
|
res = "REJECT"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\t%s\t%s\n", res, in.ToString(), errStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hadFilterErr {
|
||||||
|
return fmt.Errorf("filter errors occurred")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var testPlaceholderArgs struct {
|
||||||
|
ds string
|
||||||
|
all bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var testPlaceholder = &cli.Subcommand{
|
||||||
|
Use: "placeholder [--all | --dataset DATASET]",
|
||||||
|
Short: fmt.Sprintf("list received placeholder filesystems (zfs property %q)", zfs.PlaceholderPropertyName),
|
||||||
|
Example: `
|
||||||
|
placeholder --all
|
||||||
|
placeholder --dataset path/to/sink/clientident/fs`,
|
||||||
|
NoRequireConfig: true,
|
||||||
|
SetupFlags: func(f *pflag.FlagSet) {
|
||||||
|
f.StringVar(&testPlaceholderArgs.ds, "dataset", "", "dataset path (not required to exist)")
|
||||||
|
f.BoolVar(&testPlaceholderArgs.all, "all", false, "list tab-separated placeholder status of all filesystems")
|
||||||
|
},
|
||||||
|
Run: runTestPlaceholder,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTestPlaceholder(subcommand *cli.Subcommand, args []string) error {
|
||||||
|
|
||||||
|
var checkDPs []*zfs.DatasetPath
|
||||||
|
|
||||||
|
// all actions first
|
||||||
|
if testPlaceholderArgs.all {
|
||||||
|
out, err := zfs.ZFSList([]string{"name"})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "could not list ZFS filesystems")
|
||||||
|
}
|
||||||
|
for _, row := range out {
|
||||||
|
dp, err := zfs.NewDatasetPath(row[0])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
checkDPs = append(checkDPs, dp)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dp, err := zfs.NewDatasetPath(testPlaceholderArgs.ds)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dp.Empty() {
|
||||||
|
return fmt.Errorf("must specify --dataset DATASET or --all")
|
||||||
|
}
|
||||||
|
checkDPs = append(checkDPs, dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("IS_PLACEHOLDER\tDATASET\tzrepl:placeholder\n")
|
||||||
|
for _, dp := range checkDPs {
|
||||||
|
ph, err := zfs.ZFSGetFilesystemPlaceholderState(dp)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "cannot get placeholder state")
|
||||||
|
}
|
||||||
|
if !ph.FSExists {
|
||||||
|
panic("placeholder state inconsistent: filesystem " + ph.FS + " must exist in this context")
|
||||||
|
}
|
||||||
|
is := "yes"
|
||||||
|
if !ph.IsPlaceholder {
|
||||||
|
is = "no"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\t%s\t%s\n", is, dp.ToString(), ph.RawLocalPropertyValue)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/cli"
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon"
|
||||||
|
"github.com/zrepl/zrepl/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
var versionArgs struct {
|
||||||
|
Show string
|
||||||
|
Config *config.Config
|
||||||
|
ConfigErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
var VersionCmd = &cli.Subcommand{
|
||||||
|
Use: "version",
|
||||||
|
Short: "print version of zrepl binary and running daemon",
|
||||||
|
NoRequireConfig: true,
|
||||||
|
SetupFlags: func(f *pflag.FlagSet) {
|
||||||
|
f.StringVar(&versionArgs.Show, "show", "", "version info to show (client|daemon)")
|
||||||
|
},
|
||||||
|
Run: func(subcommand *cli.Subcommand, args []string) error {
|
||||||
|
versionArgs.Config = subcommand.Config()
|
||||||
|
versionArgs.ConfigErr = subcommand.ConfigParsingError()
|
||||||
|
return runVersionCmd()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func runVersionCmd() error {
|
||||||
|
args := versionArgs
|
||||||
|
|
||||||
|
if args.Show != "daemon" && args.Show != "client" && args.Show != "" {
|
||||||
|
return fmt.Errorf("show flag must be 'client' or 'server' or be left empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientVersion, daemonVersion *version.ZreplVersionInformation
|
||||||
|
if args.Show == "client" || args.Show == "" {
|
||||||
|
clientVersion = version.NewZreplVersionInformation()
|
||||||
|
fmt.Printf("client: %s\n", clientVersion.String())
|
||||||
|
}
|
||||||
|
if args.Show == "daemon" || args.Show == "" {
|
||||||
|
|
||||||
|
if args.ConfigErr != nil {
|
||||||
|
return fmt.Errorf("config parsing error: %s", args.ConfigErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpc, err := controlHttpClient(args.Config.Global.Control.SockPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("server: error: %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info version.ZreplVersionInformation
|
||||||
|
err = jsonRequestResponse(httpc, daemon.ControlJobEndpointVersion, "", &info)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("server: error: %s\n", err)
|
||||||
|
}
|
||||||
|
daemonVersion = &info
|
||||||
|
fmt.Printf("server: %s\n", daemonVersion.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Show == "" {
|
||||||
|
if clientVersion.Version != daemonVersion.Version {
|
||||||
|
fmt.Fprintf(os.Stderr, "WARNING: client version != daemon version, restart zrepl daemon\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/zrepl/zrepl/jobrun"
|
|
||||||
"github.com/zrepl/zrepl/zfs"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var AutosnapCmd = &cobra.Command{
|
|
||||||
Use: "autosnap",
|
|
||||||
Short: "perform automatic snapshotting",
|
|
||||||
Run: cmdAutosnap,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
RootCmd.AddCommand(AutosnapCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdAutosnap(cmd *cobra.Command, args []string) {
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
runner.Start()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if len(args) < 1 {
|
|
||||||
log.Printf("must specify exactly one job as positional argument")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
snap, ok := conf.Autosnaps[args[0]]
|
|
||||||
if !ok {
|
|
||||||
log.Printf("could not find autosnap job: %s", args[0])
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
job := jobrun.Job{
|
|
||||||
Name: snap.JobName,
|
|
||||||
RepeatStrategy: snap.Interval,
|
|
||||||
RunFunc: func(log jobrun.Logger) error {
|
|
||||||
log.Printf("doing autosnap: %v", snap)
|
|
||||||
ctx := AutosnapContext{snap}
|
|
||||||
return doAutosnap(ctx, log)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
runner.AddJob(job)
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
type AutosnapContext struct {
|
|
||||||
Autosnap *Autosnap
|
|
||||||
}
|
|
||||||
|
|
||||||
func doAutosnap(ctx AutosnapContext, log Logger) (err error) {
|
|
||||||
|
|
||||||
snap := ctx.Autosnap
|
|
||||||
|
|
||||||
filesystems, err := zfs.ZFSListMapping(snap.DatasetFilter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot filter datasets: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
suffix := time.Now().In(time.UTC).Format("20060102_150405_000")
|
|
||||||
snapname := fmt.Sprintf("%s%s", snap.Prefix, suffix)
|
|
||||||
|
|
||||||
hadError := false
|
|
||||||
|
|
||||||
for _, fs := range filesystems { // optimization: use recursive snapshots / channel programs here
|
|
||||||
log.Printf("snapshotting filesystem %s@%s", fs, snapname)
|
|
||||||
err := zfs.ZFSSnapshot(fs, snapname, false)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error snapshotting %s: %s", fs, err)
|
|
||||||
hadError = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hadError {
|
|
||||||
err = fmt.Errorf("errors occurred during autosnap, check logs for details")
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
-730
@@ -1,730 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"github.com/jinzhu/copier"
|
|
||||||
"github.com/mitchellh/mapstructure"
|
|
||||||
"github.com/zrepl/zrepl/jobrun"
|
|
||||||
"github.com/zrepl/zrepl/rpc"
|
|
||||||
"github.com/zrepl/zrepl/sshbytestream"
|
|
||||||
. "github.com/zrepl/zrepl/util"
|
|
||||||
"github.com/zrepl/zrepl/zfs"
|
|
||||||
yaml "gopkg.in/yaml.v2"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
JobSectionPush string = "push"
|
|
||||||
JobSectionPull string = "pull"
|
|
||||||
JobSectionPrune string = "prune"
|
|
||||||
JobSectionAutosnap string = "autosnap"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Remote struct {
|
|
||||||
Name string
|
|
||||||
Transport Transport
|
|
||||||
}
|
|
||||||
|
|
||||||
type Transport interface {
|
|
||||||
Connect(rpcLog Logger) (rpc.RPCRequester, error)
|
|
||||||
}
|
|
||||||
type LocalTransport struct {
|
|
||||||
Handler rpc.RPCHandler
|
|
||||||
}
|
|
||||||
type SSHTransport struct {
|
|
||||||
Host string
|
|
||||||
User string
|
|
||||||
Port uint16
|
|
||||||
IdentityFile string `mapstructure:"identity_file"`
|
|
||||||
TransportOpenCommand []string `mapstructure:"transport_open_command"`
|
|
||||||
SSHCommand string `mapstructure:"ssh_command"`
|
|
||||||
Options []string
|
|
||||||
ConnLogReadFile string `mapstructure:"connlog_read_file"`
|
|
||||||
ConnLogWriteFile string `mapstructure:"connlog_write_file"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Push struct {
|
|
||||||
JobName string // for use with jobrun package
|
|
||||||
To *Remote
|
|
||||||
Filter zfs.DatasetFilter
|
|
||||||
InitialReplPolicy rpc.InitialReplPolicy
|
|
||||||
RepeatStrategy jobrun.RepeatStrategy
|
|
||||||
}
|
|
||||||
type Pull struct {
|
|
||||||
JobName string // for use with jobrun package
|
|
||||||
From *Remote
|
|
||||||
Mapping DatasetMapFilter
|
|
||||||
InitialReplPolicy rpc.InitialReplPolicy
|
|
||||||
RepeatStrategy jobrun.RepeatStrategy
|
|
||||||
}
|
|
||||||
|
|
||||||
type Prune struct {
|
|
||||||
JobName string // for use with jobrun package
|
|
||||||
DatasetFilter zfs.DatasetFilter
|
|
||||||
SnapshotFilter zfs.FilesystemVersionFilter
|
|
||||||
RetentionPolicy *RetentionGrid // TODO abstract interface to support future policies?
|
|
||||||
}
|
|
||||||
|
|
||||||
type Autosnap struct {
|
|
||||||
JobName string // for use with jobrun package
|
|
||||||
Prefix string
|
|
||||||
Interval jobrun.RepeatStrategy
|
|
||||||
DatasetFilter zfs.DatasetFilter
|
|
||||||
}
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Remotes map[string]*Remote
|
|
||||||
Pushs map[string]*Push // job name -> job
|
|
||||||
Pulls map[string]*Pull // job name -> job
|
|
||||||
Sinks map[string]DatasetMapFilter // client identity -> mapping
|
|
||||||
PullACLs map[string]DatasetMapFilter // client identity -> filter
|
|
||||||
Prunes map[string]*Prune // job name -> job
|
|
||||||
Autosnaps map[string]*Autosnap // job name -> job
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseConfig(path string) (config Config, err error) {
|
|
||||||
|
|
||||||
c := make(map[string]interface{}, 0)
|
|
||||||
|
|
||||||
var bytes []byte
|
|
||||||
|
|
||||||
if bytes, err = ioutil.ReadFile(path); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = yaml.Unmarshal(bytes, &c); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseMain(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseMain(root map[string]interface{}) (c Config, err error) {
|
|
||||||
if c.Remotes, err = parseRemotes(root["remotes"]); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteLookup := func(name string) (remote *Remote, err error) {
|
|
||||||
remote = c.Remotes[name]
|
|
||||||
if remote == nil {
|
|
||||||
err = fmt.Errorf("remote '%s' not defined", name)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Pushs, err = parsePushs(root["pushs"], remoteLookup); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.Pulls, err = parsePulls(root["pulls"], remoteLookup); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.Sinks, err = parseSinks(root["sinks"]); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.PullACLs, err = parsePullACLs(root["pull_acls"]); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.Prunes, err = parsePrunes(root["prune"]); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.Autosnaps, err = parseAutosnaps(root["autosnap"]); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func fullJobName(section, name string) (full string, err error) {
|
|
||||||
if len(name) < 1 {
|
|
||||||
err = fmt.Errorf("job name not set")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
full = fmt.Sprintf("%s.%s", section, name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseRemotes(v interface{}) (remotes map[string]*Remote, err error) {
|
|
||||||
|
|
||||||
asMap := make(map[string]struct {
|
|
||||||
Transport map[string]interface{}
|
|
||||||
}, 0)
|
|
||||||
if err = mapstructure.Decode(v, &asMap); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
remotes = make(map[string]*Remote, len(asMap))
|
|
||||||
for name, p := range asMap {
|
|
||||||
|
|
||||||
if name == rpc.LOCAL_TRANSPORT_IDENTITY {
|
|
||||||
err = errors.New(fmt.Sprintf("remote name '%s' reserved for local pulls", rpc.LOCAL_TRANSPORT_IDENTITY))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var transport Transport
|
|
||||||
if transport, err = parseTransport(p.Transport); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
remotes[name] = &Remote{
|
|
||||||
Name: name,
|
|
||||||
Transport: transport,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTransport(it map[string]interface{}) (t Transport, err error) {
|
|
||||||
|
|
||||||
if len(it) != 1 {
|
|
||||||
err = errors.New("ambiguous transport type")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, val := range it {
|
|
||||||
switch key {
|
|
||||||
case "ssh":
|
|
||||||
t := SSHTransport{}
|
|
||||||
if err = mapstructure.Decode(val, &t); err != nil {
|
|
||||||
err = errors.New(fmt.Sprintf("could not parse ssh transport: %s", err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
default:
|
|
||||||
return nil, errors.New(fmt.Sprintf("unknown transport type '%s'\n", key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return // unreachable
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
type remoteLookup func(name string) (*Remote, error)
|
|
||||||
|
|
||||||
func parsePushs(v interface{}, rl remoteLookup) (p map[string]*Push, err error) {
|
|
||||||
|
|
||||||
asMap := make(map[string]struct {
|
|
||||||
To string
|
|
||||||
Filter map[string]string
|
|
||||||
InitialReplPolicy string
|
|
||||||
Repeat map[string]string
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
if err = mapstructure.Decode(v, &asMap); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p = make(map[string]*Push, len(asMap))
|
|
||||||
|
|
||||||
for name, e := range asMap {
|
|
||||||
|
|
||||||
var toRemote *Remote
|
|
||||||
if toRemote, err = rl(e.To); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
push := &Push{
|
|
||||||
To: toRemote,
|
|
||||||
}
|
|
||||||
|
|
||||||
if push.JobName, err = fullJobName(JobSectionPush, name); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if push.Filter, err = parseDatasetMapFilter(e.Filter, true); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if push.InitialReplPolicy, err = parseInitialReplPolicy(e.InitialReplPolicy, rpc.DEFAULT_INITIAL_REPL_POLICY); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if push.RepeatStrategy, err = parseRepeatStrategy(e.Repeat); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p[name] = push
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePulls(v interface{}, rl remoteLookup) (p map[string]*Pull, err error) {
|
|
||||||
|
|
||||||
asMap := make(map[string]struct {
|
|
||||||
From string
|
|
||||||
Mapping map[string]string
|
|
||||||
InitialReplPolicy string
|
|
||||||
Repeat map[string]string
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
if err = mapstructure.Decode(v, &asMap); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p = make(map[string]*Pull, len(asMap))
|
|
||||||
|
|
||||||
for name, e := range asMap {
|
|
||||||
|
|
||||||
if len(e.From) < 1 {
|
|
||||||
err = fmt.Errorf("source not set ('from' attribute is empty)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var fromRemote *Remote
|
|
||||||
|
|
||||||
if e.From == rpc.LOCAL_TRANSPORT_IDENTITY {
|
|
||||||
fromRemote = &Remote{
|
|
||||||
Name: rpc.LOCAL_TRANSPORT_IDENTITY,
|
|
||||||
Transport: LocalTransport{},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if fromRemote, err = rl(e.From); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pull := &Pull{
|
|
||||||
From: fromRemote,
|
|
||||||
}
|
|
||||||
if pull.JobName, err = fullJobName(JobSectionPull, name); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if pull.Mapping, err = parseDatasetMapFilter(e.Mapping, false); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if pull.InitialReplPolicy, err = parseInitialReplPolicy(e.InitialReplPolicy, rpc.DEFAULT_INITIAL_REPL_POLICY); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if pull.RepeatStrategy, err = parseRepeatStrategy(e.Repeat); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p[name] = pull
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseInitialReplPolicy(v interface{}, defaultPolicy rpc.InitialReplPolicy) (p rpc.InitialReplPolicy, err error) {
|
|
||||||
s, ok := v.(string)
|
|
||||||
if !ok {
|
|
||||||
goto err
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case s == "":
|
|
||||||
p = defaultPolicy
|
|
||||||
case s == "most_recent":
|
|
||||||
p = rpc.InitialReplPolicyMostRecent
|
|
||||||
case s == "all":
|
|
||||||
p = rpc.InitialReplPolicyAll
|
|
||||||
default:
|
|
||||||
goto err
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
err:
|
|
||||||
err = errors.New(fmt.Sprintf("expected InitialReplPolicy, got %#v", v))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseRepeatStrategy(r map[string]string) (s jobrun.RepeatStrategy, err error) {
|
|
||||||
|
|
||||||
if r == nil {
|
|
||||||
return jobrun.NoRepeatStrategy{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if repeatStr, ok := r["interval"]; ok {
|
|
||||||
d, err := parseDuration(repeatStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s = &jobrun.PeriodicRepeatStrategy{d}
|
|
||||||
return s, err
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("attribute 'interval' not found but required in repeat specification")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func expectList(v interface{}) (asList []interface{}, err error) {
|
|
||||||
var ok bool
|
|
||||||
if asList, ok = v.([]interface{}); !ok {
|
|
||||||
err = errors.New("expected list")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSinks(v interface{}) (m map[string]DatasetMapFilter, err error) {
|
|
||||||
var asMap map[string]map[string]interface{}
|
|
||||||
if err = mapstructure.Decode(v, &asMap); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
m = make(map[string]DatasetMapFilter, len(asMap))
|
|
||||||
|
|
||||||
for identity, entry := range asMap {
|
|
||||||
parseSink := func() (mapping DatasetMapFilter, err error) {
|
|
||||||
mappingMap, ok := entry["mapping"]
|
|
||||||
if !ok {
|
|
||||||
err = fmt.Errorf("no mapping specified")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mapping, err = parseDatasetMapFilter(mappingMap, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mapping, sinkErr := parseSink()
|
|
||||||
if sinkErr != nil {
|
|
||||||
err = fmt.Errorf("cannot parse sink for identity '%s': %s", identity, sinkErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m[identity] = mapping
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePullACLs(v interface{}) (m map[string]DatasetMapFilter, err error) {
|
|
||||||
var asMap map[string]map[string]interface{}
|
|
||||||
if err = mapstructure.Decode(v, &asMap); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
m = make(map[string]DatasetMapFilter, len(asMap))
|
|
||||||
|
|
||||||
for identity, entry := range asMap {
|
|
||||||
parsePullACL := func() (filter DatasetMapFilter, err error) {
|
|
||||||
filterMap, ok := entry["filter"]
|
|
||||||
if !ok {
|
|
||||||
err = fmt.Errorf("no filter specified")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filter, err = parseDatasetMapFilter(filterMap, true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filter, filterErr := parsePullACL()
|
|
||||||
if filterErr != nil {
|
|
||||||
err = fmt.Errorf("cannot parse pull-ACL for identity '%s': %s", identity, filterErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m[identity] = filter
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDatasetMapFilter(mi interface{}, filterOnly bool) (f DatasetMapFilter, err error) {
|
|
||||||
|
|
||||||
var m map[string]string
|
|
||||||
if err = mapstructure.Decode(mi, &m); err != nil {
|
|
||||||
err = fmt.Errorf("maps / filters must be specified as map[string]string: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
f = NewDatasetMapFilter(len(m), filterOnly)
|
|
||||||
for pathPattern, mapping := range m {
|
|
||||||
if err = f.Add(pathPattern, mapping); err != nil {
|
|
||||||
err = fmt.Errorf("invalid mapping entry ['%s':'%s']: %s", pathPattern, mapping, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t SSHTransport) Connect(rpcLog Logger) (r rpc.RPCRequester, err error) {
|
|
||||||
var stream io.ReadWriteCloser
|
|
||||||
var rpcTransport sshbytestream.SSHTransport
|
|
||||||
if err = copier.Copy(&rpcTransport, t); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if stream, err = sshbytestream.Outgoing(rpcTransport); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stream, err = NewReadWriteCloserLogger(stream, t.ConnLogReadFile, t.ConnLogWriteFile)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return rpc.ConnectByteStreamRPC(stream, rpcLog)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t LocalTransport) Connect(rpcLog Logger) (r rpc.RPCRequester, err error) {
|
|
||||||
if t.Handler == nil {
|
|
||||||
panic("local transport with uninitialized handler")
|
|
||||||
}
|
|
||||||
return rpc.ConnectLocalRPC(t.Handler), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *LocalTransport) SetHandler(handler rpc.RPCHandler) {
|
|
||||||
t.Handler = handler
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePrunes(m interface{}) (rets map[string]*Prune, err error) {
|
|
||||||
|
|
||||||
asList := make(map[string]map[string]interface{}, 0)
|
|
||||||
if err = mapstructure.Decode(m, &asList); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rets = make(map[string]*Prune, len(asList))
|
|
||||||
|
|
||||||
for name, e := range asList {
|
|
||||||
var prune *Prune
|
|
||||||
if prune, err = parsePrune(e, name); err != nil {
|
|
||||||
err = fmt.Errorf("cannot parse prune job %s: %s", name, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rets[name] = prune
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePrune(e map[string]interface{}, name string) (prune *Prune, err error) {
|
|
||||||
|
|
||||||
// Only support grid policy for now
|
|
||||||
policyName, ok := e["policy"]
|
|
||||||
if !ok || policyName != "grid" {
|
|
||||||
err = fmt.Errorf("prune job with unimplemented policy '%s'", policyName)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var i struct {
|
|
||||||
Grid string
|
|
||||||
DatasetFilter map[string]string `mapstructure:"dataset_filter"`
|
|
||||||
SnapshotFilter map[string]string `mapstructure:"snapshot_filter"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = mapstructure.Decode(e, &i); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
prune = &Prune{}
|
|
||||||
|
|
||||||
if prune.JobName, err = fullJobName(JobSectionPrune, name); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse grid policy
|
|
||||||
intervals, err := parseRetentionGridIntervalsString(i.Grid)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("cannot parse retention grid: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Assert intervals are of increasing length (not necessarily required, but indicates config mistake)
|
|
||||||
lastDuration := time.Duration(0)
|
|
||||||
for i := range intervals {
|
|
||||||
if intervals[i].Length < lastDuration {
|
|
||||||
err = fmt.Errorf("retention grid interval length must be monotonically increasing:"+
|
|
||||||
"interval %d is shorter than %d", i+1, i)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
lastDuration = intervals[i].Length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prune.RetentionPolicy = NewRetentionGrid(intervals)
|
|
||||||
|
|
||||||
// Parse filters
|
|
||||||
if prune.DatasetFilter, err = parseDatasetMapFilter(i.DatasetFilter, true); err != nil {
|
|
||||||
err = fmt.Errorf("cannot parse dataset filter: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if prune.SnapshotFilter, err = parseSnapshotFilter(i.SnapshotFilter); err != nil {
|
|
||||||
err = fmt.Errorf("cannot parse snapshot filter: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var durationStringRegex *regexp.Regexp = regexp.MustCompile(`^\s*(\d+)\s*(s|m|h|d|w)\s*$`)
|
|
||||||
|
|
||||||
func parseDuration(e string) (d time.Duration, err error) {
|
|
||||||
comps := durationStringRegex.FindStringSubmatch(e)
|
|
||||||
if len(comps) != 3 {
|
|
||||||
err = fmt.Errorf("does not match regex: %s %#v", e, comps)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
durationFactor, err := strconv.ParseInt(comps[1], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var durationUnit time.Duration
|
|
||||||
switch comps[2] {
|
|
||||||
case "s":
|
|
||||||
durationUnit = time.Second
|
|
||||||
case "m":
|
|
||||||
durationUnit = time.Minute
|
|
||||||
case "h":
|
|
||||||
durationUnit = time.Hour
|
|
||||||
case "d":
|
|
||||||
durationUnit = 24 * time.Hour
|
|
||||||
case "w":
|
|
||||||
durationUnit = 24 * 7 * time.Hour
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("contains unknown time unit '%s'", comps[2])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
d = time.Duration(durationFactor) * durationUnit
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
var retentionStringIntervalRegex *regexp.Regexp = regexp.MustCompile(`^\s*(\d+)\s*x\s*([^\(]+)\s*(\((.*)\))?\s*$`)
|
|
||||||
|
|
||||||
func parseRetentionGridIntervalString(e string) (intervals []RetentionInterval, err error) {
|
|
||||||
|
|
||||||
comps := retentionStringIntervalRegex.FindStringSubmatch(e)
|
|
||||||
if comps == nil {
|
|
||||||
err = fmt.Errorf("retention string does not match expected format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
times, err := strconv.Atoi(comps[1])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
} else if times <= 0 {
|
|
||||||
return nil, fmt.Errorf("contains factor <= 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
duration, err := parseDuration(comps[2])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
keepCount := 1
|
|
||||||
if comps[3] != "" {
|
|
||||||
// Decompose key=value, comma separated
|
|
||||||
// For now, only keep_count is supported
|
|
||||||
re := regexp.MustCompile(`^\s*keep=(.+)\s*$`)
|
|
||||||
res := re.FindStringSubmatch(comps[4])
|
|
||||||
if res == nil || len(res) != 2 {
|
|
||||||
err = fmt.Errorf("interval parameter contains unknown parameters")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if res[1] == "all" {
|
|
||||||
keepCount = RetentionGridKeepCountAll
|
|
||||||
} else {
|
|
||||||
keepCount, err = strconv.Atoi(res[1])
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("cannot parse keep_count value")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
intervals = make([]RetentionInterval, times)
|
|
||||||
for i := range intervals {
|
|
||||||
intervals[i] = RetentionInterval{
|
|
||||||
Length: duration,
|
|
||||||
KeepCount: keepCount,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseRetentionGridIntervalsString(s string) (intervals []RetentionInterval, err error) {
|
|
||||||
|
|
||||||
ges := strings.Split(s, "|")
|
|
||||||
intervals = make([]RetentionInterval, 0, 7*len(ges))
|
|
||||||
|
|
||||||
for intervalIdx, e := range ges {
|
|
||||||
parsed, err := parseRetentionGridIntervalString(e)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot parse interval %d of %d: %s: %s", intervalIdx+1, len(ges), err, strings.TrimSpace(e))
|
|
||||||
}
|
|
||||||
intervals = append(intervals, parsed...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type prefixSnapshotFilter struct {
|
|
||||||
prefix string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f prefixSnapshotFilter) Filter(fsv zfs.FilesystemVersion) (accept bool, err error) {
|
|
||||||
return fsv.Type == zfs.Snapshot && strings.HasPrefix(fsv.Name, f.prefix), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSnapshotFilter(fm map[string]string) (snapFilter zfs.FilesystemVersionFilter, err error) {
|
|
||||||
prefix, ok := fm["prefix"]
|
|
||||||
if !ok {
|
|
||||||
err = fmt.Errorf("unsupported snapshot filter")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
snapFilter = prefixSnapshotFilter{prefix}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAutosnaps(m interface{}) (snaps map[string]*Autosnap, err error) {
|
|
||||||
|
|
||||||
asMap := make(map[string]interface{}, 0)
|
|
||||||
if err = mapstructure.Decode(m, &asMap); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
snaps = make(map[string]*Autosnap, len(asMap))
|
|
||||||
|
|
||||||
for name, e := range asMap {
|
|
||||||
var snap *Autosnap
|
|
||||||
if snap, err = parseAutosnap(e, name); err != nil {
|
|
||||||
err = fmt.Errorf("cannot parse autonsap job %s: %s", name, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
snaps[name] = snap
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAutosnap(m interface{}, name string) (a *Autosnap, err error) {
|
|
||||||
|
|
||||||
var i struct {
|
|
||||||
Prefix string
|
|
||||||
Interval string
|
|
||||||
DatasetFilter map[string]string `mapstructure:"dataset_filter"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = mapstructure.Decode(m, &i); err != nil {
|
|
||||||
err = fmt.Errorf("structure unfit: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a = &Autosnap{}
|
|
||||||
|
|
||||||
if a.JobName, err = fullJobName(JobSectionAutosnap, name); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(i.Prefix) < 1 {
|
|
||||||
err = fmt.Errorf("prefix must not be empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.Prefix = i.Prefix
|
|
||||||
|
|
||||||
var interval time.Duration
|
|
||||||
if interval, err = parseDuration(i.Interval); err != nil {
|
|
||||||
err = fmt.Errorf("cannot parse interval: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.Interval = &jobrun.PeriodicRepeatStrategy{interval}
|
|
||||||
|
|
||||||
if len(i.DatasetFilter) == 0 {
|
|
||||||
err = fmt.Errorf("dataset_filter not specified")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if a.DatasetFilter, err = parseDatasetMapFilter(i.DatasetFilter, true); err != nil {
|
|
||||||
err = fmt.Errorf("cannot parse dataset filter: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/zrepl/zrepl/zfs"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DatasetMapFilter struct {
|
|
||||||
entries []datasetMapFilterEntry
|
|
||||||
|
|
||||||
// if set, only valid filter entries can be added using Add()
|
|
||||||
// and Map() will always return an error
|
|
||||||
filterOnly bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type datasetMapFilterEntry struct {
|
|
||||||
path *zfs.DatasetPath
|
|
||||||
// the mapping. since this datastructure acts as both mapping and filter
|
|
||||||
// we have to convert it to the desired rep dynamically
|
|
||||||
mapping string
|
|
||||||
subtreeMatch bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var NoMatchError error = errors.New("no match found in mapping")
|
|
||||||
|
|
||||||
func NewDatasetMapFilter(capacity int, filterOnly bool) DatasetMapFilter {
|
|
||||||
return DatasetMapFilter{
|
|
||||||
entries: make([]datasetMapFilterEntry, 0, capacity),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *DatasetMapFilter) Add(pathPattern, mapping string) (err error) {
|
|
||||||
|
|
||||||
if m.filterOnly {
|
|
||||||
if _, err = parseDatasetFilterResult(mapping); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assert path glob adheres to spec
|
|
||||||
const SUBTREE_PATTERN string = "<"
|
|
||||||
patternCount := strings.Count(pathPattern, SUBTREE_PATTERN)
|
|
||||||
switch {
|
|
||||||
case patternCount > 1:
|
|
||||||
case patternCount == 1 && !strings.HasSuffix(pathPattern, SUBTREE_PATTERN):
|
|
||||||
err = fmt.Errorf("pattern invalid: only one '<' at end of string allowed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pathStr := strings.TrimSuffix(pathPattern, SUBTREE_PATTERN)
|
|
||||||
path, err := zfs.NewDatasetPath(pathStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("pattern is not a dataset path: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := datasetMapFilterEntry{
|
|
||||||
path: path,
|
|
||||||
mapping: mapping,
|
|
||||||
subtreeMatch: patternCount > 0,
|
|
||||||
}
|
|
||||||
m.entries = append(m.entries, entry)
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// find the most specific prefix mapping we have
|
|
||||||
//
|
|
||||||
// longer prefix wins over shorter prefix, direct wins over glob
|
|
||||||
func (m DatasetMapFilter) mostSpecificPrefixMapping(path *zfs.DatasetPath) (idx int, found bool) {
|
|
||||||
lcp, lcp_entry_idx := -1, -1
|
|
||||||
direct_idx := -1
|
|
||||||
for e := range m.entries {
|
|
||||||
entry := m.entries[e]
|
|
||||||
ep := m.entries[e].path
|
|
||||||
lep := ep.Length()
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case !entry.subtreeMatch && ep.Equal(path):
|
|
||||||
direct_idx = e
|
|
||||||
continue
|
|
||||||
case entry.subtreeMatch && path.HasPrefix(ep) && lep > lcp:
|
|
||||||
lcp = lep
|
|
||||||
lcp_entry_idx = e
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lcp_entry_idx >= 0 || direct_idx >= 0 {
|
|
||||||
found = true
|
|
||||||
switch {
|
|
||||||
case direct_idx >= 0:
|
|
||||||
idx = direct_idx
|
|
||||||
case lcp_entry_idx >= 0:
|
|
||||||
idx = lcp_entry_idx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m DatasetMapFilter) Map(source *zfs.DatasetPath) (target *zfs.DatasetPath, err error) {
|
|
||||||
|
|
||||||
if m.filterOnly {
|
|
||||||
err = fmt.Errorf("using a filter for mapping simply does not work")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mi, hasMapping := m.mostSpecificPrefixMapping(source)
|
|
||||||
if !hasMapping {
|
|
||||||
err = NoMatchError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
me := m.entries[mi]
|
|
||||||
|
|
||||||
target, err = zfs.NewDatasetPath(me.mapping)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("mapping target is not a dataset path: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if m.entries[mi].subtreeMatch {
|
|
||||||
// strip common prefix
|
|
||||||
extendComps := source.Copy()
|
|
||||||
if me.path.Empty() {
|
|
||||||
// special case: trying to map the root => strip first component
|
|
||||||
extendComps.TrimNPrefixComps(1)
|
|
||||||
} else {
|
|
||||||
extendComps.TrimPrefix(me.path)
|
|
||||||
}
|
|
||||||
target.Extend(extendComps)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m DatasetMapFilter) Filter(p *zfs.DatasetPath) (pass bool, err error) {
|
|
||||||
mi, hasMapping := m.mostSpecificPrefixMapping(p)
|
|
||||||
if !hasMapping {
|
|
||||||
pass = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
me := m.entries[mi]
|
|
||||||
pass, err = parseDatasetFilterResult(me.mapping)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse a dataset filter result
|
|
||||||
func parseDatasetFilterResult(result string) (pass bool, err error) {
|
|
||||||
l := strings.ToLower(result)
|
|
||||||
switch strings.ToLower(l) {
|
|
||||||
case "ok":
|
|
||||||
pass = true
|
|
||||||
return
|
|
||||||
case "omit":
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("'%s' is not a valid filter result", result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/zrepl/zrepl/util"
|
|
||||||
"github.com/zrepl/zrepl/zfs"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSampleConfigFileIsParsedWithoutErrors(t *testing.T) {
|
|
||||||
_, err := ParseConfig("./sampleconf/zrepl.yml")
|
|
||||||
assert.Nil(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseRetentionGridStringParsing(t *testing.T) {
|
|
||||||
|
|
||||||
intervals, err := parseRetentionGridIntervalsString("2x10m(keep=2) | 1x1h | 3x1w")
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Len(t, intervals, 6)
|
|
||||||
proto := util.RetentionInterval{
|
|
||||||
KeepCount: 2,
|
|
||||||
Length: 10 * time.Minute,
|
|
||||||
}
|
|
||||||
assert.EqualValues(t, proto, intervals[0])
|
|
||||||
assert.EqualValues(t, proto, intervals[1])
|
|
||||||
|
|
||||||
proto.KeepCount = 1
|
|
||||||
proto.Length = 1 * time.Hour
|
|
||||||
assert.EqualValues(t, proto, intervals[2])
|
|
||||||
|
|
||||||
proto.Length = 7 * 24 * time.Hour
|
|
||||||
assert.EqualValues(t, proto, intervals[3])
|
|
||||||
assert.EqualValues(t, proto, intervals[4])
|
|
||||||
assert.EqualValues(t, proto, intervals[5])
|
|
||||||
|
|
||||||
intervals, err = parseRetentionGridIntervalsString("|")
|
|
||||||
assert.Error(t, err)
|
|
||||||
intervals, err = parseRetentionGridIntervalsString("2x10m")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
intervals, err = parseRetentionGridIntervalsString("1x10m(keep=all)")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Len(t, intervals, 1)
|
|
||||||
assert.EqualValues(t, util.RetentionGridKeepCountAll, intervals[0].KeepCount)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDatasetMapFilter(t *testing.T) {
|
|
||||||
|
|
||||||
expectMapping := func(m map[string]string, from, to string) {
|
|
||||||
dmf, err := parseDatasetMapFilter(m, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("expect test map to be valid: %s", err)
|
|
||||||
t.FailNow()
|
|
||||||
}
|
|
||||||
fromPath, err := zfs.NewDatasetPath(from)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("expect test from path to be valid: %s", err)
|
|
||||||
t.FailNow()
|
|
||||||
}
|
|
||||||
toPath, err := zfs.NewDatasetPath(to)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("expect test to path to be valid: %s", err)
|
|
||||||
t.FailNow()
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := dmf.Map(fromPath)
|
|
||||||
t.Logf("%s => %s", fromPath.ToString(), res.ToString())
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.True(t, res.Equal(toPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
expectFilter := func(m map[string]string, path string, pass bool) {
|
|
||||||
dmf, err := parseDatasetMapFilter(m, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("expect test filter to be valid: %s", err)
|
|
||||||
t.FailNow()
|
|
||||||
}
|
|
||||||
p, err := zfs.NewDatasetPath(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("expect test path to be valid: %s", err)
|
|
||||||
t.FailNow()
|
|
||||||
}
|
|
||||||
res, err := dmf.Filter(p)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, pass, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
map1 := map[string]string{
|
|
||||||
"a/b/c<": "root1",
|
|
||||||
"a/b<": "root2",
|
|
||||||
"<": "root3/b/c",
|
|
||||||
"q<": "root4/1/2",
|
|
||||||
}
|
|
||||||
|
|
||||||
expectMapping(map1, "a/b/c", "root1")
|
|
||||||
expectMapping(map1, "a/b/c/d", "root1/d")
|
|
||||||
expectMapping(map1, "a/b/e", "root2/e")
|
|
||||||
expectMapping(map1, "a/b", "root2")
|
|
||||||
expectMapping(map1, "x", "root3/b/c")
|
|
||||||
expectMapping(map1, "x/y", "root3/b/c/y")
|
|
||||||
expectMapping(map1, "q", "root4/1/2")
|
|
||||||
expectMapping(map1, "q/r", "root4/1/2/r")
|
|
||||||
|
|
||||||
filter1 := map[string]string{
|
|
||||||
"<": "omit",
|
|
||||||
"a<": "ok",
|
|
||||||
"a/b<": "omit",
|
|
||||||
}
|
|
||||||
|
|
||||||
expectFilter(filter1, "b", false)
|
|
||||||
expectFilter(filter1, "a", true)
|
|
||||||
expectFilter(filter1, "a/d", true)
|
|
||||||
expectFilter(filter1, "a/b", false)
|
|
||||||
expectFilter(filter1, "a/b/c", false)
|
|
||||||
|
|
||||||
filter2 := map[string]string{}
|
|
||||||
expectFilter(filter2, "foo", false) // default to omit
|
|
||||||
|
|
||||||
}
|
|
||||||
-177
@@ -1,177 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/zrepl/zrepl/rpc"
|
|
||||||
"github.com/zrepl/zrepl/zfs"
|
|
||||||
"io"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DatasetMapping interface {
|
|
||||||
Map(source *zfs.DatasetPath) (target *zfs.DatasetPath, err error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Handler struct {
|
|
||||||
Logger Logger
|
|
||||||
PullACL zfs.DatasetFilter
|
|
||||||
SinkMappingFunc func(clientIdentity string) (mapping DatasetMapping, err error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h Handler) HandleFilesystemRequest(r rpc.FilesystemRequest) (roots []*zfs.DatasetPath, err error) {
|
|
||||||
|
|
||||||
h.Logger.Printf("handling fsr: %#v", r)
|
|
||||||
|
|
||||||
h.Logger.Printf("using PullACL: %#v", h.PullACL)
|
|
||||||
|
|
||||||
if roots, err = zfs.ZFSListMapping(h.PullACL); err != nil {
|
|
||||||
h.Logger.Printf("handle fsr err: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Logger.Printf("returning: %#v", roots)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h Handler) HandleFilesystemVersionsRequest(r rpc.FilesystemVersionsRequest) (versions []zfs.FilesystemVersion, err error) {
|
|
||||||
|
|
||||||
h.Logger.Printf("handling filesystem versions request: %#v", r)
|
|
||||||
|
|
||||||
// allowed to request that?
|
|
||||||
if h.pullACLCheck(r.Filesystem); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// find our versions
|
|
||||||
if versions, err = zfs.ZFSListFilesystemVersions(r.Filesystem, nil); err != nil {
|
|
||||||
h.Logger.Printf("our versions error: %#v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Logger.Printf("our versions: %#v\n", versions)
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h Handler) HandleInitialTransferRequest(r rpc.InitialTransferRequest) (stream io.Reader, err error) {
|
|
||||||
|
|
||||||
h.Logger.Printf("handling initial transfer request: %#v", r)
|
|
||||||
if err = h.pullACLCheck(r.Filesystem); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Logger.Printf("invoking zfs send")
|
|
||||||
|
|
||||||
if stream, err = zfs.ZFSSend(r.Filesystem, &r.FilesystemVersion, nil); err != nil {
|
|
||||||
h.Logger.Printf("error sending filesystem: %#v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h Handler) HandleIncrementalTransferRequest(r rpc.IncrementalTransferRequest) (stream io.Reader, err error) {
|
|
||||||
|
|
||||||
h.Logger.Printf("handling incremental transfer request: %#v", r)
|
|
||||||
if err = h.pullACLCheck(r.Filesystem); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Logger.Printf("invoking zfs send")
|
|
||||||
|
|
||||||
if stream, err = zfs.ZFSSend(r.Filesystem, &r.From, &r.To); err != nil {
|
|
||||||
h.Logger.Printf("error sending filesystem: %#v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h Handler) HandleResumeTransferRequest(r rpc.ResumeTransferRequest) (stream io.Reader, err error) {
|
|
||||||
// decode receive_resume_token: zfs send -nvP <token>
|
|
||||||
// A) use exit code to determine if could send (exit != 0 means could not send)
|
|
||||||
// B) check ACL if the filesystem in toname field (it is a snapshot so strip its snapshot name first)
|
|
||||||
// is allowed for the given client
|
|
||||||
/*
|
|
||||||
|
|
||||||
# zfs send -nvt will print nvlist contents on ZoL and FreeBSD, should be good enough
|
|
||||||
# will print regardless of whether it can send or not -> good for us
|
|
||||||
# need clean way to extract that...
|
|
||||||
# expect 'resume token contents:\nnvlist version: 0\n'
|
|
||||||
# parse everything after that is tab-indented as key value pairs separated by = sign
|
|
||||||
# => return dict
|
|
||||||
|
|
||||||
zfs send -nv -t 1-c6491acbe-c8-789c636064000310a500c4ec50360710e72765a526973030b0419460caa7a515a79630c001489e0d493ea9b224b5182451885d7f497e7a69660a0343f79b1b9a8a2b3db65b20c97382e5f312735319188a4bf28b12d353f5931293b34b0b8af5ab8a520b72f4d3f2f31d8a53f35220660300c1091dbe
|
|
||||||
resume token contents:
|
|
||||||
nvlist version: 0
|
|
||||||
object = 0x6
|
|
||||||
offset = 0x0
|
|
||||||
bytes = 0x7100
|
|
||||||
toguid = 0xb748a92129d8ec8b
|
|
||||||
toname = storage/backups/zrepl/foo@send
|
|
||||||
cannot resume send: 'storage/backups/zrepl/foo@send' used in the initial send no longer exists
|
|
||||||
|
|
||||||
zfs send -nvt 1-ebbbbea7e-f0-789c636064000310a501c49c50360710a715e5e7a69766a6304041f79b1b9a8a2b3db62b00d9ec48eaf293b252934b181858a0ea30e4d3d28a534b18e00024cf86249f5459925a0ca44fc861d75f920f71c59c5fdf6f7b3eea32b24092e704cbe725e6a632301497e41725a6a7ea27252667971614eb5715a516e4e8a7e5e73b14a7e6a51881cd06005a222749
|
|
||||||
resume token contents:
|
|
||||||
nvlist version: 0
|
|
||||||
fromguid = 0xb748a92129d8ec8b #NOTE the fromguid field which is only in this one, so don't hardcode
|
|
||||||
object = 0x4
|
|
||||||
offset = 0x0
|
|
||||||
bytes = 0x1ec8
|
|
||||||
toguid = 0x328ae249dbf7fa9c
|
|
||||||
toname = storage/backups/zrepl/foo@send2
|
|
||||||
send from storage/backups/zrepl/foo@send to storage/backups/zrepl/foo@send2 estimated size is 1.02M
|
|
||||||
|
|
||||||
*/
|
|
||||||
panic("not implemented")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h Handler) HandlePullMeRequest(r rpc.PullMeRequest, clientIdentity string, client rpc.RPCRequester) (err error) {
|
|
||||||
|
|
||||||
// Check if we have a sink for this request
|
|
||||||
// Use that mapping to do what happens in doPull
|
|
||||||
|
|
||||||
h.Logger.Printf("handling PullMeRequest: %#v", r)
|
|
||||||
|
|
||||||
var sinkMapping DatasetMapping
|
|
||||||
sinkMapping, err = h.SinkMappingFunc(clientIdentity)
|
|
||||||
if err != nil {
|
|
||||||
h.Logger.Printf("no sink mapping for client identity '%s', denying PullMeRequest", clientIdentity)
|
|
||||||
err = fmt.Errorf("no sink for client identity '%s'", clientIdentity)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Logger.Printf("doing pull...")
|
|
||||||
|
|
||||||
err = doPull(PullContext{
|
|
||||||
Remote: client,
|
|
||||||
Log: h.Logger,
|
|
||||||
Mapping: sinkMapping,
|
|
||||||
InitialReplPolicy: r.InitialReplPolicy,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
h.Logger.Printf("PullMeRequest failed with error: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Logger.Printf("finished handling PullMeRequest: %#v", r)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h Handler) pullACLCheck(p *zfs.DatasetPath) (err error) {
|
|
||||||
var allowed bool
|
|
||||||
allowed, err = h.PullACL.Filter(p)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("error evaluating ACL: %s", err)
|
|
||||||
h.Logger.Printf(err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !allowed {
|
|
||||||
err = fmt.Errorf("ACL prohibits access to %s", p.ToString())
|
|
||||||
h.Logger.Printf(err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
-104
@@ -1,104 +0,0 @@
|
|||||||
// zrepl replicates ZFS filesystems & volumes between pools
|
|
||||||
//
|
|
||||||
// Code Organization
|
|
||||||
//
|
|
||||||
// The cmd package uses github.com/spf13/cobra for its CLI.
|
|
||||||
//
|
|
||||||
// It combines the other packages in the zrepl project to implement zrepl functionality.
|
|
||||||
//
|
|
||||||
// Each subcommand's code is in the corresponding *.go file.
|
|
||||||
// All other *.go files contain code shared by the subcommands.
|
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/zrepl/zrepl/jobrun"
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
"io"
|
|
||||||
golog "log"
|
|
||||||
"net/http"
|
|
||||||
_ "net/http/pprof"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Logger interface {
|
|
||||||
Printf(format string, v ...interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// global state / facilities
|
|
||||||
var (
|
|
||||||
conf Config
|
|
||||||
runner *jobrun.JobRunner
|
|
||||||
logFlags int = golog.LUTC | golog.Ldate | golog.Ltime
|
|
||||||
logOut io.Writer
|
|
||||||
log Logger
|
|
||||||
)
|
|
||||||
|
|
||||||
var RootCmd = &cobra.Command{
|
|
||||||
Use: "zrepl",
|
|
||||||
Short: "ZFS dataset replication",
|
|
||||||
Long: `Replicate ZFS filesystems & volumes between pools:
|
|
||||||
|
|
||||||
- push & pull mode
|
|
||||||
- automatic snapshot creation & pruning
|
|
||||||
- local / over the network
|
|
||||||
- ACLs instead of blank SSH access`,
|
|
||||||
}
|
|
||||||
|
|
||||||
var rootArgs struct {
|
|
||||||
configFile string
|
|
||||||
stderrFile string
|
|
||||||
httpPprof string
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cobra.OnInitialize(initConfig)
|
|
||||||
RootCmd.PersistentFlags().StringVar(&rootArgs.configFile, "config", "", "config file path")
|
|
||||||
RootCmd.PersistentFlags().StringVar(&rootArgs.stderrFile, "stderrFile", "-", "redirect stderr to given path")
|
|
||||||
RootCmd.PersistentFlags().StringVar(&rootArgs.httpPprof, "debug.pprof.http", "", "run pprof http server on given port")
|
|
||||||
}
|
|
||||||
|
|
||||||
func initConfig() {
|
|
||||||
|
|
||||||
// Logging & stderr redirection
|
|
||||||
if rootArgs.stderrFile != "-" {
|
|
||||||
file, err := os.OpenFile(rootArgs.stderrFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = unix.Dup2(int(file.Fd()), int(os.Stderr.Fd())); err != nil {
|
|
||||||
file.WriteString(fmt.Sprintf("error redirecting stderr file %s: %s\n", rootArgs.stderrFile, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logOut = file
|
|
||||||
} else {
|
|
||||||
logOut = os.Stderr
|
|
||||||
}
|
|
||||||
|
|
||||||
log = golog.New(logOut, "", logFlags)
|
|
||||||
|
|
||||||
// CPU profiling
|
|
||||||
if rootArgs.httpPprof != "" {
|
|
||||||
go func() {
|
|
||||||
http.ListenAndServe(rootArgs.httpPprof, nil)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config
|
|
||||||
if rootArgs.configFile == "" {
|
|
||||||
log.Printf("config file not set")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
if conf, err = ParseConfig(rootArgs.configFile); err != nil {
|
|
||||||
log.Printf("error parsing config: %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
jobrunLogger := golog.New(os.Stderr, "jobrun ", logFlags)
|
|
||||||
runner = jobrun.NewJobRunner(jobrunLogger)
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
-139
@@ -1,139 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/zrepl/zrepl/util"
|
|
||||||
"github.com/zrepl/zrepl/zfs"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var pruneArgs struct {
|
|
||||||
job string
|
|
||||||
dryRun bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var PruneCmd = &cobra.Command{
|
|
||||||
Use: "prune",
|
|
||||||
Short: "perform pruning",
|
|
||||||
Run: cmdPrune,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
PruneCmd.Flags().StringVar(&pruneArgs.job, "job", "", "job to run")
|
|
||||||
PruneCmd.Flags().BoolVarP(&pruneArgs.dryRun, "dryrun", "n", false, "dry run")
|
|
||||||
RootCmd.AddCommand(PruneCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdPrune(cmd *cobra.Command, args []string) {
|
|
||||||
|
|
||||||
if len(args) < 1 {
|
|
||||||
log.Printf("must specify exactly one job as positional argument")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
job, ok := conf.Prunes[args[0]]
|
|
||||||
if !ok {
|
|
||||||
log.Printf("could not find prune job: %s", args[0])
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Beginning prune job:\n%s", job)
|
|
||||||
ctx := PruneContext{job, time.Now(), pruneArgs.dryRun}
|
|
||||||
err := doPrune(ctx, log)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Prune job failed with error: %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
type PruneContext struct {
|
|
||||||
Prune *Prune
|
|
||||||
Now time.Time
|
|
||||||
DryRun bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type retentionGridAdaptor struct {
|
|
||||||
zfs.FilesystemVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a retentionGridAdaptor) Date() time.Time {
|
|
||||||
return a.Creation
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a retentionGridAdaptor) LessThan(b util.RetentionGridEntry) bool {
|
|
||||||
return a.CreateTXG < b.(retentionGridAdaptor).CreateTXG
|
|
||||||
}
|
|
||||||
|
|
||||||
func doPrune(ctx PruneContext, log Logger) error {
|
|
||||||
|
|
||||||
if ctx.DryRun {
|
|
||||||
log.Printf("doing dry run")
|
|
||||||
}
|
|
||||||
|
|
||||||
prune := ctx.Prune
|
|
||||||
|
|
||||||
// ZFSListSnapsFiltered --> todo can extend fsfilter or need new? Have already something per fs
|
|
||||||
// Dedicated snapshot object? Adaptor object to FilesystemVersion?
|
|
||||||
|
|
||||||
filesystems, err := zfs.ZFSListMapping(prune.DatasetFilter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error applying filesystem filter: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fs := range filesystems {
|
|
||||||
|
|
||||||
fsversions, err := zfs.ZFSListFilesystemVersions(fs, prune.SnapshotFilter)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error listing filesytem versions of %s: %s", fs, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fsversions) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
adaptors := make([]util.RetentionGridEntry, len(fsversions))
|
|
||||||
for fsv := range fsversions {
|
|
||||||
adaptors[fsv] = retentionGridAdaptor{fsversions[fsv]}
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.SliceStable(adaptors, func(i, j int) bool {
|
|
||||||
return adaptors[i].LessThan(adaptors[j])
|
|
||||||
})
|
|
||||||
ctx.Now = adaptors[len(adaptors)-1].Date()
|
|
||||||
|
|
||||||
describe := func(a retentionGridAdaptor) string {
|
|
||||||
timeSince := a.Creation.Sub(ctx.Now)
|
|
||||||
const day time.Duration = 24 * time.Hour
|
|
||||||
days := timeSince / day
|
|
||||||
remainder := timeSince % day
|
|
||||||
return fmt.Sprintf("%s@%dd%s from now", a.ToAbsPath(fs), days, remainder)
|
|
||||||
}
|
|
||||||
|
|
||||||
keep, remove := prune.RetentionPolicy.FitEntries(ctx.Now, adaptors)
|
|
||||||
for _, a := range remove {
|
|
||||||
r := a.(retentionGridAdaptor)
|
|
||||||
log.Printf("remove %s", describe(r))
|
|
||||||
// do echo what we'll do and exec zfs destroy if not dry run
|
|
||||||
// special handling for EBUSY (zfs hold)
|
|
||||||
// error handling for clones? just echo to cli, skip over, and exit with non-zero status code (we're idempotent)
|
|
||||||
if !ctx.DryRun {
|
|
||||||
err := zfs.ZFSDestroy(r.ToAbsPath(fs))
|
|
||||||
if err != nil {
|
|
||||||
// handle
|
|
||||||
log.Printf("error: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, a := range keep {
|
|
||||||
r := a.(retentionGridAdaptor)
|
|
||||||
log.Printf("would keep %s", describe(r))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,587 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/zrepl/zrepl/jobrun"
|
|
||||||
"github.com/zrepl/zrepl/rpc"
|
|
||||||
"github.com/zrepl/zrepl/util"
|
|
||||||
"github.com/zrepl/zrepl/zfs"
|
|
||||||
)
|
|
||||||
|
|
||||||
var runArgs struct {
|
|
||||||
job string
|
|
||||||
once bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var RunCmd = &cobra.Command{
|
|
||||||
Use: "run",
|
|
||||||
Short: "run push & pull replication",
|
|
||||||
Run: cmdRun,
|
|
||||||
}
|
|
||||||
|
|
||||||
var PushCmd = &cobra.Command{
|
|
||||||
Use: "push",
|
|
||||||
Short: "run push job (first positional argument)",
|
|
||||||
Run: cmdPush,
|
|
||||||
}
|
|
||||||
|
|
||||||
var PullCmd = &cobra.Command{
|
|
||||||
Use: "pull",
|
|
||||||
Short: "run pull job (first positional argument)",
|
|
||||||
Run: cmdPull,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
RootCmd.AddCommand(RunCmd)
|
|
||||||
RunCmd.Flags().BoolVar(&runArgs.once, "once", false, "run jobs only once, regardless of configured repeat behavior")
|
|
||||||
RunCmd.Flags().StringVar(&runArgs.job, "job", "", "run only the given job")
|
|
||||||
|
|
||||||
RootCmd.AddCommand(PushCmd)
|
|
||||||
RootCmd.AddCommand(PullCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdPush(cmd *cobra.Command, args []string) {
|
|
||||||
|
|
||||||
if len(args) != 1 {
|
|
||||||
log.Printf("must specify exactly one job as positional argument")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
job, ok := conf.Pushs[args[0]]
|
|
||||||
if !ok {
|
|
||||||
log.Printf("could not find push job %s", args[0])
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := jobPush(job, log); err != nil {
|
|
||||||
log.Printf("error doing push: %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdPull(cmd *cobra.Command, args []string) {
|
|
||||||
|
|
||||||
if len(args) != 1 {
|
|
||||||
log.Printf("must specify exactly one job as positional argument")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
job, ok := conf.Pulls[args[0]]
|
|
||||||
if !ok {
|
|
||||||
log.Printf("could not find pull job %s", args[0])
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := jobPull(job, log); err != nil {
|
|
||||||
log.Printf("error doing pull: %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdRun(cmd *cobra.Command, args []string) {
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
runner.Start()
|
|
||||||
}()
|
|
||||||
|
|
||||||
jobs := make([]jobrun.Job, len(conf.Pulls)+len(conf.Pushs))
|
|
||||||
i := 0
|
|
||||||
for _, pull := range conf.Pulls {
|
|
||||||
jobs[i] = jobrun.Job{
|
|
||||||
Name: fmt.Sprintf("pull.%d", i),
|
|
||||||
RepeatStrategy: pull.RepeatStrategy,
|
|
||||||
RunFunc: func(log jobrun.Logger) error {
|
|
||||||
log.Printf("doing pull: %v", pull)
|
|
||||||
return jobPull(pull, log)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
for _, push := range conf.Pushs {
|
|
||||||
jobs[i] = jobrun.Job{
|
|
||||||
Name: fmt.Sprintf("push.%d", i),
|
|
||||||
RepeatStrategy: push.RepeatStrategy,
|
|
||||||
RunFunc: func(log jobrun.Logger) error {
|
|
||||||
log.Printf("doing push: %v", push)
|
|
||||||
return jobPush(push, log)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, j := range jobs {
|
|
||||||
if runArgs.once {
|
|
||||||
j.RepeatStrategy = jobrun.NoRepeatStrategy{}
|
|
||||||
}
|
|
||||||
if runArgs.job != "" {
|
|
||||||
if runArgs.job == j.Name {
|
|
||||||
runner.AddJob(j)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
runner.AddJob(j)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case job := <-runner.NotificationChan():
|
|
||||||
log.Printf("job %s reported error: %v\n", job.Name, job.LastError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
type localPullACL struct{}
|
|
||||||
|
|
||||||
func (a localPullACL) Filter(p *zfs.DatasetPath) (pass bool, err error) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func jobPull(pull *Pull, log jobrun.Logger) (err error) {
|
|
||||||
|
|
||||||
if lt, ok := pull.From.Transport.(LocalTransport); ok {
|
|
||||||
|
|
||||||
lt.SetHandler(Handler{
|
|
||||||
Logger: log,
|
|
||||||
// Allow access to any dataset since we control what mapping
|
|
||||||
// is passed to the pull routine.
|
|
||||||
// All local datasets will be passed to its Map() function,
|
|
||||||
// but only those for which a mapping exists will actually be pulled.
|
|
||||||
// We can pay this small performance penalty for now.
|
|
||||||
PullACL: localPullACL{},
|
|
||||||
})
|
|
||||||
pull.From.Transport = lt
|
|
||||||
log.Printf("fixing up local transport: %#v", pull.From.Transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
var remote rpc.RPCRequester
|
|
||||||
|
|
||||||
if remote, err = pull.From.Transport.Connect(log); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer closeRPCWithTimeout(log, remote, time.Second*10, "")
|
|
||||||
|
|
||||||
return doPull(PullContext{remote, log, pull.Mapping, pull.InitialReplPolicy})
|
|
||||||
}
|
|
||||||
|
|
||||||
func jobPush(push *Push, log jobrun.Logger) (err error) {
|
|
||||||
|
|
||||||
if _, ok := push.To.Transport.(LocalTransport); ok {
|
|
||||||
panic("no support for local pushs")
|
|
||||||
}
|
|
||||||
|
|
||||||
var remote rpc.RPCRequester
|
|
||||||
if remote, err = push.To.Transport.Connect(log); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer closeRPCWithTimeout(log, remote, time.Second*10, "")
|
|
||||||
|
|
||||||
log.Printf("building handler for PullMeRequest")
|
|
||||||
handler := Handler{
|
|
||||||
Logger: log,
|
|
||||||
PullACL: push.Filter,
|
|
||||||
SinkMappingFunc: nil, // no need for that in the handler for PullMe
|
|
||||||
}
|
|
||||||
log.Printf("handler: %#v", handler)
|
|
||||||
|
|
||||||
r := rpc.PullMeRequest{
|
|
||||||
InitialReplPolicy: push.InitialReplPolicy,
|
|
||||||
}
|
|
||||||
log.Printf("doing PullMeRequest: %#v", r)
|
|
||||||
|
|
||||||
if err = remote.PullMeRequest(r, handler); err != nil {
|
|
||||||
log.Printf("PullMeRequest failed: %s", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("push job finished")
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func closeRPCWithTimeout(log Logger, remote rpc.RPCRequester, timeout time.Duration, goodbye string) {
|
|
||||||
log.Printf("closing rpc connection")
|
|
||||||
|
|
||||||
ch := make(chan error)
|
|
||||||
go func() {
|
|
||||||
ch <- remote.CloseRequest(rpc.CloseRequest{goodbye})
|
|
||||||
close(ch)
|
|
||||||
}()
|
|
||||||
|
|
||||||
var err error
|
|
||||||
select {
|
|
||||||
case <-time.After(timeout):
|
|
||||||
err = fmt.Errorf("timeout exceeded (%s)", timeout)
|
|
||||||
case closeRequestErr := <-ch:
|
|
||||||
err = closeRequestErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error closing connection: %s", err)
|
|
||||||
err = remote.ForceClose()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error force-closing connection: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type PullContext struct {
|
|
||||||
Remote rpc.RPCRequester
|
|
||||||
Log Logger
|
|
||||||
Mapping DatasetMapping
|
|
||||||
InitialReplPolicy rpc.InitialReplPolicy
|
|
||||||
}
|
|
||||||
|
|
||||||
func doPull(pull PullContext) (err error) {
|
|
||||||
|
|
||||||
remote := pull.Remote
|
|
||||||
log := pull.Log
|
|
||||||
|
|
||||||
log.Printf("requesting remote filesystem list")
|
|
||||||
fsr := rpc.FilesystemRequest{}
|
|
||||||
var remoteFilesystems []*zfs.DatasetPath
|
|
||||||
if remoteFilesystems, err = remote.FilesystemRequest(fsr); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("map remote filesystems to local paths and determine order for per-filesystem sync")
|
|
||||||
type RemoteLocalMapping struct {
|
|
||||||
Remote *zfs.DatasetPath
|
|
||||||
Local *zfs.DatasetPath
|
|
||||||
}
|
|
||||||
replMapping := make(map[string]RemoteLocalMapping, len(remoteFilesystems))
|
|
||||||
localTraversal := zfs.NewDatasetPathForest()
|
|
||||||
for fs := range remoteFilesystems {
|
|
||||||
var err error
|
|
||||||
var localFs *zfs.DatasetPath
|
|
||||||
localFs, err = pull.Mapping.Map(remoteFilesystems[fs])
|
|
||||||
if err != nil {
|
|
||||||
if err != NoMatchError {
|
|
||||||
err := fmt.Errorf("error mapping %s: %s", remoteFilesystems[fs], err)
|
|
||||||
log.Printf("%s", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("%s => %s", remoteFilesystems[fs].ToString(), localFs.ToString())
|
|
||||||
m := RemoteLocalMapping{remoteFilesystems[fs], localFs}
|
|
||||||
replMapping[m.Local.ToString()] = m
|
|
||||||
localTraversal.Add(m.Local)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("build cache for already present local filesystem state")
|
|
||||||
localFilesystemState, err := zfs.ZFSListFilesystemState()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error requesting local filesystem state: %s", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("start per-filesystem sync")
|
|
||||||
localTraversal.WalkTopDown(func(v zfs.DatasetPathVisit) bool {
|
|
||||||
|
|
||||||
if v.FilledIn {
|
|
||||||
if _, exists := localFilesystemState[v.Path.ToString()]; exists {
|
|
||||||
// No need to verify if this is a placeholder or not. It is sufficient
|
|
||||||
// to know we can add child filesystems to it
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
log.Printf("creating placeholder filesystem %s", v.Path.ToString())
|
|
||||||
err = zfs.ZFSCreatePlaceholderFilesystem(v.Path)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("aborting, cannot create placeholder filesystem %s: %s", v.Path, err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
m, ok := replMapping[v.Path.ToString()]
|
|
||||||
if !ok {
|
|
||||||
panic("internal inconsistency: replMapping should contain mapping for any path that was not filled in by WalkTopDown()")
|
|
||||||
}
|
|
||||||
|
|
||||||
log := func(format string, args ...interface{}) {
|
|
||||||
log.Printf("[%s => %s]: %s", m.Remote.ToString(), m.Local.ToString(), fmt.Sprintf(format, args...))
|
|
||||||
}
|
|
||||||
|
|
||||||
log("examing local filesystem state")
|
|
||||||
localState, localExists := localFilesystemState[m.Local.ToString()]
|
|
||||||
var versions []zfs.FilesystemVersion
|
|
||||||
switch {
|
|
||||||
case !localExists:
|
|
||||||
log("local filesystem does not exist")
|
|
||||||
case localState.Placeholder:
|
|
||||||
log("local filesystem is marked as placeholder")
|
|
||||||
case localState.ResumeToken != "":
|
|
||||||
|
|
||||||
log("local filesystem has receive_resume_token")
|
|
||||||
if false { // TODO Check if managed resume is disabled (explain information leakage in docs!)
|
|
||||||
log("managed resume tokens are disabled via zrepl config, assuming config change or external administrative action")
|
|
||||||
log("policy forbids aborting the partial recv automatically, skipping this filesystem")
|
|
||||||
log("for zrepl to resume replication for this dataset, administrators should finishing the recv manually or abort the recv via `zfs recv -A %s`", m.Local.ToString())
|
|
||||||
return true // TODO right choice to allow children? ... should be, since the fs exists...
|
|
||||||
}
|
|
||||||
|
|
||||||
log("decoding receive_resume_token")
|
|
||||||
// TODO, see handler comments
|
|
||||||
// TODO override logger with fromguid toguid
|
|
||||||
|
|
||||||
log("requesting resume of transfer")
|
|
||||||
r := rpc.ResumeTransferRequest{m.Remote, localState.ResumeToken}
|
|
||||||
stream, err := remote.ResumeTransferRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
log("resume transfer request failed: %s", err)
|
|
||||||
rre, ok := err.(*rpc.ResumeTransferError)
|
|
||||||
if !ok {
|
|
||||||
log("skipping this filesystem, could be temporary issue")
|
|
||||||
return true // TODO right choice to allow children
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine if we should clear the resume token
|
|
||||||
clearAndUseSnaps := false
|
|
||||||
switch rre.Reason {
|
|
||||||
case rpc.ResumeTransferErrorReasonNotImplemented:
|
|
||||||
fallthrough
|
|
||||||
case rpc.ResumeTransferErrorReasonDisabled:
|
|
||||||
fallthrough
|
|
||||||
case rpc.ResumeTransferErrorReasonZFSErrorPermanent:
|
|
||||||
clearAndUseSnaps = true
|
|
||||||
|
|
||||||
case rpc.ResumeTransferErrorReasonZFSErrorMaybeTemporary:
|
|
||||||
fallthrough
|
|
||||||
default:
|
|
||||||
clearAndUseSnaps = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !clearAndUseSnaps {
|
|
||||||
log("skipping this filesystem, error identified as temporary")
|
|
||||||
return true // TODO right choice to allow children
|
|
||||||
}
|
|
||||||
|
|
||||||
log("clearing local receive_resume_token")
|
|
||||||
if err := zfs.ZFSRecvAbort(m.Local); err != nil {
|
|
||||||
log("error clearing receive_resume_token: %s", err)
|
|
||||||
return true // TODO right choice to allow children? the filesystem exists, so it should be ok
|
|
||||||
}
|
|
||||||
// TODO go back to top of function (put all this into separate function, then tail recursive call)
|
|
||||||
// TODO return this_function()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO warning code duplication, see below. need to unify this
|
|
||||||
log("invoking zfs receive")
|
|
||||||
watcher := util.IOProgressWatcher{Reader: stream}
|
|
||||||
watcher.KickOff(1*time.Second, func(p util.IOProgress) {
|
|
||||||
log("progress on receive operation: %v bytes received", p.TotalRX)
|
|
||||||
})
|
|
||||||
|
|
||||||
recvArgs := []string{"-u"}
|
|
||||||
if localState.Placeholder {
|
|
||||||
log("receive with forced rollback to replace placeholder filesystem")
|
|
||||||
recvArgs = append(recvArgs, "-F")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = zfs.ZFSRecv(m.Local, &watcher, recvArgs...); err != nil {
|
|
||||||
log("error receiving stream: %s", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
log("finished receiving stream, %v bytes total", watcher.Progress().TotalRX)
|
|
||||||
|
|
||||||
// TODO further problem property handling code must be duplicated here...
|
|
||||||
|
|
||||||
// TODO tail recursive call to this function, we might not be dony syncing yet
|
|
||||||
// TODO return this_function()
|
|
||||||
return true
|
|
||||||
|
|
||||||
default:
|
|
||||||
log("local filesystem exists")
|
|
||||||
log("requesting local filesystem versions")
|
|
||||||
if versions, err = zfs.ZFSListFilesystemVersions(m.Local, nil); err != nil {
|
|
||||||
log("cannot get local filesystem versions: %s", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log("requesting remote filesystem versions")
|
|
||||||
var theirVersions []zfs.FilesystemVersion
|
|
||||||
theirVersions, err = remote.FilesystemVersionsRequest(rpc.FilesystemVersionsRequest{
|
|
||||||
Filesystem: m.Remote,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log("error requesting remote filesystem versions: %s", err)
|
|
||||||
log("stopping replication for all filesystems mapped as children of %s", m.Local.ToString())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
log("computing diff between remote and local filesystem versions")
|
|
||||||
diff := zfs.MakeFilesystemDiff(versions, theirVersions)
|
|
||||||
log("%s", diff)
|
|
||||||
|
|
||||||
if localState.Placeholder && diff.Conflict != zfs.ConflictAllRight {
|
|
||||||
panic("internal inconsistency: local placeholder implies ConflictAllRight")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch diff.Conflict {
|
|
||||||
case zfs.ConflictAllRight:
|
|
||||||
|
|
||||||
log("performing initial sync, following policy: '%s'", pull.InitialReplPolicy)
|
|
||||||
|
|
||||||
if pull.InitialReplPolicy != rpc.InitialReplPolicyMostRecent {
|
|
||||||
panic(fmt.Sprintf("policy '%s' not implemented", pull.InitialReplPolicy))
|
|
||||||
}
|
|
||||||
|
|
||||||
snapsOnly := make([]zfs.FilesystemVersion, 0, len(diff.MRCAPathRight))
|
|
||||||
for s := range diff.MRCAPathRight {
|
|
||||||
if diff.MRCAPathRight[s].Type == zfs.Snapshot {
|
|
||||||
snapsOnly = append(snapsOnly, diff.MRCAPathRight[s])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(snapsOnly) < 1 {
|
|
||||||
log("cannot perform initial sync: no remote snapshots. stopping...")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
r := rpc.InitialTransferRequest{
|
|
||||||
Filesystem: m.Remote,
|
|
||||||
FilesystemVersion: snapsOnly[len(snapsOnly)-1],
|
|
||||||
}
|
|
||||||
|
|
||||||
log("requesting snapshot stream for %s", r.FilesystemVersion)
|
|
||||||
|
|
||||||
var stream io.Reader
|
|
||||||
if stream, err = remote.InitialTransferRequest(r); err != nil {
|
|
||||||
log("error requesting initial transfer: %s", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
log("received initial transfer request response")
|
|
||||||
|
|
||||||
log("invoking zfs receive")
|
|
||||||
watcher := util.IOProgressWatcher{Reader: stream}
|
|
||||||
watcher.KickOff(1*time.Second, func(p util.IOProgress) {
|
|
||||||
log("progress on receive operation: %v bytes received", p.TotalRX)
|
|
||||||
})
|
|
||||||
|
|
||||||
recvArgs := []string{"-u"}
|
|
||||||
if localState.Placeholder {
|
|
||||||
log("receive with forced rollback to replace placeholder filesystem")
|
|
||||||
recvArgs = append(recvArgs, "-F")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = zfs.ZFSRecv(m.Local, &watcher, recvArgs...); err != nil {
|
|
||||||
log("error receiving stream: %s", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
log("finished receiving stream, %v bytes total", watcher.Progress().TotalRX)
|
|
||||||
|
|
||||||
log("configuring properties of received filesystem")
|
|
||||||
if err = zfs.ZFSSet(m.Local, "readonly", "on"); err != nil {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
log("finished initial transfer")
|
|
||||||
return true
|
|
||||||
|
|
||||||
case zfs.ConflictIncremental:
|
|
||||||
|
|
||||||
if len(diff.IncrementalPath) < 2 {
|
|
||||||
log("remote and local are in sync")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
log("following incremental path from diff")
|
|
||||||
var pathRx uint64
|
|
||||||
|
|
||||||
for i := 0; i < len(diff.IncrementalPath)-1; i++ {
|
|
||||||
|
|
||||||
from, to := diff.IncrementalPath[i], diff.IncrementalPath[i+1]
|
|
||||||
|
|
||||||
log := func(format string, args ...interface{}) {
|
|
||||||
log("[%v/%v][%s => %s]: %s", i+1, len(diff.IncrementalPath)-1,
|
|
||||||
from.Name, to.Name, fmt.Sprintf(format, args...))
|
|
||||||
}
|
|
||||||
|
|
||||||
log("requesting incremental snapshot stream")
|
|
||||||
r := rpc.IncrementalTransferRequest{
|
|
||||||
Filesystem: m.Remote,
|
|
||||||
From: from,
|
|
||||||
To: to,
|
|
||||||
}
|
|
||||||
var stream io.Reader
|
|
||||||
if stream, err = remote.IncrementalTransferRequest(r); err != nil {
|
|
||||||
log("error requesting incremental snapshot stream: %s", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
log("invoking zfs receive")
|
|
||||||
watcher := util.IOProgressWatcher{Reader: stream}
|
|
||||||
watcher.KickOff(1*time.Second, func(p util.IOProgress) {
|
|
||||||
log("progress on receive operation: %v bytes received", p.TotalRX)
|
|
||||||
})
|
|
||||||
|
|
||||||
if err = zfs.ZFSRecv(m.Local, &watcher); err != nil {
|
|
||||||
log("error receiving stream: %s", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
totalRx := watcher.Progress().TotalRX
|
|
||||||
pathRx += totalRx
|
|
||||||
log("finished incremental transfer, %v bytes total", totalRx)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
log("finished following incremental path, %v bytes total", pathRx)
|
|
||||||
return true
|
|
||||||
|
|
||||||
case zfs.ConflictNoCommonAncestor:
|
|
||||||
|
|
||||||
log("remote and local filesystem have snapshots, but no common one")
|
|
||||||
log("perform manual replication to establish a common snapshot history")
|
|
||||||
log("remote versions:")
|
|
||||||
for _, v := range diff.MRCAPathRight {
|
|
||||||
log(" %s (GUID %v)", v, v.Guid)
|
|
||||||
}
|
|
||||||
log("local versions:")
|
|
||||||
for _, v := range diff.MRCAPathLeft {
|
|
||||||
log(" %s (GUID %v)", v, v.Guid)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
|
|
||||||
case zfs.ConflictDiverged:
|
|
||||||
|
|
||||||
log("remote and local filesystem share a history but have diverged")
|
|
||||||
log("perform manual replication or delete snapshots on the receiving" +
|
|
||||||
"side to establish an incremental replication parse")
|
|
||||||
log("remote-only versions:")
|
|
||||||
for _, v := range diff.MRCAPathRight {
|
|
||||||
log(" %s (GUID %v)", v, v.Guid)
|
|
||||||
}
|
|
||||||
log("local-only versions:")
|
|
||||||
for _, v := range diff.MRCAPathLeft {
|
|
||||||
log(" %s (GUID %v)", v, v.Guid)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
panic("implementation error: this should not be reached")
|
|
||||||
return false
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
remotes:
|
|
||||||
offsite_backups:
|
|
||||||
transport:
|
|
||||||
ssh:
|
|
||||||
host: 192.168.122.6
|
|
||||||
user: root
|
|
||||||
port: 22
|
|
||||||
identity_file: /etc/zrepl/identities/offsite_backups
|
|
||||||
|
|
||||||
pushs:
|
|
||||||
|
|
||||||
offsite:
|
|
||||||
to: offsite_backups
|
|
||||||
filter: {
|
|
||||||
# like in pull_acls
|
|
||||||
"tank/var/db<": ok,
|
|
||||||
"tank/usr/home<": ok,
|
|
||||||
}
|
|
||||||
|
|
||||||
pulls:
|
|
||||||
|
|
||||||
offsite:
|
|
||||||
from: offsite_backups
|
|
||||||
mapping: {
|
|
||||||
# like in sinks
|
|
||||||
}
|
|
||||||
|
|
||||||
# local replication, only allowed in pull mode
|
|
||||||
# the from name 'local' is reserved for this purpose
|
|
||||||
homemirror:
|
|
||||||
from: local
|
|
||||||
repeat:
|
|
||||||
interval: 15m
|
|
||||||
mapping: {
|
|
||||||
"tank/usr/home":"mirrorpool/foo/bar"
|
|
||||||
}
|
|
||||||
|
|
||||||
sinks:
|
|
||||||
|
|
||||||
db1:
|
|
||||||
mapping: {
|
|
||||||
# direct mapping
|
|
||||||
"ssdpool/var/db/postgresql9.6":"zroot/backups/db1/pg_data"
|
|
||||||
}
|
|
||||||
|
|
||||||
mirror1:
|
|
||||||
mapping: {
|
|
||||||
# "<" subtree wildcard matches the dataset left of < and all its children
|
|
||||||
"tank/foo/bar<":"zroot/backups/mirror1"
|
|
||||||
}
|
|
||||||
|
|
||||||
mirror2:
|
|
||||||
# more specific path patterns win over less specific ones
|
|
||||||
# direct mappings win over subtree wildcards
|
|
||||||
# detailed rule precedence: check unit tests & docs for exact behavior
|
|
||||||
# TODO subcommand to test a mapping & filter
|
|
||||||
mapping: {
|
|
||||||
"tank<": "zroot/backups/mirror1/tank1",
|
|
||||||
"tank/cdn/root<": "storage/cdn/root",
|
|
||||||
"tank/legacydb": "legacypool/backups/legacydb",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pull_acls:
|
|
||||||
|
|
||||||
office_backup:
|
|
||||||
filter: {
|
|
||||||
# valid filter results (right hand side): ok, omit
|
|
||||||
# default is to omit
|
|
||||||
# rule precedence is same as for mappings
|
|
||||||
"tank<": omit,
|
|
||||||
"tank/usr/home": ok,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
prune:
|
|
||||||
|
|
||||||
clean_backups:
|
|
||||||
policy: grid
|
|
||||||
grid: 6x10m | 24x1h | 7x1d | 5 x 1w | 4 x 5w
|
|
||||||
dataset_filter: {
|
|
||||||
"tank/backups/legacyscript<": omit,
|
|
||||||
"tank/backups<": ok,
|
|
||||||
}
|
|
||||||
snapshot_filter: {
|
|
||||||
prefix: zrepl_
|
|
||||||
}
|
|
||||||
|
|
||||||
hfbak_prune: # cleans up after hfbak autosnap job
|
|
||||||
policy: grid
|
|
||||||
grid: 1x1m(keep=all)
|
|
||||||
dataset_filter: {
|
|
||||||
"pool1<": ok
|
|
||||||
}
|
|
||||||
snapshot_filter: {
|
|
||||||
prefix: zrepl_hfbak_
|
|
||||||
}
|
|
||||||
|
|
||||||
autosnap:
|
|
||||||
|
|
||||||
hfbak:
|
|
||||||
prefix: zrepl_hfbak_
|
|
||||||
interval: 1s
|
|
||||||
dataset_filter: {
|
|
||||||
"pool1<": ok
|
|
||||||
}
|
|
||||||
# prune: hfbak_prune
|
|
||||||
# future versions may inline the retention policy here, but for now,
|
|
||||||
# pruning has to be triggered manually (it's safe to run autosnap + prune in parallel)
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/zrepl/zrepl/rpc"
|
|
||||||
"github.com/zrepl/zrepl/sshbytestream"
|
|
||||||
"io"
|
|
||||||
golog "log"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
var StdinserverCmd = &cobra.Command{
|
|
||||||
Use: "stdinserver CLIENT_IDENTITY",
|
|
||||||
Short: "start in stdin server mode (from authorized_keys file)",
|
|
||||||
Run: cmdStdinServer,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
RootCmd.AddCommand(StdinserverCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cmdStdinServer(cmd *cobra.Command, args []string) {
|
|
||||||
|
|
||||||
var err error
|
|
||||||
defer func() {
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("stdinserver exiting with error: %s", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if len(args) != 1 || args[0] == "" {
|
|
||||||
err = fmt.Errorf("must specify client identity as positional argument")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
identity := args[0]
|
|
||||||
|
|
||||||
pullACL, ok := conf.PullACLs[identity]
|
|
||||||
if !ok {
|
|
||||||
err = fmt.Errorf("could not find PullACL for identity '%s'", identity)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var sshByteStream io.ReadWriteCloser
|
|
||||||
if sshByteStream, err = sshbytestream.Incoming(); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sinkMapping := func(identity string) (m DatasetMapping, err error) {
|
|
||||||
sink, ok := conf.Sinks[identity]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("could not find sink for identity '%s'", identity)
|
|
||||||
}
|
|
||||||
return sink, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sinkLogger := golog.New(logOut, fmt.Sprintf("sink[%s] ", identity), logFlags)
|
|
||||||
handler := Handler{
|
|
||||||
Logger: sinkLogger,
|
|
||||||
SinkMappingFunc: sinkMapping,
|
|
||||||
PullACL: pullACL,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = rpc.ListenByteStreamRPC(sshByteStream, identity, handler, sinkLogger); err != nil {
|
|
||||||
log.Printf("listenbytestreamerror: %#v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log/syslog"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/zrepl/yaml-config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Jobs []JobEnum `yaml:"jobs"`
|
||||||
|
Global *Global `yaml:"global,optional,fromdefaults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Job(name string) (*JobEnum, error) {
|
||||||
|
for _, j := range c.Jobs {
|
||||||
|
if j.Name() == name {
|
||||||
|
return &j, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("job %q not defined in config", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobEnum struct {
|
||||||
|
Ret interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j JobEnum) Name() string {
|
||||||
|
var name string
|
||||||
|
switch v := j.Ret.(type) {
|
||||||
|
case *SnapJob:
|
||||||
|
name = v.Name
|
||||||
|
case *PushJob:
|
||||||
|
name = v.Name
|
||||||
|
case *SinkJob:
|
||||||
|
name = v.Name
|
||||||
|
case *PullJob:
|
||||||
|
name = v.Name
|
||||||
|
case *SourceJob:
|
||||||
|
name = v.Name
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("unknown job type %T", v))
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveJob struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Connect ConnectEnum `yaml:"connect"`
|
||||||
|
Pruning PruningSenderReceiver `yaml:"pruning"`
|
||||||
|
Debug JobDebugSettings `yaml:"debug,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PassiveJob struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Serve ServeEnum `yaml:"serve"`
|
||||||
|
Debug JobDebugSettings `yaml:"debug,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapJob struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Pruning PruningLocal `yaml:"pruning"`
|
||||||
|
Debug JobDebugSettings `yaml:"debug,optional"`
|
||||||
|
Snapshotting SnapshottingEnum `yaml:"snapshotting"`
|
||||||
|
Filesystems FilesystemsFilter `yaml:"filesystems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushJob struct {
|
||||||
|
ActiveJob `yaml:",inline"`
|
||||||
|
Snapshotting SnapshottingEnum `yaml:"snapshotting"`
|
||||||
|
Filesystems FilesystemsFilter `yaml:"filesystems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PullJob struct {
|
||||||
|
ActiveJob `yaml:",inline"`
|
||||||
|
RootFS string `yaml:"root_fs"`
|
||||||
|
Interval PositiveDurationOrManual `yaml:"interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PositiveDurationOrManual struct {
|
||||||
|
Interval time.Duration
|
||||||
|
Manual bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ yaml.Unmarshaler = (*PositiveDurationOrManual)(nil)
|
||||||
|
|
||||||
|
func (i *PositiveDurationOrManual) UnmarshalYAML(u func(interface{}, bool) error) (err error) {
|
||||||
|
var s string
|
||||||
|
if err := u(&s, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch s {
|
||||||
|
case "manual":
|
||||||
|
i.Manual = true
|
||||||
|
i.Interval = 0
|
||||||
|
case "":
|
||||||
|
return fmt.Errorf("value must not be empty")
|
||||||
|
default:
|
||||||
|
i.Manual = false
|
||||||
|
i.Interval, err = time.ParseDuration(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if i.Interval <= 0 {
|
||||||
|
return fmt.Errorf("value must be a positive duration, got %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SinkJob struct {
|
||||||
|
PassiveJob `yaml:",inline"`
|
||||||
|
RootFS string `yaml:"root_fs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceJob struct {
|
||||||
|
PassiveJob `yaml:",inline"`
|
||||||
|
Snapshotting SnapshottingEnum `yaml:"snapshotting"`
|
||||||
|
Filesystems FilesystemsFilter `yaml:"filesystems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilesystemsFilter map[string]bool
|
||||||
|
|
||||||
|
type SnapshottingEnum struct {
|
||||||
|
Ret interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapshottingPeriodic struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Prefix string `yaml:"prefix"`
|
||||||
|
Interval time.Duration `yaml:"interval,positive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapshottingManual struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PruningSenderReceiver struct {
|
||||||
|
KeepSender []PruningEnum `yaml:"keep_sender"`
|
||||||
|
KeepReceiver []PruningEnum `yaml:"keep_receiver"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PruningLocal struct {
|
||||||
|
Keep []PruningEnum `yaml:"keep"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoggingOutletEnumList []LoggingOutletEnum
|
||||||
|
|
||||||
|
func (l *LoggingOutletEnumList) SetDefault() {
|
||||||
|
def := `
|
||||||
|
type: "stdout"
|
||||||
|
time: true
|
||||||
|
level: "warn"
|
||||||
|
format: "human"
|
||||||
|
`
|
||||||
|
s := &StdoutLoggingOutlet{}
|
||||||
|
err := yaml.UnmarshalStrict([]byte(def), &s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
*l = []LoggingOutletEnum{LoggingOutletEnum{Ret: s}}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ yaml.Defaulter = &LoggingOutletEnumList{}
|
||||||
|
|
||||||
|
type Global struct {
|
||||||
|
Logging *LoggingOutletEnumList `yaml:"logging,optional,fromdefaults"`
|
||||||
|
Monitoring []MonitoringEnum `yaml:"monitoring,optional"`
|
||||||
|
Control *GlobalControl `yaml:"control,optional,fromdefaults"`
|
||||||
|
Serve *GlobalServe `yaml:"serve,optional,fromdefaults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Default(i interface{}) {
|
||||||
|
v := reflect.ValueOf(i)
|
||||||
|
if v.Kind() != reflect.Ptr {
|
||||||
|
panic(v)
|
||||||
|
}
|
||||||
|
y := `{}`
|
||||||
|
err := yaml.Unmarshal([]byte(y), v.Interface())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectEnum struct {
|
||||||
|
Ret interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectCommon struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TCPConnect struct {
|
||||||
|
ConnectCommon `yaml:",inline"`
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
DialTimeout time.Duration `yaml:"dial_timeout,zeropositive,default=10s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TLSConnect struct {
|
||||||
|
ConnectCommon `yaml:",inline"`
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
Ca string `yaml:"ca"`
|
||||||
|
Cert string `yaml:"cert"`
|
||||||
|
Key string `yaml:"key"`
|
||||||
|
ServerCN string `yaml:"server_cn"`
|
||||||
|
DialTimeout time.Duration `yaml:"dial_timeout,zeropositive,default=10s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SSHStdinserverConnect struct {
|
||||||
|
ConnectCommon `yaml:",inline"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
User string `yaml:"user"`
|
||||||
|
Port uint16 `yaml:"port"`
|
||||||
|
IdentityFile string `yaml:"identity_file"`
|
||||||
|
TransportOpenCommand []string `yaml:"transport_open_command,optional"` //TODO unused
|
||||||
|
SSHCommand string `yaml:"ssh_command,optional"` //TODO unused
|
||||||
|
Options []string `yaml:"options,optional"`
|
||||||
|
DialTimeout time.Duration `yaml:"dial_timeout,zeropositive,default=10s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalConnect struct {
|
||||||
|
ConnectCommon `yaml:",inline"`
|
||||||
|
ListenerName string `yaml:"listener_name"`
|
||||||
|
ClientIdentity string `yaml:"client_identity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServeEnum struct {
|
||||||
|
Ret interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServeCommon struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TCPServe struct {
|
||||||
|
ServeCommon `yaml:",inline"`
|
||||||
|
Listen string `yaml:"listen"`
|
||||||
|
Clients map[string]string `yaml:"clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TLSServe struct {
|
||||||
|
ServeCommon `yaml:",inline"`
|
||||||
|
Listen string `yaml:"listen"`
|
||||||
|
Ca string `yaml:"ca"`
|
||||||
|
Cert string `yaml:"cert"`
|
||||||
|
Key string `yaml:"key"`
|
||||||
|
ClientCNs []string `yaml:"client_cns"`
|
||||||
|
HandshakeTimeout time.Duration `yaml:"handshake_timeout,zeropositive,default=10s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StdinserverServer struct {
|
||||||
|
ServeCommon `yaml:",inline"`
|
||||||
|
ClientIdentities []string `yaml:"client_identities"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalServe struct {
|
||||||
|
ServeCommon `yaml:",inline"`
|
||||||
|
ListenerName string `yaml:"listener_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PruningEnum struct {
|
||||||
|
Ret interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PruneKeepNotReplicated struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
KeepSnapshotAtCursor bool `yaml:"keep_snapshot_at_cursor,optional,default=true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PruneKeepLastN struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PruneKeepRegex struct { // FIXME rename to KeepRegex
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Regex string `yaml:"regex"`
|
||||||
|
Negate bool `yaml:"negate,optional,default=false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoggingOutletEnum struct {
|
||||||
|
Ret interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoggingOutletCommon struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Level string `yaml:"level"`
|
||||||
|
Format string `yaml:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StdoutLoggingOutlet struct {
|
||||||
|
LoggingOutletCommon `yaml:",inline"`
|
||||||
|
Time bool `yaml:"time,default=true"`
|
||||||
|
Color bool `yaml:"color,default=true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyslogLoggingOutlet struct {
|
||||||
|
LoggingOutletCommon `yaml:",inline"`
|
||||||
|
Facility *SyslogFacility `yaml:"facility,optional,fromdefaults"`
|
||||||
|
RetryInterval time.Duration `yaml:"retry_interval,positive,default=10s"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TCPLoggingOutlet struct {
|
||||||
|
LoggingOutletCommon `yaml:",inline"`
|
||||||
|
Address string `yaml:"address"`
|
||||||
|
Net string `yaml:"net,default=tcp"`
|
||||||
|
RetryInterval time.Duration `yaml:"retry_interval,positive,default=10s"`
|
||||||
|
TLS *TCPLoggingOutletTLS `yaml:"tls,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TCPLoggingOutletTLS struct {
|
||||||
|
CA string `yaml:"ca"`
|
||||||
|
Cert string `yaml:"cert"`
|
||||||
|
Key string `yaml:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonitoringEnum struct {
|
||||||
|
Ret interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrometheusMonitoring struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Listen string `yaml:"listen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyslogFacility syslog.Priority
|
||||||
|
|
||||||
|
func (f *SyslogFacility) SetDefault() {
|
||||||
|
*f = SyslogFacility(syslog.LOG_LOCAL0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ yaml.Defaulter = (*SyslogFacility)(nil)
|
||||||
|
|
||||||
|
type GlobalControl struct {
|
||||||
|
SockPath string `yaml:"sockpath,default=/var/run/zrepl/control"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalServe struct {
|
||||||
|
StdinServer *GlobalStdinServer `yaml:"stdinserver,optional,fromdefaults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalStdinServer struct {
|
||||||
|
SockDir string `yaml:"sockdir,default=/var/run/zrepl/stdinserver"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobDebugSettings struct {
|
||||||
|
Conn *struct {
|
||||||
|
ReadDump string `yaml:"read_dump"`
|
||||||
|
WriteDump string `yaml:"write_dump"`
|
||||||
|
} `yaml:"conn,optional"`
|
||||||
|
RPCLog bool `yaml:"rpc_log,optional,default=false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumUnmarshal(u func(interface{}, bool) error, types map[string]interface{}) (interface{}, error) {
|
||||||
|
var in struct {
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
if err := u(&in, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if in.Type == "" {
|
||||||
|
return nil, &yaml.TypeError{Errors: []string{"must specify type"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
v, ok := types[in.Type]
|
||||||
|
if !ok {
|
||||||
|
return nil, &yaml.TypeError{Errors: []string{fmt.Sprintf("invalid type name %q", in.Type)}}
|
||||||
|
}
|
||||||
|
if err := u(v, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *JobEnum) UnmarshalYAML(u func(interface{}, bool) error) (err error) {
|
||||||
|
t.Ret, err = enumUnmarshal(u, map[string]interface{}{
|
||||||
|
"snap": &SnapJob{},
|
||||||
|
"push": &PushJob{},
|
||||||
|
"sink": &SinkJob{},
|
||||||
|
"pull": &PullJob{},
|
||||||
|
"source": &SourceJob{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnectEnum) UnmarshalYAML(u func(interface{}, bool) error) (err error) {
|
||||||
|
t.Ret, err = enumUnmarshal(u, map[string]interface{}{
|
||||||
|
"tcp": &TCPConnect{},
|
||||||
|
"tls": &TLSConnect{},
|
||||||
|
"ssh+stdinserver": &SSHStdinserverConnect{},
|
||||||
|
"local": &LocalConnect{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ServeEnum) UnmarshalYAML(u func(interface{}, bool) error) (err error) {
|
||||||
|
t.Ret, err = enumUnmarshal(u, map[string]interface{}{
|
||||||
|
"tcp": &TCPServe{},
|
||||||
|
"tls": &TLSServe{},
|
||||||
|
"stdinserver": &StdinserverServer{},
|
||||||
|
"local": &LocalServe{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *PruningEnum) UnmarshalYAML(u func(interface{}, bool) error) (err error) {
|
||||||
|
t.Ret, err = enumUnmarshal(u, map[string]interface{}{
|
||||||
|
"not_replicated": &PruneKeepNotReplicated{},
|
||||||
|
"last_n": &PruneKeepLastN{},
|
||||||
|
"grid": &PruneGrid{},
|
||||||
|
"regex": &PruneKeepRegex{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SnapshottingEnum) UnmarshalYAML(u func(interface{}, bool) error) (err error) {
|
||||||
|
t.Ret, err = enumUnmarshal(u, map[string]interface{}{
|
||||||
|
"periodic": &SnapshottingPeriodic{},
|
||||||
|
"manual": &SnapshottingManual{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *LoggingOutletEnum) UnmarshalYAML(u func(interface{}, bool) error) (err error) {
|
||||||
|
t.Ret, err = enumUnmarshal(u, map[string]interface{}{
|
||||||
|
"stdout": &StdoutLoggingOutlet{},
|
||||||
|
"syslog": &SyslogLoggingOutlet{},
|
||||||
|
"tcp": &TCPLoggingOutlet{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *MonitoringEnum) UnmarshalYAML(u func(interface{}, bool) error) (err error) {
|
||||||
|
t.Ret, err = enumUnmarshal(u, map[string]interface{}{
|
||||||
|
"prometheus": &PrometheusMonitoring{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SyslogFacility) UnmarshalYAML(u func(interface{}, bool) error) (err error) {
|
||||||
|
var s string
|
||||||
|
if err := u(&s, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var level syslog.Priority
|
||||||
|
switch s {
|
||||||
|
case "kern":
|
||||||
|
level = syslog.LOG_KERN
|
||||||
|
case "user":
|
||||||
|
level = syslog.LOG_USER
|
||||||
|
case "mail":
|
||||||
|
level = syslog.LOG_MAIL
|
||||||
|
case "daemon":
|
||||||
|
level = syslog.LOG_DAEMON
|
||||||
|
case "auth":
|
||||||
|
level = syslog.LOG_AUTH
|
||||||
|
case "syslog":
|
||||||
|
level = syslog.LOG_SYSLOG
|
||||||
|
case "lpr":
|
||||||
|
level = syslog.LOG_LPR
|
||||||
|
case "news":
|
||||||
|
level = syslog.LOG_NEWS
|
||||||
|
case "uucp":
|
||||||
|
level = syslog.LOG_UUCP
|
||||||
|
case "cron":
|
||||||
|
level = syslog.LOG_CRON
|
||||||
|
case "authpriv":
|
||||||
|
level = syslog.LOG_AUTHPRIV
|
||||||
|
case "ftp":
|
||||||
|
level = syslog.LOG_FTP
|
||||||
|
case "local0":
|
||||||
|
level = syslog.LOG_LOCAL0
|
||||||
|
case "local1":
|
||||||
|
level = syslog.LOG_LOCAL1
|
||||||
|
case "local2":
|
||||||
|
level = syslog.LOG_LOCAL2
|
||||||
|
case "local3":
|
||||||
|
level = syslog.LOG_LOCAL3
|
||||||
|
case "local4":
|
||||||
|
level = syslog.LOG_LOCAL4
|
||||||
|
case "local5":
|
||||||
|
level = syslog.LOG_LOCAL5
|
||||||
|
case "local6":
|
||||||
|
level = syslog.LOG_LOCAL6
|
||||||
|
case "local7":
|
||||||
|
level = syslog.LOG_LOCAL7
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid syslog level: %q", s)
|
||||||
|
}
|
||||||
|
*t = SyslogFacility(level)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ConfigFileDefaultLocations = []string{
|
||||||
|
"/etc/zrepl/zrepl.yml",
|
||||||
|
"/usr/local/etc/zrepl/zrepl.yml",
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseConfig(path string) (i *Config, err error) {
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
// Try default locations
|
||||||
|
for _, l := range ConfigFileDefaultLocations {
|
||||||
|
stat, statErr := os.Stat(l)
|
||||||
|
if statErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !stat.Mode().IsRegular() {
|
||||||
|
err = errors.Errorf("file at default location is not a regular file: %s", l)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path = l
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes []byte
|
||||||
|
|
||||||
|
if bytes, err = ioutil.ReadFile(path); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParseConfigBytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseConfigBytes(bytes []byte) (*Config, error) {
|
||||||
|
var c *Config
|
||||||
|
if err := yaml.UnmarshalStrict(bytes, &c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
return nil, fmt.Errorf("config is empty or only consists of comments")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var durationStringRegex *regexp.Regexp = regexp.MustCompile(`^\s*(\d+)\s*(s|m|h|d|w)\s*$`)
|
||||||
|
|
||||||
|
func parsePostitiveDuration(e string) (d time.Duration, err error) {
|
||||||
|
comps := durationStringRegex.FindStringSubmatch(e)
|
||||||
|
if len(comps) != 3 {
|
||||||
|
err = fmt.Errorf("does not match regex: %s %#v", e, comps)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
durationFactor, err := strconv.ParseInt(comps[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if durationFactor <= 0 {
|
||||||
|
return 0, errors.New("duration must be positive integer")
|
||||||
|
}
|
||||||
|
|
||||||
|
var durationUnit time.Duration
|
||||||
|
switch comps[2] {
|
||||||
|
case "s":
|
||||||
|
durationUnit = time.Second
|
||||||
|
case "m":
|
||||||
|
durationUnit = time.Minute
|
||||||
|
case "h":
|
||||||
|
durationUnit = time.Hour
|
||||||
|
case "d":
|
||||||
|
durationUnit = 24 * time.Hour
|
||||||
|
case "w":
|
||||||
|
durationUnit = 24 * 7 * time.Hour
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("contains unknown time unit '%s'", comps[2])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d = time.Duration(durationFactor) * durationUnit
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/syslog"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/zrepl/yaml-config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testValidGlobalSection(t *testing.T, s string) *Config {
|
||||||
|
jobdef := `
|
||||||
|
jobs:
|
||||||
|
- name: dummyjob
|
||||||
|
type: sink
|
||||||
|
serve:
|
||||||
|
type: tcp
|
||||||
|
listen: ":2342"
|
||||||
|
clients: {
|
||||||
|
"10.0.0.1":"foo"
|
||||||
|
}
|
||||||
|
root_fs: zoot/foo
|
||||||
|
`
|
||||||
|
_, err := ParseConfigBytes([]byte(jobdef))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return testValidConfig(t, s+jobdef)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutletTypes(t *testing.T) {
|
||||||
|
conf := testValidGlobalSection(t, `
|
||||||
|
global:
|
||||||
|
logging:
|
||||||
|
- type: stdout
|
||||||
|
level: debug
|
||||||
|
format: human
|
||||||
|
- type: syslog
|
||||||
|
level: info
|
||||||
|
retry_interval: 20s
|
||||||
|
format: human
|
||||||
|
- type: tcp
|
||||||
|
level: debug
|
||||||
|
format: json
|
||||||
|
address: logserver.example.com:1234
|
||||||
|
- type: tcp
|
||||||
|
level: debug
|
||||||
|
format: json
|
||||||
|
address: encryptedlogserver.example.com:1234
|
||||||
|
retry_interval: 20s
|
||||||
|
tls:
|
||||||
|
ca: /etc/zrepl/log/ca.crt
|
||||||
|
cert: /etc/zrepl/log/key.pem
|
||||||
|
key: /etc/zrepl/log/cert.pem
|
||||||
|
`)
|
||||||
|
assert.Equal(t, 4, len(*conf.Global.Logging))
|
||||||
|
assert.NotNil(t, (*conf.Global.Logging)[3].Ret.(*TCPLoggingOutlet).TLS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultLoggingOutlet(t *testing.T) {
|
||||||
|
conf := testValidGlobalSection(t, "")
|
||||||
|
assert.Equal(t, 1, len(*conf.Global.Logging))
|
||||||
|
o := (*conf.Global.Logging)[0].Ret.(*StdoutLoggingOutlet)
|
||||||
|
assert.Equal(t, "warn", o.Level)
|
||||||
|
assert.Equal(t, "human", o.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrometheusMonitoring(t *testing.T) {
|
||||||
|
conf := testValidGlobalSection(t, `
|
||||||
|
global:
|
||||||
|
monitoring:
|
||||||
|
- type: prometheus
|
||||||
|
listen: ':9091'
|
||||||
|
`)
|
||||||
|
assert.Equal(t, ":9091", conf.Global.Monitoring[0].Ret.(*PrometheusMonitoring).Listen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyslogLoggingOutletFacility(t *testing.T) {
|
||||||
|
type SyslogFacilityPriority struct {
|
||||||
|
Facility string
|
||||||
|
Priority syslog.Priority
|
||||||
|
}
|
||||||
|
syslogFacilitiesPriorities := []SyslogFacilityPriority{
|
||||||
|
{"", syslog.LOG_LOCAL0}, // default
|
||||||
|
{"kern", syslog.LOG_KERN}, {"daemon", syslog.LOG_DAEMON}, {"auth", syslog.LOG_AUTH},
|
||||||
|
{"syslog", syslog.LOG_SYSLOG}, {"lpr", syslog.LOG_LPR}, {"news", syslog.LOG_NEWS},
|
||||||
|
{"uucp", syslog.LOG_UUCP}, {"cron", syslog.LOG_CRON}, {"authpriv", syslog.LOG_AUTHPRIV},
|
||||||
|
{"ftp", syslog.LOG_FTP}, {"local0", syslog.LOG_LOCAL0}, {"local1", syslog.LOG_LOCAL1},
|
||||||
|
{"local2", syslog.LOG_LOCAL2}, {"local3", syslog.LOG_LOCAL3}, {"local4", syslog.LOG_LOCAL4},
|
||||||
|
{"local5", syslog.LOG_LOCAL5}, {"local6", syslog.LOG_LOCAL6}, {"local7", syslog.LOG_LOCAL7},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sFP := range syslogFacilitiesPriorities {
|
||||||
|
logcfg := fmt.Sprintf(`
|
||||||
|
global:
|
||||||
|
logging:
|
||||||
|
- type: syslog
|
||||||
|
level: info
|
||||||
|
format: human
|
||||||
|
facility: %s
|
||||||
|
`, sFP.Facility)
|
||||||
|
conf := testValidGlobalSection(t, logcfg)
|
||||||
|
assert.Equal(t, 1, len(*conf.Global.Logging))
|
||||||
|
assert.True(t, SyslogFacility(sFP.Priority) == *(*conf.Global.Logging)[0].Ret.(*SyslogLoggingOutlet).Facility)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggingOutletEnumList_SetDefaults(t *testing.T) {
|
||||||
|
e := &LoggingOutletEnumList{}
|
||||||
|
var i yaml.Defaulter = e
|
||||||
|
require.NotPanics(t, func() {
|
||||||
|
i.SetDefault()
|
||||||
|
assert.Equal(t, "warn", (*e)[0].Ret.(*StdoutLoggingOutlet).Level)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigEmptyFails(t *testing.T) {
|
||||||
|
conf, err := testConfig(t, "\n")
|
||||||
|
assert.Nil(t, conf)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobsOnlyWorks(t *testing.T) {
|
||||||
|
testValidConfig(t, `
|
||||||
|
jobs:
|
||||||
|
- name: push
|
||||||
|
type: push
|
||||||
|
# snapshot the filesystems matched by the left-hand-side of the mapping
|
||||||
|
# every 10m with zrepl_ as prefix
|
||||||
|
connect:
|
||||||
|
type: tcp
|
||||||
|
address: localhost:2342
|
||||||
|
filesystems: {
|
||||||
|
"pool1/var/db<": true,
|
||||||
|
"pool1/usr/home<": true,
|
||||||
|
"pool1/usr/home/paranoid": false, #don't backup paranoid user
|
||||||
|
"pool1/poudriere/ports<": false #don't backup the ports trees
|
||||||
|
}
|
||||||
|
snapshotting:
|
||||||
|
type: manual
|
||||||
|
pruning:
|
||||||
|
keep_sender:
|
||||||
|
- type: not_replicated
|
||||||
|
keep_receiver:
|
||||||
|
- type: last_n
|
||||||
|
count: 1
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/zrepl/yaml-config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPositiveDurationOrManual(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
Comment, Input string
|
||||||
|
Result *PositiveDurationOrManual
|
||||||
|
}{
|
||||||
|
{"empty is error", "", nil},
|
||||||
|
{"negative is error", "-1s", nil},
|
||||||
|
{"zero seconds is error", "0s", nil},
|
||||||
|
{"zero is error", "0", nil},
|
||||||
|
{"non-manual is error", "something", nil},
|
||||||
|
{"positive seconds works", "1s", &PositiveDurationOrManual{Manual: false, Interval: 1 * time.Second}},
|
||||||
|
{"manual works", "manual", &PositiveDurationOrManual{Manual: true, Interval: 0}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.Comment, func(t *testing.T) {
|
||||||
|
var out struct {
|
||||||
|
FieldName PositiveDurationOrManual `yaml:"fieldname"`
|
||||||
|
}
|
||||||
|
input := fmt.Sprintf("\nfieldname: %s\n", tc.Input)
|
||||||
|
err := yaml.UnmarshalStrict([]byte(input), &out)
|
||||||
|
if tc.Result == nil {
|
||||||
|
assert.Error(t, err)
|
||||||
|
t.Logf("%#v", out)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, *tc.Result, out.FieldName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSnapshotting(t *testing.T) {
|
||||||
|
tmpl := `
|
||||||
|
jobs:
|
||||||
|
- name: foo
|
||||||
|
type: push
|
||||||
|
connect:
|
||||||
|
type: local
|
||||||
|
listener_name: foo
|
||||||
|
client_identity: bar
|
||||||
|
filesystems: {"<": true}
|
||||||
|
%s
|
||||||
|
pruning:
|
||||||
|
keep_sender:
|
||||||
|
- type: last_n
|
||||||
|
count: 10
|
||||||
|
keep_receiver:
|
||||||
|
- type: last_n
|
||||||
|
count: 10
|
||||||
|
`
|
||||||
|
manual := `
|
||||||
|
snapshotting:
|
||||||
|
type: manual
|
||||||
|
`
|
||||||
|
periodic := `
|
||||||
|
snapshotting:
|
||||||
|
type: periodic
|
||||||
|
prefix: zrepl_
|
||||||
|
interval: 10m
|
||||||
|
`
|
||||||
|
|
||||||
|
fillSnapshotting := func(s string) string { return fmt.Sprintf(tmpl, s) }
|
||||||
|
var c *Config
|
||||||
|
|
||||||
|
t.Run("manual", func(t *testing.T) {
|
||||||
|
c = testValidConfig(t, fillSnapshotting(manual))
|
||||||
|
snm := c.Jobs[0].Ret.(*PushJob).Snapshotting.Ret.(*SnapshottingManual)
|
||||||
|
assert.Equal(t, "manual", snm.Type)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("periodic", func(t *testing.T) {
|
||||||
|
c = testValidConfig(t, fillSnapshotting(periodic))
|
||||||
|
snp := c.Jobs[0].Ret.(*PushJob).Snapshotting.Ret.(*SnapshottingPeriodic)
|
||||||
|
assert.Equal(t, "periodic", snp.Type)
|
||||||
|
assert.Equal(t, 10*time.Minute, snp.Interval)
|
||||||
|
assert.Equal(t, "zrepl_", snp.Prefix)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/kr/pretty"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSampleConfigsAreParsedWithoutErrors(t *testing.T) {
|
||||||
|
paths, err := filepath.Glob("./samples/*")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("glob failed: %+v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
|
||||||
|
if path.Ext(p) != ".yml" {
|
||||||
|
t.Logf("skipping file %s", p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run(p, func(t *testing.T) {
|
||||||
|
c, err := ParseConfig(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error parsing %s:\n%+v", p, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("file: %s", p)
|
||||||
|
t.Log(pretty.Sprint(c))
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// template must be a template/text template with a single '{{ . }}' as placehodler for val
|
||||||
|
//nolint[:deadcode,unused]
|
||||||
|
func testValidConfigTemplate(t *testing.T, tmpl string, val string) *Config {
|
||||||
|
tmp, err := template.New("master").Parse(tmpl)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = tmp.Execute(&buf, val)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return testValidConfig(t, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidConfig(t *testing.T, input string) *Config {
|
||||||
|
t.Helper()
|
||||||
|
conf, err := testConfig(t, input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, conf)
|
||||||
|
return conf
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfig(t *testing.T, input string) (*Config, error) {
|
||||||
|
t.Helper()
|
||||||
|
return ParseConfigBytes([]byte(input))
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RetentionIntervalList []RetentionInterval
|
||||||
|
|
||||||
|
type PruneGrid struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Grid RetentionIntervalList `yaml:"grid"`
|
||||||
|
Regex string `yaml:"regex"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetentionInterval struct {
|
||||||
|
length time.Duration
|
||||||
|
keepCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *RetentionInterval) Length() time.Duration {
|
||||||
|
return i.length
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *RetentionInterval) KeepCount() int {
|
||||||
|
return i.keepCount
|
||||||
|
}
|
||||||
|
|
||||||
|
const RetentionGridKeepCountAll int = -1
|
||||||
|
|
||||||
|
func (t *RetentionIntervalList) UnmarshalYAML(u func(interface{}, bool) error) (err error) {
|
||||||
|
var in string
|
||||||
|
if err := u(&in, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
intervals, err := parseRetentionGridIntervalsString(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*t = intervals
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var retentionStringIntervalRegex *regexp.Regexp = regexp.MustCompile(`^\s*(\d+)\s*x\s*([^\(]+)\s*(\((.*)\))?\s*$`)
|
||||||
|
|
||||||
|
func parseRetentionGridIntervalString(e string) (intervals []RetentionInterval, err error) {
|
||||||
|
|
||||||
|
comps := retentionStringIntervalRegex.FindStringSubmatch(e)
|
||||||
|
if comps == nil {
|
||||||
|
err = fmt.Errorf("retention string does not match expected format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
times, err := strconv.Atoi(comps[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if times <= 0 {
|
||||||
|
return nil, fmt.Errorf("contains factor <= 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, err := parsePostitiveDuration(comps[2])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keepCount := 1
|
||||||
|
if comps[3] != "" {
|
||||||
|
// Decompose key=value, comma separated
|
||||||
|
// For now, only keep_count is supported
|
||||||
|
re := regexp.MustCompile(`^\s*keep=(.+)\s*$`)
|
||||||
|
res := re.FindStringSubmatch(comps[4])
|
||||||
|
if res == nil || len(res) != 2 {
|
||||||
|
err = fmt.Errorf("interval parameter contains unknown parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res[1] == "all" {
|
||||||
|
keepCount = RetentionGridKeepCountAll
|
||||||
|
} else {
|
||||||
|
keepCount, err = strconv.Atoi(res[1])
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("cannot parse keep_count value")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
intervals = make([]RetentionInterval, times)
|
||||||
|
for i := range intervals {
|
||||||
|
intervals[i] = RetentionInterval{
|
||||||
|
length: duration,
|
||||||
|
keepCount: keepCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRetentionGridIntervalsString(s string) (intervals []RetentionInterval, err error) {
|
||||||
|
|
||||||
|
ges := strings.Split(s, "|")
|
||||||
|
intervals = make([]RetentionInterval, 0, 7*len(ges))
|
||||||
|
|
||||||
|
for intervalIdx, e := range ges {
|
||||||
|
parsed, err := parseRetentionGridIntervalString(e)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse interval %d of %d: %s: %s", intervalIdx+1, len(ges), err, strings.TrimSpace(e))
|
||||||
|
}
|
||||||
|
intervals = append(intervals, parsed...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: sink
|
||||||
|
name: "local_sink"
|
||||||
|
root_fs: "storage/zrepl/sink"
|
||||||
|
serve:
|
||||||
|
type: local
|
||||||
|
listener_name: localsink
|
||||||
|
|
||||||
|
- type: push
|
||||||
|
name: "backup_system"
|
||||||
|
connect:
|
||||||
|
type: local
|
||||||
|
listener_name: localsink
|
||||||
|
client_identity: local_backup
|
||||||
|
filesystems: {
|
||||||
|
"system<": true,
|
||||||
|
}
|
||||||
|
snapshotting:
|
||||||
|
type: periodic
|
||||||
|
interval: 10m
|
||||||
|
prefix: zrepl_
|
||||||
|
pruning:
|
||||||
|
keep_sender:
|
||||||
|
- type: not_replicated
|
||||||
|
- type: last_n
|
||||||
|
count: 10
|
||||||
|
keep_receiver:
|
||||||
|
- type: grid
|
||||||
|
grid: 1x1h(keep=all) | 24x1h | 35x1d | 6x30d
|
||||||
|
regex: "zrepl_.*"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
jobs:
|
||||||
|
- name: pull_servers
|
||||||
|
type: pull
|
||||||
|
connect:
|
||||||
|
type: tls
|
||||||
|
address: "server1.foo.bar:8888"
|
||||||
|
ca: "/certs/ca.crt"
|
||||||
|
cert: "/certs/cert.crt"
|
||||||
|
key: "/certs/key.pem"
|
||||||
|
server_cn: "server1"
|
||||||
|
root_fs: "pool2/backup_servers"
|
||||||
|
interval: 10m
|
||||||
|
pruning:
|
||||||
|
keep_sender:
|
||||||
|
- type: not_replicated
|
||||||
|
- type: last_n
|
||||||
|
count: 10
|
||||||
|
- type: grid
|
||||||
|
grid: 1x1h(keep=all) | 24x1h | 14x1d
|
||||||
|
regex: "zrepl_.*"
|
||||||
|
keep_receiver:
|
||||||
|
- type: grid
|
||||||
|
grid: 1x1h(keep=all) | 24x1h | 35x1d | 6x30d
|
||||||
|
regex: "zrepl_.*"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
jobs:
|
||||||
|
|
||||||
|
- name: pull_servers
|
||||||
|
type: pull
|
||||||
|
connect:
|
||||||
|
type: ssh+stdinserver
|
||||||
|
host: app-srv.example.com
|
||||||
|
user: root
|
||||||
|
port: 22
|
||||||
|
identity_file: /etc/zrepl/ssh/identity
|
||||||
|
options: # optional, default [], `-o` arguments passed to ssh
|
||||||
|
- "Compression=on"
|
||||||
|
root_fs: "pool2/backup_servers"
|
||||||
|
interval: 10m
|
||||||
|
pruning:
|
||||||
|
keep_sender:
|
||||||
|
- type: not_replicated
|
||||||
|
- type: last_n
|
||||||
|
count: 10
|
||||||
|
- type: grid
|
||||||
|
grid: 1x1h(keep=all) | 24x1h | 14x1d
|
||||||
|
regex: "^zrepl_.*"
|
||||||
|
keep_receiver:
|
||||||
|
- type: regex
|
||||||
|
regex: keep_
|
||||||
|
- type: grid
|
||||||
|
grid: 1x1h(keep=all) | 24x1h | 35x1d | 6x30d
|
||||||
|
regex: "^zrepl_.*"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
jobs:
|
||||||
|
- type: push
|
||||||
|
name: "push"
|
||||||
|
filesystems: {
|
||||||
|
"<": true,
|
||||||
|
"tmp": false
|
||||||
|
}
|
||||||
|
connect:
|
||||||
|
type: tcp
|
||||||
|
address: "backup-server.foo.bar:8888"
|
||||||
|
snapshotting:
|
||||||
|
type: manual
|
||||||
|
pruning:
|
||||||
|
keep_sender:
|
||||||
|
- type: not_replicated
|
||||||
|
- type: last_n
|
||||||
|
count: 10
|
||||||
|
- type: grid
|
||||||
|
grid: 1x1h(keep=all) | 24x1h | 14x1d
|
||||||
|
regex: "^zrepl_.*"
|
||||||
|
keep_receiver:
|
||||||
|
- type: grid
|
||||||
|
grid: 1x1h(keep=all) | 24x1h | 35x1d | 6x30d
|
||||||
|
regex: "^zrepl_.*"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
jobs:
|
||||||
|
- type: sink
|
||||||
|
name: "laptop_sink"
|
||||||
|
root_fs: "pool2/backup_laptops"
|
||||||
|
serve:
|
||||||
|
type: tls
|
||||||
|
listen: "192.168.122.189:8888"
|
||||||
|
ca: "ca.pem"
|
||||||
|
cert: "cert.pem"
|
||||||
|
key: "key.pem"
|
||||||
|
client_cns:
|
||||||
|
- "laptop1"
|
||||||
|
- "homeserver"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
jobs:
|
||||||
|
- name: snapjob
|
||||||
|
type: snap
|
||||||
|
filesystems: {
|
||||||
|
"tank/frequently_changed<": true,
|
||||||
|
}
|
||||||
|
snapshotting:
|
||||||
|
type: periodic
|
||||||
|
interval: 2m
|
||||||
|
prefix: zrepl_snapjob_
|
||||||
|
pruning:
|
||||||
|
keep:
|
||||||
|
- type: last_n
|
||||||
|
count: 60
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
jobs:
|
||||||
|
- name: pull_source
|
||||||
|
type: source
|
||||||
|
serve:
|
||||||
|
type: tcp
|
||||||
|
listen: "0.0.0.0:8888"
|
||||||
|
clients: {
|
||||||
|
"192.168.122.123" : "client1"
|
||||||
|
}
|
||||||
|
filesystems: {
|
||||||
|
"<": true,
|
||||||
|
"secret": false
|
||||||
|
}
|
||||||
|
snapshotting:
|
||||||
|
type: periodic
|
||||||
|
interval: 10m
|
||||||
|
prefix: zrepl_
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
jobs:
|
||||||
|
- name: pull_source
|
||||||
|
type: source
|
||||||
|
serve:
|
||||||
|
type: stdinserver
|
||||||
|
client_identities:
|
||||||
|
- "client1"
|
||||||
|
- "client2"
|
||||||
|
filesystems: {
|
||||||
|
"<": true,
|
||||||
|
"secret": false
|
||||||
|
}
|
||||||
|
snapshotting:
|
||||||
|
type: periodic
|
||||||
|
interval: 10m
|
||||||
|
prefix: zrepl_
|
||||||
|
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/daemon/job"
|
||||||
|
"github.com/zrepl/zrepl/daemon/nethelpers"
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
"github.com/zrepl/zrepl/version"
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type controlJob struct {
|
||||||
|
sockaddr *net.UnixAddr
|
||||||
|
jobs *jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
func newControlJob(sockpath string, jobs *jobs) (j *controlJob, err error) {
|
||||||
|
j = &controlJob{jobs: jobs}
|
||||||
|
|
||||||
|
j.sockaddr, err = net.ResolveUnixAddr("unix", sockpath)
|
||||||
|
if err != nil {
|
||||||
|
err = errors.Wrap(err, "cannot resolve unix address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *controlJob) Name() string { return jobNameControl }
|
||||||
|
|
||||||
|
func (j *controlJob) Status() *job.Status { return &job.Status{Type: job.TypeInternal} }
|
||||||
|
|
||||||
|
func (j *controlJob) OwnedDatasetSubtreeRoot() (p *zfs.DatasetPath, ok bool) { return nil, false }
|
||||||
|
|
||||||
|
var promControl struct {
|
||||||
|
requestBegin *prometheus.CounterVec
|
||||||
|
requestFinished *prometheus.HistogramVec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *controlJob) RegisterMetrics(registerer prometheus.Registerer) {
|
||||||
|
promControl.requestBegin = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "zrepl",
|
||||||
|
Subsystem: "control",
|
||||||
|
Name: "request_begin",
|
||||||
|
Help: "number of request we started to handle",
|
||||||
|
}, []string{"endpoint"})
|
||||||
|
|
||||||
|
promControl.requestFinished = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: "zrepl",
|
||||||
|
Subsystem: "control",
|
||||||
|
Name: "request_finished",
|
||||||
|
Help: "time it took a request to finih",
|
||||||
|
Buckets: []float64{1e-6, 10e-6, 100e-6, 500e-6, 1e-3, 10e-3, 100e-3, 200e-3, 400e-3, 800e-3, 1, 10, 20},
|
||||||
|
}, []string{"endpoint"})
|
||||||
|
registerer.MustRegister(promControl.requestBegin)
|
||||||
|
registerer.MustRegister(promControl.requestFinished)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
ControlJobEndpointPProf string = "/debug/pprof"
|
||||||
|
ControlJobEndpointVersion string = "/version"
|
||||||
|
ControlJobEndpointStatus string = "/status"
|
||||||
|
ControlJobEndpointSignal string = "/signal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (j *controlJob) Run(ctx context.Context) {
|
||||||
|
|
||||||
|
log := job.GetLogger(ctx)
|
||||||
|
defer log.Info("control job finished")
|
||||||
|
|
||||||
|
l, err := nethelpers.ListenUnixPrivate(j.sockaddr)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("error listening")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pprofServer := NewPProfServer(ctx)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle(ControlJobEndpointPProf,
|
||||||
|
requestLogger{log: log, handler: jsonRequestResponder{log, func(decoder jsonDecoder) (interface{}, error) {
|
||||||
|
var msg PprofServerControlMsg
|
||||||
|
err := decoder(&msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Errorf("decode failed")
|
||||||
|
}
|
||||||
|
pprofServer.Control(msg)
|
||||||
|
return struct{}{}, nil
|
||||||
|
}}})
|
||||||
|
|
||||||
|
mux.Handle(ControlJobEndpointVersion,
|
||||||
|
requestLogger{log: log, handler: jsonResponder{log, func() (interface{}, error) {
|
||||||
|
return version.NewZreplVersionInformation(), nil
|
||||||
|
}}})
|
||||||
|
|
||||||
|
mux.Handle(ControlJobEndpointStatus,
|
||||||
|
// don't log requests to status endpoint, too spammy
|
||||||
|
jsonResponder{log, func() (interface{}, error) {
|
||||||
|
s := j.jobs.status()
|
||||||
|
return s, nil
|
||||||
|
}})
|
||||||
|
|
||||||
|
mux.Handle(ControlJobEndpointSignal,
|
||||||
|
requestLogger{log: log, handler: jsonRequestResponder{log, func(decoder jsonDecoder) (interface{}, error) {
|
||||||
|
type reqT struct {
|
||||||
|
Name string
|
||||||
|
Op string
|
||||||
|
}
|
||||||
|
var req reqT
|
||||||
|
if decoder(&req) != nil {
|
||||||
|
return nil, errors.Errorf("decode failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch req.Op {
|
||||||
|
case "wakeup":
|
||||||
|
err = j.jobs.wakeup(req.Name)
|
||||||
|
case "reset":
|
||||||
|
err = j.jobs.reset(req.Name)
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("operation %q is invalid", req.Op)
|
||||||
|
}
|
||||||
|
|
||||||
|
return struct{}{}, err
|
||||||
|
}}})
|
||||||
|
server := http.Server{
|
||||||
|
Handler: mux,
|
||||||
|
// control socket is local, 1s timeout should be more than sufficient, even on a loaded system
|
||||||
|
WriteTimeout: 1 * time.Second,
|
||||||
|
ReadTimeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
outer:
|
||||||
|
for {
|
||||||
|
|
||||||
|
served := make(chan error)
|
||||||
|
go func() {
|
||||||
|
served <- server.Serve(l)
|
||||||
|
close(served)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.WithError(ctx.Err()).Info("context done")
|
||||||
|
err := server.Shutdown(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("cannot shutdown server")
|
||||||
|
}
|
||||||
|
break outer
|
||||||
|
case err = <-served:
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("error serving")
|
||||||
|
break outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonResponder struct {
|
||||||
|
log Logger
|
||||||
|
producer func() (interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j jsonResponder) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
logIoErr := func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
j.log.WithError(err).Error("control handler io error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := j.producer()
|
||||||
|
if err != nil {
|
||||||
|
j.log.WithError(err).Error("control handler error")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, err = io.WriteString(w, err.Error())
|
||||||
|
logIoErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = json.NewEncoder(&buf).Encode(res)
|
||||||
|
if err != nil {
|
||||||
|
j.log.WithError(err).Error("control handler json marshal error")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, err = io.WriteString(w, err.Error())
|
||||||
|
} else {
|
||||||
|
_, err = io.Copy(w, &buf)
|
||||||
|
}
|
||||||
|
logIoErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonDecoder = func(interface{}) error
|
||||||
|
|
||||||
|
type jsonRequestResponder struct {
|
||||||
|
log Logger
|
||||||
|
producer func(decoder jsonDecoder) (interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j jsonRequestResponder) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
logIoErr := func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
j.log.WithError(err).Error("control handler io error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var decodeError error
|
||||||
|
decoder := func(i interface{}) error {
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&i)
|
||||||
|
decodeError = err
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, producerErr := j.producer(decoder)
|
||||||
|
|
||||||
|
//If we had a decode error ignore output of producer and return error
|
||||||
|
if decodeError != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, err := io.WriteString(w, decodeError.Error())
|
||||||
|
logIoErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if producerErr != nil {
|
||||||
|
j.log.WithError(producerErr).Error("control handler error")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, err := io.WriteString(w, producerErr.Error())
|
||||||
|
logIoErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
encodeErr := json.NewEncoder(&buf).Encode(res)
|
||||||
|
if encodeErr != nil {
|
||||||
|
j.log.WithError(producerErr).Error("control handler json marhsal error")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, err := io.WriteString(w, encodeErr.Error())
|
||||||
|
logIoErr(err)
|
||||||
|
} else {
|
||||||
|
_, err := io.Copy(w, &buf)
|
||||||
|
logIoErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestLogger struct {
|
||||||
|
log logger.Logger
|
||||||
|
handler http.Handler
|
||||||
|
handlerFunc http.HandlerFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l requestLogger) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log := l.log.WithField("method", r.Method).WithField("url", r.URL)
|
||||||
|
log.Debug("start")
|
||||||
|
promControl.requestBegin.WithLabelValues(r.URL.Path).Inc()
|
||||||
|
defer prometheus.NewTimer(promControl.requestFinished.WithLabelValues(r.URL.Path)).ObserveDuration()
|
||||||
|
if l.handlerFunc != nil {
|
||||||
|
l.handlerFunc(w, r)
|
||||||
|
} else if l.handler != nil {
|
||||||
|
l.handler.ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
log.Error("no handler or handlerFunc configured")
|
||||||
|
}
|
||||||
|
log.Debug("finish")
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon/job"
|
||||||
|
"github.com/zrepl/zrepl/daemon/job/reset"
|
||||||
|
"github.com/zrepl/zrepl/daemon/job/wakeup"
|
||||||
|
"github.com/zrepl/zrepl/daemon/logging"
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
"github.com/zrepl/zrepl/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(conf *config.Config) error {
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
defer cancel()
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigChan
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
outlets, err := logging.OutletsFromConfig(*conf.Global.Logging)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "cannot build logging from config")
|
||||||
|
}
|
||||||
|
|
||||||
|
confJobs, err := job.JobsFromConfig(conf)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "cannot build jobs from config")
|
||||||
|
}
|
||||||
|
|
||||||
|
log := logger.NewLogger(outlets, 1*time.Second)
|
||||||
|
log.Info(version.NewZreplVersionInformation().String())
|
||||||
|
|
||||||
|
for _, job := range confJobs {
|
||||||
|
if IsInternalJobName(job.Name()) {
|
||||||
|
panic(fmt.Sprintf("internal job name used for config job '%s'", job.Name())) //FIXME
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = job.WithLogger(ctx, log)
|
||||||
|
|
||||||
|
jobs := newJobs()
|
||||||
|
|
||||||
|
// start control socket
|
||||||
|
controlJob, err := newControlJob(conf.Global.Control.SockPath, jobs)
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // FIXME
|
||||||
|
}
|
||||||
|
jobs.start(ctx, controlJob, true)
|
||||||
|
|
||||||
|
for i, jc := range conf.Global.Monitoring {
|
||||||
|
var (
|
||||||
|
job job.Job
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch v := jc.Ret.(type) {
|
||||||
|
case *config.PrometheusMonitoring:
|
||||||
|
job, err = newPrometheusJobFromConfig(v)
|
||||||
|
default:
|
||||||
|
return errors.Errorf("unknown monitoring job #%d (type %T)", i, v)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "cannot build monitorin gjob #%d", i)
|
||||||
|
}
|
||||||
|
jobs.start(ctx, job, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("starting daemon")
|
||||||
|
|
||||||
|
// start regular jobs
|
||||||
|
for _, j := range confJobs {
|
||||||
|
jobs.start(ctx, j, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-jobs.wait():
|
||||||
|
log.Info("all jobs finished")
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.WithError(ctx.Err()).Info("context finished")
|
||||||
|
}
|
||||||
|
log.Info("daemon exiting")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type jobs struct {
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
// m protects all fields below it
|
||||||
|
m sync.RWMutex
|
||||||
|
wakeups map[string]wakeup.Func // by Job.Name
|
||||||
|
resets map[string]reset.Func // by Job.Name
|
||||||
|
jobs map[string]job.Job
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJobs() *jobs {
|
||||||
|
return &jobs{
|
||||||
|
wakeups: make(map[string]wakeup.Func),
|
||||||
|
resets: make(map[string]reset.Func),
|
||||||
|
jobs: make(map[string]job.Job),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
logJobField string = "job"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *jobs) wait() <-chan struct{} {
|
||||||
|
ch := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
s.wg.Wait()
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *jobs) status() map[string]*job.Status {
|
||||||
|
s.m.RLock()
|
||||||
|
defer s.m.RUnlock()
|
||||||
|
|
||||||
|
type res struct {
|
||||||
|
name string
|
||||||
|
status *job.Status
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
c := make(chan res, len(s.jobs))
|
||||||
|
for name, j := range s.jobs {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(name string, j job.Job) {
|
||||||
|
defer wg.Done()
|
||||||
|
c <- res{name: name, status: j.Status()}
|
||||||
|
}(name, j)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(c)
|
||||||
|
ret := make(map[string]*job.Status, len(s.jobs))
|
||||||
|
for res := range c {
|
||||||
|
ret[res.name] = res.status
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *jobs) wakeup(job string) error {
|
||||||
|
s.m.RLock()
|
||||||
|
defer s.m.RUnlock()
|
||||||
|
|
||||||
|
wu, ok := s.wakeups[job]
|
||||||
|
if !ok {
|
||||||
|
return errors.Errorf("Job %s does not exist", job)
|
||||||
|
}
|
||||||
|
return wu()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *jobs) reset(job string) error {
|
||||||
|
s.m.RLock()
|
||||||
|
defer s.m.RUnlock()
|
||||||
|
|
||||||
|
wu, ok := s.resets[job]
|
||||||
|
if !ok {
|
||||||
|
return errors.Errorf("Job %s does not exist", job)
|
||||||
|
}
|
||||||
|
return wu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
jobNamePrometheus = "_prometheus"
|
||||||
|
jobNameControl = "_control"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsInternalJobName(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *jobs) start(ctx context.Context, j job.Job, internal bool) {
|
||||||
|
s.m.Lock()
|
||||||
|
defer s.m.Unlock()
|
||||||
|
|
||||||
|
jobLog := job.GetLogger(ctx).
|
||||||
|
WithField(logJobField, j.Name()).
|
||||||
|
WithOutlet(newPrometheusLogOutlet(j.Name()), logger.Debug)
|
||||||
|
jobName := j.Name()
|
||||||
|
if !internal && IsInternalJobName(jobName) {
|
||||||
|
panic(fmt.Sprintf("internal job name used for non-internal job %s", jobName))
|
||||||
|
}
|
||||||
|
if internal && !IsInternalJobName(jobName) {
|
||||||
|
panic(fmt.Sprintf("internal job does not use internal job name %s", jobName))
|
||||||
|
}
|
||||||
|
if _, ok := s.jobs[jobName]; ok {
|
||||||
|
panic(fmt.Sprintf("duplicate job name %s", jobName))
|
||||||
|
}
|
||||||
|
|
||||||
|
j.RegisterMetrics(prometheus.DefaultRegisterer)
|
||||||
|
|
||||||
|
s.jobs[jobName] = j
|
||||||
|
ctx = job.WithLogger(ctx, jobLog)
|
||||||
|
ctx, wakeup := wakeup.Context(ctx)
|
||||||
|
ctx, resetFunc := reset.Context(ctx)
|
||||||
|
s.wakeups[jobName] = wakeup
|
||||||
|
s.resets[jobName] = resetFunc
|
||||||
|
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
jobLog.Info("starting job")
|
||||||
|
defer jobLog.Info("job exited")
|
||||||
|
j.Run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package filters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/endpoint"
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DatasetMapFilter struct {
|
||||||
|
entries []datasetMapFilterEntry
|
||||||
|
|
||||||
|
// if set, only valid filter entries can be added using Add()
|
||||||
|
// and Map() will always return an error
|
||||||
|
filterMode bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type datasetMapFilterEntry struct {
|
||||||
|
path *zfs.DatasetPath
|
||||||
|
// the mapping. since this datastructure acts as both mapping and filter
|
||||||
|
// we have to convert it to the desired rep dynamically
|
||||||
|
mapping string
|
||||||
|
subtreeMatch bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDatasetMapFilter(capacity int, filterMode bool) *DatasetMapFilter {
|
||||||
|
return &DatasetMapFilter{
|
||||||
|
entries: make([]datasetMapFilterEntry, 0, capacity),
|
||||||
|
filterMode: filterMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *DatasetMapFilter) Add(pathPattern, mapping string) (err error) {
|
||||||
|
|
||||||
|
if m.filterMode {
|
||||||
|
if _, err = m.parseDatasetFilterResult(mapping); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assert path glob adheres to spec
|
||||||
|
const SUBTREE_PATTERN string = "<"
|
||||||
|
patternCount := strings.Count(pathPattern, SUBTREE_PATTERN)
|
||||||
|
switch {
|
||||||
|
case patternCount > 1:
|
||||||
|
case patternCount == 1 && !strings.HasSuffix(pathPattern, SUBTREE_PATTERN):
|
||||||
|
err = fmt.Errorf("pattern invalid: only one '<' at end of string allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pathStr := strings.TrimSuffix(pathPattern, SUBTREE_PATTERN)
|
||||||
|
path, err := zfs.NewDatasetPath(pathStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pattern is not a dataset path: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := datasetMapFilterEntry{
|
||||||
|
path: path,
|
||||||
|
mapping: mapping,
|
||||||
|
subtreeMatch: patternCount > 0,
|
||||||
|
}
|
||||||
|
m.entries = append(m.entries, entry)
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the most specific prefix mapping we have
|
||||||
|
//
|
||||||
|
// longer prefix wins over shorter prefix, direct wins over glob
|
||||||
|
func (m DatasetMapFilter) mostSpecificPrefixMapping(path *zfs.DatasetPath) (idx int, found bool) {
|
||||||
|
lcp, lcp_entry_idx := -1, -1
|
||||||
|
direct_idx := -1
|
||||||
|
for e := range m.entries {
|
||||||
|
entry := m.entries[e]
|
||||||
|
ep := m.entries[e].path
|
||||||
|
lep := ep.Length()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case !entry.subtreeMatch && ep.Equal(path):
|
||||||
|
direct_idx = e
|
||||||
|
continue
|
||||||
|
case entry.subtreeMatch && path.HasPrefix(ep) && lep > lcp:
|
||||||
|
lcp = lep
|
||||||
|
lcp_entry_idx = e
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lcp_entry_idx >= 0 || direct_idx >= 0 {
|
||||||
|
found = true
|
||||||
|
switch {
|
||||||
|
case direct_idx >= 0:
|
||||||
|
idx = direct_idx
|
||||||
|
case lcp_entry_idx >= 0:
|
||||||
|
idx = lcp_entry_idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns target == nil if there is no mapping
|
||||||
|
func (m DatasetMapFilter) Map(source *zfs.DatasetPath) (target *zfs.DatasetPath, err error) {
|
||||||
|
|
||||||
|
if m.filterMode {
|
||||||
|
err = fmt.Errorf("using a filter for mapping simply does not work")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mi, hasMapping := m.mostSpecificPrefixMapping(source)
|
||||||
|
if !hasMapping {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
me := m.entries[mi]
|
||||||
|
|
||||||
|
if me.mapping == "" {
|
||||||
|
// Special case treatment: 'foo/bar<' => ''
|
||||||
|
if !me.subtreeMatch {
|
||||||
|
return nil, fmt.Errorf("mapping to '' must be a subtree match")
|
||||||
|
}
|
||||||
|
// ok...
|
||||||
|
} else {
|
||||||
|
if strings.HasPrefix("!", me.mapping) {
|
||||||
|
// reject mapping
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err = zfs.NewDatasetPath(me.mapping)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("mapping target is not a dataset path: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if me.subtreeMatch {
|
||||||
|
// strip common prefix ('<' wildcards are no special case here)
|
||||||
|
extendComps := source.Copy()
|
||||||
|
extendComps.TrimPrefix(me.path)
|
||||||
|
target.Extend(extendComps)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m DatasetMapFilter) Filter(p *zfs.DatasetPath) (pass bool, err error) {
|
||||||
|
|
||||||
|
if !m.filterMode {
|
||||||
|
err = fmt.Errorf("using a mapping as a filter does not work")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mi, hasMapping := m.mostSpecificPrefixMapping(p)
|
||||||
|
if !hasMapping {
|
||||||
|
pass = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
me := m.entries[mi]
|
||||||
|
pass, err = m.parseDatasetFilterResult(me.mapping)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a new filter-only DatasetMapFilter from a mapping
|
||||||
|
// The new filter allows excactly those paths that were not forbidden by the mapping.
|
||||||
|
func (m DatasetMapFilter) InvertedFilter() (inv *DatasetMapFilter, err error) {
|
||||||
|
|
||||||
|
if m.filterMode {
|
||||||
|
err = errors.Errorf("can only invert mappings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inv = &DatasetMapFilter{
|
||||||
|
make([]datasetMapFilterEntry, len(m.entries)),
|
||||||
|
true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, e := range m.entries {
|
||||||
|
inv.entries[i].path, err = zfs.NewDatasetPath(e.mapping)
|
||||||
|
if err != nil {
|
||||||
|
err = errors.Wrapf(err, "mapping cannot be inverted: '%s' is not a dataset path", e.mapping)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inv.entries[i].mapping = MapFilterResultOk
|
||||||
|
inv.entries[i].subtreeMatch = e.subtreeMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME investigate whether we can support more...
|
||||||
|
func (m DatasetMapFilter) Invert() (endpoint.FSMap, error) {
|
||||||
|
|
||||||
|
if m.filterMode {
|
||||||
|
return nil, errors.Errorf("can only invert mappings")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.entries) != 1 {
|
||||||
|
return nil, errors.Errorf("inversion of complicated mappings is not implemented") // FIXME
|
||||||
|
}
|
||||||
|
|
||||||
|
e := m.entries[0]
|
||||||
|
|
||||||
|
inv := &DatasetMapFilter{
|
||||||
|
make([]datasetMapFilterEntry, len(m.entries)),
|
||||||
|
false,
|
||||||
|
}
|
||||||
|
mp, err := zfs.NewDatasetPath(e.mapping)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
inv.entries[0] = datasetMapFilterEntry{
|
||||||
|
path: mp,
|
||||||
|
mapping: e.path.ToString(),
|
||||||
|
subtreeMatch: e.subtreeMatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new DatasetMapFilter in filter mode from a mapping
|
||||||
|
// All accepting mapping results are mapped to accepting filter results
|
||||||
|
// All rejecting mapping results are mapped to rejecting filter results
|
||||||
|
func (m DatasetMapFilter) AsFilter() endpoint.FSFilter {
|
||||||
|
|
||||||
|
f := &DatasetMapFilter{
|
||||||
|
make([]datasetMapFilterEntry, len(m.entries)),
|
||||||
|
true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, e := range m.entries {
|
||||||
|
var newe datasetMapFilterEntry = e
|
||||||
|
if strings.HasPrefix(newe.mapping, "!") {
|
||||||
|
newe.mapping = MapFilterResultOmit
|
||||||
|
} else {
|
||||||
|
newe.mapping = MapFilterResultOk
|
||||||
|
}
|
||||||
|
f.entries[i] = newe
|
||||||
|
}
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
MapFilterResultOk string = "ok"
|
||||||
|
MapFilterResultOmit string = "!"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse a dataset filter result
|
||||||
|
func (m DatasetMapFilter) parseDatasetFilterResult(result string) (pass bool, err error) {
|
||||||
|
l := strings.ToLower(result)
|
||||||
|
if l == MapFilterResultOk {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if l == MapFilterResultOmit {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("'%s' is not a valid filter result", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DatasetMapFilterFromConfig(in map[string]bool) (f *DatasetMapFilter, err error) {
|
||||||
|
|
||||||
|
f = NewDatasetMapFilter(len(in), true)
|
||||||
|
for pathPattern, accept := range in {
|
||||||
|
mapping := MapFilterResultOmit
|
||||||
|
if accept {
|
||||||
|
mapping = MapFilterResultOk
|
||||||
|
}
|
||||||
|
if err = f.Add(pathPattern, mapping); err != nil {
|
||||||
|
err = fmt.Errorf("invalid mapping entry ['%s':'%s']: %s", pathPattern, mapping, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package filters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AnyFSVFilter struct{}
|
||||||
|
|
||||||
|
func NewAnyFSVFilter() AnyFSVFilter {
|
||||||
|
return AnyFSVFilter{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ zfs.FilesystemVersionFilter = AnyFSVFilter{}
|
||||||
|
|
||||||
|
func (AnyFSVFilter) Filter(t zfs.VersionType, name string) (accept bool, err error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrefixFilter struct {
|
||||||
|
prefix string
|
||||||
|
fstype zfs.VersionType
|
||||||
|
fstypeSet bool // optionals anyone?
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ zfs.FilesystemVersionFilter = &PrefixFilter{}
|
||||||
|
|
||||||
|
func NewPrefixFilter(prefix string) *PrefixFilter {
|
||||||
|
return &PrefixFilter{prefix: prefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTypedPrefixFilter(prefix string, versionType zfs.VersionType) *PrefixFilter {
|
||||||
|
return &PrefixFilter{prefix, versionType, true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *PrefixFilter) Filter(t zfs.VersionType, name string) (accept bool, err error) {
|
||||||
|
fstypeMatches := (!f.fstypeSet || t == f.fstype)
|
||||||
|
prefixMatches := strings.HasPrefix(name, f.prefix)
|
||||||
|
return fstypeMatches && prefixMatches, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon/filters"
|
||||||
|
"github.com/zrepl/zrepl/daemon/job/reset"
|
||||||
|
"github.com/zrepl/zrepl/daemon/job/wakeup"
|
||||||
|
"github.com/zrepl/zrepl/daemon/logging"
|
||||||
|
"github.com/zrepl/zrepl/daemon/pruner"
|
||||||
|
"github.com/zrepl/zrepl/daemon/snapper"
|
||||||
|
"github.com/zrepl/zrepl/endpoint"
|
||||||
|
"github.com/zrepl/zrepl/replication"
|
||||||
|
"github.com/zrepl/zrepl/replication/driver"
|
||||||
|
"github.com/zrepl/zrepl/replication/logic"
|
||||||
|
"github.com/zrepl/zrepl/replication/report"
|
||||||
|
"github.com/zrepl/zrepl/rpc"
|
||||||
|
"github.com/zrepl/zrepl/transport"
|
||||||
|
"github.com/zrepl/zrepl/transport/fromconfig"
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActiveSide struct {
|
||||||
|
mode activeMode
|
||||||
|
name string
|
||||||
|
connecter transport.Connecter
|
||||||
|
|
||||||
|
prunerFactory *pruner.PrunerFactory
|
||||||
|
|
||||||
|
promRepStateSecs *prometheus.HistogramVec // labels: state
|
||||||
|
promPruneSecs *prometheus.HistogramVec // labels: prune_side
|
||||||
|
promBytesReplicated *prometheus.CounterVec // labels: filesystem
|
||||||
|
|
||||||
|
tasksMtx sync.Mutex
|
||||||
|
tasks activeSideTasks
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:generate enumer -type=ActiveSideState
|
||||||
|
type ActiveSideState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActiveSideReplicating ActiveSideState = 1 << iota
|
||||||
|
ActiveSidePruneSender
|
||||||
|
ActiveSidePruneReceiver
|
||||||
|
ActiveSideDone // also errors
|
||||||
|
)
|
||||||
|
|
||||||
|
type activeSideTasks struct {
|
||||||
|
state ActiveSideState
|
||||||
|
|
||||||
|
// valid for state ActiveSideReplicating, ActiveSidePruneSender, ActiveSidePruneReceiver, ActiveSideDone
|
||||||
|
replicationReport driver.ReportFunc
|
||||||
|
replicationCancel context.CancelFunc
|
||||||
|
|
||||||
|
// valid for state ActiveSidePruneSender, ActiveSidePruneReceiver, ActiveSideDone
|
||||||
|
prunerSender, prunerReceiver *pruner.Pruner
|
||||||
|
|
||||||
|
// valid for state ActiveSidePruneReceiver, ActiveSideDone
|
||||||
|
prunerSenderCancel, prunerReceiverCancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ActiveSide) updateTasks(u func(*activeSideTasks)) activeSideTasks {
|
||||||
|
a.tasksMtx.Lock()
|
||||||
|
defer a.tasksMtx.Unlock()
|
||||||
|
copy := a.tasks
|
||||||
|
if u == nil {
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
u(©)
|
||||||
|
a.tasks = copy
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
type activeMode interface {
|
||||||
|
ConnectEndpoints(rpcLoggers rpc.Loggers, connecter transport.Connecter)
|
||||||
|
DisconnectEndpoints()
|
||||||
|
SenderReceiver() (logic.Sender, logic.Receiver)
|
||||||
|
Type() Type
|
||||||
|
RunPeriodic(ctx context.Context, wakeUpCommon chan<- struct{})
|
||||||
|
ResetConnectBackoff()
|
||||||
|
}
|
||||||
|
|
||||||
|
type modePush struct {
|
||||||
|
setupMtx sync.Mutex
|
||||||
|
sender *endpoint.Sender
|
||||||
|
receiver *rpc.Client
|
||||||
|
fsfilter endpoint.FSFilter
|
||||||
|
snapper *snapper.PeriodicOrManual
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modePush) ConnectEndpoints(loggers rpc.Loggers, connecter transport.Connecter) {
|
||||||
|
m.setupMtx.Lock()
|
||||||
|
defer m.setupMtx.Unlock()
|
||||||
|
if m.receiver != nil || m.sender != nil {
|
||||||
|
panic("inconsistent use of ConnectEndpoints and DisconnectEndpoints")
|
||||||
|
}
|
||||||
|
m.sender = endpoint.NewSender(m.fsfilter)
|
||||||
|
m.receiver = rpc.NewClient(connecter, loggers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modePush) DisconnectEndpoints() {
|
||||||
|
m.setupMtx.Lock()
|
||||||
|
defer m.setupMtx.Unlock()
|
||||||
|
m.receiver.Close()
|
||||||
|
m.sender = nil
|
||||||
|
m.receiver = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modePush) SenderReceiver() (logic.Sender, logic.Receiver) {
|
||||||
|
m.setupMtx.Lock()
|
||||||
|
defer m.setupMtx.Unlock()
|
||||||
|
return m.sender, m.receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modePush) Type() Type { return TypePush }
|
||||||
|
|
||||||
|
func (m *modePush) RunPeriodic(ctx context.Context, wakeUpCommon chan<- struct{}) {
|
||||||
|
m.snapper.Run(ctx, wakeUpCommon)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modePush) ResetConnectBackoff() {
|
||||||
|
m.setupMtx.Lock()
|
||||||
|
defer m.setupMtx.Unlock()
|
||||||
|
if m.receiver != nil {
|
||||||
|
m.receiver.ResetConnectBackoff()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modePushFromConfig(g *config.Global, in *config.PushJob) (*modePush, error) {
|
||||||
|
m := &modePush{}
|
||||||
|
fsf, err := filters.DatasetMapFilterFromConfig(in.Filesystems)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannnot build filesystem filter")
|
||||||
|
}
|
||||||
|
m.fsfilter = fsf
|
||||||
|
|
||||||
|
if m.snapper, err = snapper.FromConfig(g, fsf, in.Snapshotting); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot build snapper")
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type modePull struct {
|
||||||
|
setupMtx sync.Mutex
|
||||||
|
receiver *endpoint.Receiver
|
||||||
|
sender *rpc.Client
|
||||||
|
rootFS *zfs.DatasetPath
|
||||||
|
interval config.PositiveDurationOrManual
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modePull) ConnectEndpoints(loggers rpc.Loggers, connecter transport.Connecter) {
|
||||||
|
m.setupMtx.Lock()
|
||||||
|
defer m.setupMtx.Unlock()
|
||||||
|
if m.receiver != nil || m.sender != nil {
|
||||||
|
panic("inconsistent use of ConnectEndpoints and DisconnectEndpoints")
|
||||||
|
}
|
||||||
|
m.receiver = endpoint.NewReceiver(m.rootFS, false)
|
||||||
|
m.sender = rpc.NewClient(connecter, loggers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modePull) DisconnectEndpoints() {
|
||||||
|
m.setupMtx.Lock()
|
||||||
|
defer m.setupMtx.Unlock()
|
||||||
|
m.sender.Close()
|
||||||
|
m.sender = nil
|
||||||
|
m.receiver = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modePull) SenderReceiver() (logic.Sender, logic.Receiver) {
|
||||||
|
m.setupMtx.Lock()
|
||||||
|
defer m.setupMtx.Unlock()
|
||||||
|
return m.sender, m.receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*modePull) Type() Type { return TypePull }
|
||||||
|
|
||||||
|
func (m *modePull) RunPeriodic(ctx context.Context, wakeUpCommon chan<- struct{}) {
|
||||||
|
if m.interval.Manual {
|
||||||
|
GetLogger(ctx).Info("manual pull configured, periodic pull disabled")
|
||||||
|
// "waiting for wakeups" is printed in common ActiveSide.do
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t := time.NewTicker(m.interval.Interval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
select {
|
||||||
|
case wakeUpCommon <- struct{}{}:
|
||||||
|
default:
|
||||||
|
GetLogger(ctx).
|
||||||
|
WithField("pull_interval", m.interval).
|
||||||
|
Warn("pull job took longer than pull interval")
|
||||||
|
wakeUpCommon <- struct{}{} // block anyways, to queue up the wakeup
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modePull) ResetConnectBackoff() {
|
||||||
|
m.setupMtx.Lock()
|
||||||
|
defer m.setupMtx.Unlock()
|
||||||
|
if m.sender != nil {
|
||||||
|
m.sender.ResetConnectBackoff()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modePullFromConfig(g *config.Global, in *config.PullJob) (m *modePull, err error) {
|
||||||
|
m = &modePull{}
|
||||||
|
m.interval = in.Interval
|
||||||
|
|
||||||
|
m.rootFS, err = zfs.NewDatasetPath(in.RootFS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("RootFS is not a valid zfs filesystem path")
|
||||||
|
}
|
||||||
|
if m.rootFS.Length() <= 0 {
|
||||||
|
return nil, errors.New("RootFS must not be empty") // duplicates error check of receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activeSide(g *config.Global, in *config.ActiveJob, mode activeMode) (j *ActiveSide, err error) {
|
||||||
|
|
||||||
|
j = &ActiveSide{mode: mode}
|
||||||
|
j.name = in.Name
|
||||||
|
j.promRepStateSecs = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: "zrepl",
|
||||||
|
Subsystem: "replication",
|
||||||
|
Name: "state_time",
|
||||||
|
Help: "seconds spent during replication",
|
||||||
|
ConstLabels: prometheus.Labels{"zrepl_job": j.name},
|
||||||
|
}, []string{"state"})
|
||||||
|
j.promBytesReplicated = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "zrepl",
|
||||||
|
Subsystem: "replication",
|
||||||
|
Name: "bytes_replicated",
|
||||||
|
Help: "number of bytes replicated from sender to receiver per filesystem",
|
||||||
|
ConstLabels: prometheus.Labels{"zrepl_job": j.name},
|
||||||
|
}, []string{"filesystem"})
|
||||||
|
|
||||||
|
j.connecter, err = fromconfig.ConnecterFromConfig(g, in.Connect)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot build client")
|
||||||
|
}
|
||||||
|
|
||||||
|
j.promPruneSecs = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: "zrepl",
|
||||||
|
Subsystem: "pruning",
|
||||||
|
Name: "time",
|
||||||
|
Help: "seconds spent in pruner",
|
||||||
|
ConstLabels: prometheus.Labels{"zrepl_job": j.name},
|
||||||
|
}, []string{"prune_side"})
|
||||||
|
j.prunerFactory, err = pruner.NewPrunerFactory(in.Pruning, j.promPruneSecs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *ActiveSide) RegisterMetrics(registerer prometheus.Registerer) {
|
||||||
|
registerer.MustRegister(j.promRepStateSecs)
|
||||||
|
registerer.MustRegister(j.promPruneSecs)
|
||||||
|
registerer.MustRegister(j.promBytesReplicated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *ActiveSide) Name() string { return j.name }
|
||||||
|
|
||||||
|
type ActiveSideStatus struct {
|
||||||
|
Replication *report.Report
|
||||||
|
PruningSender, PruningReceiver *pruner.Report
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *ActiveSide) Status() *Status {
|
||||||
|
tasks := j.updateTasks(nil)
|
||||||
|
|
||||||
|
s := &ActiveSideStatus{}
|
||||||
|
t := j.mode.Type()
|
||||||
|
if tasks.replicationReport != nil {
|
||||||
|
s.Replication = tasks.replicationReport()
|
||||||
|
}
|
||||||
|
if tasks.prunerSender != nil {
|
||||||
|
s.PruningSender = tasks.prunerSender.Report()
|
||||||
|
}
|
||||||
|
if tasks.prunerReceiver != nil {
|
||||||
|
s.PruningReceiver = tasks.prunerReceiver.Report()
|
||||||
|
}
|
||||||
|
return &Status{Type: t, JobSpecific: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *ActiveSide) OwnedDatasetSubtreeRoot() (rfs *zfs.DatasetPath, ok bool) {
|
||||||
|
pull, ok := j.mode.(*modePull)
|
||||||
|
if !ok {
|
||||||
|
_ = j.mode.(*modePush) // make sure we didn't introduce a new job type
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return pull.rootFS.Copy(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *ActiveSide) Run(ctx context.Context) {
|
||||||
|
log := GetLogger(ctx)
|
||||||
|
ctx = logging.WithSubsystemLoggers(ctx, log)
|
||||||
|
|
||||||
|
defer log.Info("job exiting")
|
||||||
|
|
||||||
|
periodicDone := make(chan struct{})
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
go j.mode.RunPeriodic(ctx, periodicDone)
|
||||||
|
|
||||||
|
invocationCount := 0
|
||||||
|
outer:
|
||||||
|
for {
|
||||||
|
log.Info("wait for wakeups")
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.WithError(ctx.Err()).Info("context")
|
||||||
|
break outer
|
||||||
|
|
||||||
|
case <-wakeup.Wait(ctx):
|
||||||
|
j.mode.ResetConnectBackoff()
|
||||||
|
case <-periodicDone:
|
||||||
|
}
|
||||||
|
invocationCount++
|
||||||
|
invLog := log.WithField("invocation", invocationCount)
|
||||||
|
j.do(WithLogger(ctx, invLog))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *ActiveSide) do(ctx context.Context) {
|
||||||
|
|
||||||
|
log := GetLogger(ctx)
|
||||||
|
ctx = logging.WithSubsystemLoggers(ctx, log)
|
||||||
|
loggers := rpc.GetLoggersOrPanic(ctx) // filled by WithSubsystemLoggers
|
||||||
|
j.mode.ConnectEndpoints(loggers, j.connecter)
|
||||||
|
defer j.mode.DisconnectEndpoints()
|
||||||
|
|
||||||
|
// allow cancellation of an invocation (this function)
|
||||||
|
ctx, cancelThisRun := context.WithCancel(ctx)
|
||||||
|
defer cancelThisRun()
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-reset.Wait(ctx):
|
||||||
|
log.Info("reset received, cancelling current invocation")
|
||||||
|
cancelThisRun()
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
sender, receiver := j.mode.SenderReceiver()
|
||||||
|
|
||||||
|
{
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
ctx, repCancel := context.WithCancel(ctx)
|
||||||
|
var repWait driver.WaitFunc
|
||||||
|
j.updateTasks(func(tasks *activeSideTasks) {
|
||||||
|
// reset it
|
||||||
|
*tasks = activeSideTasks{}
|
||||||
|
tasks.replicationCancel = repCancel
|
||||||
|
tasks.replicationReport, repWait = replication.Do(
|
||||||
|
ctx, logic.NewPlanner(j.promRepStateSecs, j.promBytesReplicated, sender, receiver),
|
||||||
|
)
|
||||||
|
tasks.state = ActiveSideReplicating
|
||||||
|
})
|
||||||
|
log.Info("start replication")
|
||||||
|
repWait(true) // wait blocking
|
||||||
|
repCancel() // always cancel to free up context resources
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
ctx, senderCancel := context.WithCancel(ctx)
|
||||||
|
tasks := j.updateTasks(func(tasks *activeSideTasks) {
|
||||||
|
tasks.prunerSender = j.prunerFactory.BuildSenderPruner(ctx, sender, sender)
|
||||||
|
tasks.prunerSenderCancel = senderCancel
|
||||||
|
tasks.state = ActiveSidePruneSender
|
||||||
|
})
|
||||||
|
log.Info("start pruning sender")
|
||||||
|
tasks.prunerSender.Prune()
|
||||||
|
log.Info("finished pruning sender")
|
||||||
|
senderCancel()
|
||||||
|
}
|
||||||
|
{
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
ctx, receiverCancel := context.WithCancel(ctx)
|
||||||
|
tasks := j.updateTasks(func(tasks *activeSideTasks) {
|
||||||
|
tasks.prunerReceiver = j.prunerFactory.BuildReceiverPruner(ctx, receiver, sender)
|
||||||
|
tasks.prunerReceiverCancel = receiverCancel
|
||||||
|
tasks.state = ActiveSidePruneReceiver
|
||||||
|
})
|
||||||
|
log.Info("start pruning receiver")
|
||||||
|
tasks.prunerReceiver.Prune()
|
||||||
|
log.Info("finished pruning receiver")
|
||||||
|
receiverCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
j.updateTasks(func(tasks *activeSideTasks) {
|
||||||
|
tasks.state = ActiveSideDone
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Code generated by "enumer -type=ActiveSideState"; DO NOT EDIT.
|
||||||
|
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
_ActiveSideStateName_0 = "ActiveSideReplicatingActiveSidePruneSender"
|
||||||
|
_ActiveSideStateName_1 = "ActiveSidePruneReceiver"
|
||||||
|
_ActiveSideStateName_2 = "ActiveSideDone"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ActiveSideStateIndex_0 = [...]uint8{0, 21, 42}
|
||||||
|
_ActiveSideStateIndex_1 = [...]uint8{0, 23}
|
||||||
|
_ActiveSideStateIndex_2 = [...]uint8{0, 14}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i ActiveSideState) String() string {
|
||||||
|
switch {
|
||||||
|
case 1 <= i && i <= 2:
|
||||||
|
i -= 1
|
||||||
|
return _ActiveSideStateName_0[_ActiveSideStateIndex_0[i]:_ActiveSideStateIndex_0[i+1]]
|
||||||
|
case i == 4:
|
||||||
|
return _ActiveSideStateName_1
|
||||||
|
case i == 8:
|
||||||
|
return _ActiveSideStateName_2
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("ActiveSideState(%d)", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ActiveSideStateValues = []ActiveSideState{1, 2, 4, 8}
|
||||||
|
|
||||||
|
var _ActiveSideStateNameToValueMap = map[string]ActiveSideState{
|
||||||
|
_ActiveSideStateName_0[0:21]: 1,
|
||||||
|
_ActiveSideStateName_0[21:42]: 2,
|
||||||
|
_ActiveSideStateName_1[0:23]: 4,
|
||||||
|
_ActiveSideStateName_2[0:14]: 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveSideStateString retrieves an enum value from the enum constants string name.
|
||||||
|
// Throws an error if the param is not part of the enum.
|
||||||
|
func ActiveSideStateString(s string) (ActiveSideState, error) {
|
||||||
|
if val, ok := _ActiveSideStateNameToValueMap[s]; ok {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%s does not belong to ActiveSideState values", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveSideStateValues returns all values of the enum
|
||||||
|
func ActiveSideStateValues() []ActiveSideState {
|
||||||
|
return _ActiveSideStateValues
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAActiveSideState returns "true" if the value is listed in the enum definition. "false" otherwise
|
||||||
|
func (i ActiveSideState) IsAActiveSideState() bool {
|
||||||
|
for _, v := range _ActiveSideStateValues {
|
||||||
|
if i == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func JobsFromConfig(c *config.Config) ([]Job, error) {
|
||||||
|
js := make([]Job, len(c.Jobs))
|
||||||
|
for i := range c.Jobs {
|
||||||
|
j, err := buildJob(c.Global, c.Jobs[i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if j == nil || j.Name() == "" {
|
||||||
|
panic(fmt.Sprintf("implementation error: job builder returned nil job type %T", c.Jobs[i].Ret))
|
||||||
|
}
|
||||||
|
js[i] = j
|
||||||
|
}
|
||||||
|
|
||||||
|
// receiving-side root filesystems must not overlap
|
||||||
|
{
|
||||||
|
rfss := make([]string, 0, len(js))
|
||||||
|
for _, j := range js {
|
||||||
|
jrfs, ok := j.OwnedDatasetSubtreeRoot()
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rfss = append(rfss, jrfs.ToString())
|
||||||
|
}
|
||||||
|
if err := validateReceivingSidesDoNotOverlap(rfss); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return js, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildJob(c *config.Global, in config.JobEnum) (j Job, err error) {
|
||||||
|
cannotBuildJob := func(e error, name string) (Job, error) {
|
||||||
|
return nil, errors.Wrapf(e, "cannot build job %q", name)
|
||||||
|
}
|
||||||
|
// FIXME prettify this
|
||||||
|
switch v := in.Ret.(type) {
|
||||||
|
case *config.SinkJob:
|
||||||
|
m, err := modeSinkFromConfig(c, v)
|
||||||
|
if err != nil {
|
||||||
|
return cannotBuildJob(err, v.Name)
|
||||||
|
}
|
||||||
|
j, err = passiveSideFromConfig(c, &v.PassiveJob, m)
|
||||||
|
if err != nil {
|
||||||
|
return cannotBuildJob(err, v.Name)
|
||||||
|
}
|
||||||
|
case *config.SourceJob:
|
||||||
|
m, err := modeSourceFromConfig(c, v)
|
||||||
|
if err != nil {
|
||||||
|
return cannotBuildJob(err, v.Name)
|
||||||
|
}
|
||||||
|
j, err = passiveSideFromConfig(c, &v.PassiveJob, m)
|
||||||
|
if err != nil {
|
||||||
|
return cannotBuildJob(err, v.Name)
|
||||||
|
}
|
||||||
|
case *config.SnapJob:
|
||||||
|
j, err = snapJobFromConfig(c, v)
|
||||||
|
if err != nil {
|
||||||
|
return cannotBuildJob(err, v.Name)
|
||||||
|
}
|
||||||
|
case *config.PushJob:
|
||||||
|
m, err := modePushFromConfig(c, v)
|
||||||
|
if err != nil {
|
||||||
|
return cannotBuildJob(err, v.Name)
|
||||||
|
}
|
||||||
|
j, err = activeSide(c, &v.ActiveJob, m)
|
||||||
|
if err != nil {
|
||||||
|
return cannotBuildJob(err, v.Name)
|
||||||
|
}
|
||||||
|
case *config.PullJob:
|
||||||
|
m, err := modePullFromConfig(c, v)
|
||||||
|
if err != nil {
|
||||||
|
return cannotBuildJob(err, v.Name)
|
||||||
|
}
|
||||||
|
j, err = activeSide(c, &v.ActiveJob, m)
|
||||||
|
if err != nil {
|
||||||
|
return cannotBuildJob(err, v.Name)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("implementation error: unknown job type %T", v))
|
||||||
|
}
|
||||||
|
return j, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateReceivingSidesDoNotOverlap(receivingRootFSs []string) error {
|
||||||
|
if len(receivingRootFSs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rfss := make([]string, len(receivingRootFSs))
|
||||||
|
copy(rfss, receivingRootFSs)
|
||||||
|
sort.Slice(rfss, func(i, j int) bool {
|
||||||
|
return strings.Compare(rfss[i], rfss[j]) == -1
|
||||||
|
})
|
||||||
|
// idea:
|
||||||
|
// no path in rfss must be prefix of another
|
||||||
|
//
|
||||||
|
// rfss is now lexicographically sorted, which means that
|
||||||
|
// if i is prefix of j, i < j (in lexicographical order)
|
||||||
|
// thus,
|
||||||
|
// if any i is prefix of i+n (n >= 1), there is overlap
|
||||||
|
for i := 0; i < len(rfss)-1; i++ {
|
||||||
|
if strings.HasPrefix(rfss[i+1], rfss[i]) {
|
||||||
|
return fmt.Errorf("receiving jobs with overlapping root filesystems are forbidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateReceivingSidesDoNotOverlap(t *testing.T) {
|
||||||
|
type testCase struct {
|
||||||
|
err bool
|
||||||
|
input []string
|
||||||
|
}
|
||||||
|
tcs := []testCase{
|
||||||
|
{false, nil},
|
||||||
|
{false, []string{}},
|
||||||
|
{false, []string{""}}, // not our job to determine valid paths
|
||||||
|
{false, []string{"a"}},
|
||||||
|
{false, []string{"some/path"}},
|
||||||
|
{false, []string{"zroot/sink1", "zroot/sink2", "zroot/sink3"}},
|
||||||
|
{true, []string{"zroot/b", "zroot/b"}},
|
||||||
|
{true, []string{"zroot/foo", "zroot/foo/bar", "zroot/baz"}},
|
||||||
|
{false, []string{"a/x", "b/x"}},
|
||||||
|
{false, []string{"a", "b"}},
|
||||||
|
{true, []string{"a", "a"}},
|
||||||
|
{true, []string{"a/x/y", "a/x"}},
|
||||||
|
{true, []string{"a/x", "a/x/y"}},
|
||||||
|
{true, []string{"a/x", "b/x", "a/x/y"}},
|
||||||
|
{true, []string{"a", "a/b", "a/c", "a/b"}},
|
||||||
|
{true, []string{"a/b", "a/c", "a/b", "a/d", "a/c"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tcs {
|
||||||
|
t.Logf("input: %v", tc.input)
|
||||||
|
err := validateReceivingSidesDoNotOverlap(tc.input)
|
||||||
|
if tc.err {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logger = logger.Logger
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
contextKeyLog contextKey = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetLogger(ctx context.Context) Logger {
|
||||||
|
if l, ok := ctx.Value(contextKeyLog).(Logger); ok {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return logger.NewNullLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithLogger(ctx context.Context, l Logger) context.Context {
|
||||||
|
return context.WithValue(ctx, contextKeyLog, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job interface {
|
||||||
|
Name() string
|
||||||
|
Run(ctx context.Context)
|
||||||
|
Status() *Status
|
||||||
|
RegisterMetrics(registerer prometheus.Registerer)
|
||||||
|
// Jobs that return a subtree of the dataset hierarchy
|
||||||
|
// must return the root of that subtree as rfs and ok = true
|
||||||
|
OwnedDatasetSubtreeRoot() (rfs *zfs.DatasetPath, ok bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Type string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeInternal Type = "internal"
|
||||||
|
TypeSnap Type = "snap"
|
||||||
|
TypePush Type = "push"
|
||||||
|
TypeSink Type = "sink"
|
||||||
|
TypePull Type = "pull"
|
||||||
|
TypeSource Type = "source"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
Type Type
|
||||||
|
JobSpecific interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Status) MarshalJSON() ([]byte, error) {
|
||||||
|
typeJson, err := json.Marshal(s.Type)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jobJSON, err := json.Marshal(s.JobSpecific)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := map[string]json.RawMessage{
|
||||||
|
"type": typeJson,
|
||||||
|
string(s.Type): jobJSON,
|
||||||
|
}
|
||||||
|
return json.Marshal(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Status) UnmarshalJSON(in []byte) (err error) {
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(in, &m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tJSON, ok := m["type"]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("field 'type' not found")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(tJSON, &s.Type); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key := string(s.Type)
|
||||||
|
jobJSON, ok := m[key]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("field '%s', not found", key)
|
||||||
|
}
|
||||||
|
switch s.Type {
|
||||||
|
case TypeSnap:
|
||||||
|
var st SnapJobStatus
|
||||||
|
err = json.Unmarshal(jobJSON, &st)
|
||||||
|
s.JobSpecific = &st
|
||||||
|
|
||||||
|
case TypePull:
|
||||||
|
fallthrough
|
||||||
|
case TypePush:
|
||||||
|
var st ActiveSideStatus
|
||||||
|
err = json.Unmarshal(jobJSON, &st)
|
||||||
|
s.JobSpecific = &st
|
||||||
|
|
||||||
|
case TypeSource:
|
||||||
|
fallthrough
|
||||||
|
case TypeSink:
|
||||||
|
var st PassiveStatus
|
||||||
|
err = json.Unmarshal(jobJSON, &st)
|
||||||
|
s.JobSpecific = &st
|
||||||
|
|
||||||
|
case TypeInternal:
|
||||||
|
// internal jobs do not report specifics
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unknown job type '%s'", key)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon/filters"
|
||||||
|
"github.com/zrepl/zrepl/daemon/logging"
|
||||||
|
"github.com/zrepl/zrepl/daemon/snapper"
|
||||||
|
"github.com/zrepl/zrepl/endpoint"
|
||||||
|
"github.com/zrepl/zrepl/rpc"
|
||||||
|
"github.com/zrepl/zrepl/transport"
|
||||||
|
"github.com/zrepl/zrepl/transport/fromconfig"
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PassiveSide struct {
|
||||||
|
mode passiveMode
|
||||||
|
name string
|
||||||
|
listen transport.AuthenticatedListenerFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
type passiveMode interface {
|
||||||
|
Handler() rpc.Handler
|
||||||
|
RunPeriodic(ctx context.Context)
|
||||||
|
Type() Type
|
||||||
|
}
|
||||||
|
|
||||||
|
type modeSink struct {
|
||||||
|
rootDataset *zfs.DatasetPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modeSink) Type() Type { return TypeSink }
|
||||||
|
|
||||||
|
func (m *modeSink) Handler() rpc.Handler {
|
||||||
|
return endpoint.NewReceiver(m.rootDataset, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modeSink) RunPeriodic(_ context.Context) {}
|
||||||
|
|
||||||
|
func modeSinkFromConfig(g *config.Global, in *config.SinkJob) (m *modeSink, err error) {
|
||||||
|
m = &modeSink{}
|
||||||
|
m.rootDataset, err = zfs.NewDatasetPath(in.RootFS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("root dataset is not a valid zfs filesystem path")
|
||||||
|
}
|
||||||
|
if m.rootDataset.Length() <= 0 {
|
||||||
|
return nil, errors.New("root dataset must not be empty") // duplicates error check of receiver
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type modeSource struct {
|
||||||
|
fsfilter zfs.DatasetFilter
|
||||||
|
snapper *snapper.PeriodicOrManual
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeSourceFromConfig(g *config.Global, in *config.SourceJob) (m *modeSource, err error) {
|
||||||
|
// FIXME exact dedup of modePush
|
||||||
|
m = &modeSource{}
|
||||||
|
fsf, err := filters.DatasetMapFilterFromConfig(in.Filesystems)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannnot build filesystem filter")
|
||||||
|
}
|
||||||
|
m.fsfilter = fsf
|
||||||
|
|
||||||
|
if m.snapper, err = snapper.FromConfig(g, fsf, in.Snapshotting); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot build snapper")
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modeSource) Type() Type { return TypeSource }
|
||||||
|
|
||||||
|
func (m *modeSource) Handler() rpc.Handler {
|
||||||
|
return endpoint.NewSender(m.fsfilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *modeSource) RunPeriodic(ctx context.Context) {
|
||||||
|
m.snapper.Run(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func passiveSideFromConfig(g *config.Global, in *config.PassiveJob, mode passiveMode) (s *PassiveSide, err error) {
|
||||||
|
|
||||||
|
s = &PassiveSide{mode: mode, name: in.Name}
|
||||||
|
if s.listen, err = fromconfig.ListenerFactoryFromConfig(g, in.Serve); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot build listener factory")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *PassiveSide) Name() string { return j.name }
|
||||||
|
|
||||||
|
type PassiveStatus struct{}
|
||||||
|
|
||||||
|
func (s *PassiveSide) Status() *Status {
|
||||||
|
return &Status{Type: s.mode.Type()} // FIXME PassiveStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *PassiveSide) OwnedDatasetSubtreeRoot() (rfs *zfs.DatasetPath, ok bool) {
|
||||||
|
sink, ok := j.mode.(*modeSink)
|
||||||
|
if !ok {
|
||||||
|
_ = j.mode.(*modeSource) // make sure we didn't introduce a new job type
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return sink.rootDataset.Copy(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*PassiveSide) RegisterMetrics(registerer prometheus.Registerer) {}
|
||||||
|
|
||||||
|
func (j *PassiveSide) Run(ctx context.Context) {
|
||||||
|
|
||||||
|
log := GetLogger(ctx)
|
||||||
|
defer log.Info("job exiting")
|
||||||
|
ctx = logging.WithSubsystemLoggers(ctx, log)
|
||||||
|
{
|
||||||
|
ctx, cancel := context.WithCancel(ctx) // shadowing
|
||||||
|
defer cancel()
|
||||||
|
go j.mode.RunPeriodic(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := j.mode.Handler()
|
||||||
|
if handler == nil {
|
||||||
|
panic(fmt.Sprintf("implementation error: j.mode.Handler() returned nil: %#v", j))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxInterceptor := func(handlerCtx context.Context) context.Context {
|
||||||
|
return logging.WithSubsystemLoggers(handlerCtx, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcLoggers := rpc.GetLoggersOrPanic(ctx) // WithSubsystemLoggers above
|
||||||
|
server := rpc.NewServer(handler, rpcLoggers, ctxInterceptor)
|
||||||
|
|
||||||
|
listener, err := j.listen()
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("cannot listen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.Serve(ctx, listener)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package reset
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const contextKeyReset contextKey = iota
|
||||||
|
|
||||||
|
func Wait(ctx context.Context) <-chan struct{} {
|
||||||
|
wc, ok := ctx.Value(contextKeyReset).(chan struct{})
|
||||||
|
if !ok {
|
||||||
|
wc = make(chan struct{})
|
||||||
|
}
|
||||||
|
return wc
|
||||||
|
}
|
||||||
|
|
||||||
|
type Func func() error
|
||||||
|
|
||||||
|
var AlreadyReset = errors.New("already reset")
|
||||||
|
|
||||||
|
func Context(ctx context.Context) (context.Context, Func) {
|
||||||
|
wc := make(chan struct{})
|
||||||
|
wuf := func() error {
|
||||||
|
select {
|
||||||
|
case wc <- struct{}{}:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return AlreadyReset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, contextKeyReset, wc), wuf
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon/filters"
|
||||||
|
"github.com/zrepl/zrepl/daemon/job/wakeup"
|
||||||
|
"github.com/zrepl/zrepl/daemon/logging"
|
||||||
|
"github.com/zrepl/zrepl/daemon/pruner"
|
||||||
|
"github.com/zrepl/zrepl/daemon/snapper"
|
||||||
|
"github.com/zrepl/zrepl/endpoint"
|
||||||
|
"github.com/zrepl/zrepl/replication/logic/pdu"
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SnapJob struct {
|
||||||
|
name string
|
||||||
|
fsfilter zfs.DatasetFilter
|
||||||
|
snapper *snapper.PeriodicOrManual
|
||||||
|
|
||||||
|
prunerFactory *pruner.LocalPrunerFactory
|
||||||
|
|
||||||
|
promPruneSecs *prometheus.HistogramVec // labels: prune_side
|
||||||
|
|
||||||
|
pruner *pruner.Pruner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *SnapJob) Name() string { return j.name }
|
||||||
|
|
||||||
|
func (j *SnapJob) Type() Type { return TypeSnap }
|
||||||
|
|
||||||
|
func snapJobFromConfig(g *config.Global, in *config.SnapJob) (j *SnapJob, err error) {
|
||||||
|
j = &SnapJob{}
|
||||||
|
fsf, err := filters.DatasetMapFilterFromConfig(in.Filesystems)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannnot build filesystem filter")
|
||||||
|
}
|
||||||
|
j.fsfilter = fsf
|
||||||
|
|
||||||
|
if j.snapper, err = snapper.FromConfig(g, fsf, in.Snapshotting); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot build snapper")
|
||||||
|
}
|
||||||
|
j.name = in.Name
|
||||||
|
j.promPruneSecs = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Namespace: "zrepl",
|
||||||
|
Subsystem: "pruning",
|
||||||
|
Name: "time",
|
||||||
|
Help: "seconds spent in pruner",
|
||||||
|
ConstLabels: prometheus.Labels{"zrepl_job": j.name},
|
||||||
|
}, []string{"prune_side"})
|
||||||
|
j.prunerFactory, err = pruner.NewLocalPrunerFactory(in.Pruning, j.promPruneSecs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot build snapjob pruning rules")
|
||||||
|
}
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *SnapJob) RegisterMetrics(registerer prometheus.Registerer) {
|
||||||
|
registerer.MustRegister(j.promPruneSecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapJobStatus struct {
|
||||||
|
Pruning *pruner.Report
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *SnapJob) Status() *Status {
|
||||||
|
s := &SnapJobStatus{}
|
||||||
|
t := j.Type()
|
||||||
|
if j.pruner != nil {
|
||||||
|
s.Pruning = j.pruner.Report()
|
||||||
|
}
|
||||||
|
return &Status{Type: t, JobSpecific: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *SnapJob) OwnedDatasetSubtreeRoot() (rfs *zfs.DatasetPath, ok bool) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *SnapJob) Run(ctx context.Context) {
|
||||||
|
log := GetLogger(ctx)
|
||||||
|
ctx = logging.WithSubsystemLoggers(ctx, log)
|
||||||
|
|
||||||
|
defer log.Info("job exiting")
|
||||||
|
|
||||||
|
periodicDone := make(chan struct{})
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
go j.snapper.Run(ctx, periodicDone)
|
||||||
|
|
||||||
|
invocationCount := 0
|
||||||
|
outer:
|
||||||
|
for {
|
||||||
|
log.Info("wait for wakeups")
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.WithError(ctx.Err()).Info("context")
|
||||||
|
break outer
|
||||||
|
|
||||||
|
case <-wakeup.Wait(ctx):
|
||||||
|
case <-periodicDone:
|
||||||
|
}
|
||||||
|
invocationCount++
|
||||||
|
invLog := log.WithField("invocation", invocationCount)
|
||||||
|
j.doPrune(WithLogger(ctx, invLog))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptor that implements pruner.History around a pruner.Target.
|
||||||
|
// The ReplicationCursor method is Get-op only and always returns
|
||||||
|
// the filesystem's most recent version's GUID.
|
||||||
|
//
|
||||||
|
// TODO:
|
||||||
|
// This is a work-around for the current package daemon/pruner
|
||||||
|
// and package pruning.Snapshot limitation: they require the
|
||||||
|
// `Replicated` getter method be present, but obviously,
|
||||||
|
// a local job like SnapJob can't deliver on that.
|
||||||
|
// But the pruner.Pruner gives up on an FS if no replication
|
||||||
|
// cursor is present, which is why this pruner returns the
|
||||||
|
// most recent filesystem version.
|
||||||
|
type alwaysUpToDateReplicationCursorHistory struct {
|
||||||
|
// the Target passed as Target to BuildLocalPruner
|
||||||
|
target pruner.Target
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ pruner.History = (*alwaysUpToDateReplicationCursorHistory)(nil)
|
||||||
|
|
||||||
|
func (h alwaysUpToDateReplicationCursorHistory) ReplicationCursor(ctx context.Context, req *pdu.ReplicationCursorReq) (*pdu.ReplicationCursorRes, error) {
|
||||||
|
if req.GetGet() == nil {
|
||||||
|
return nil, fmt.Errorf("unsupported ReplicationCursor request: SnapJob only supports GETting a (faked) cursor")
|
||||||
|
}
|
||||||
|
fsvReq := &pdu.ListFilesystemVersionsReq{
|
||||||
|
Filesystem: req.GetFilesystem(),
|
||||||
|
}
|
||||||
|
res, err := h.target.ListFilesystemVersions(ctx, fsvReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fsvs := res.GetVersions()
|
||||||
|
if len(fsvs) <= 0 {
|
||||||
|
return &pdu.ReplicationCursorRes{Result: &pdu.ReplicationCursorRes_Notexist{Notexist: true}}, nil
|
||||||
|
}
|
||||||
|
// always return must recent version
|
||||||
|
sort.Slice(fsvs, func(i, j int) bool {
|
||||||
|
return fsvs[i].CreateTXG < fsvs[j].CreateTXG
|
||||||
|
})
|
||||||
|
mostRecent := fsvs[len(fsvs)-1]
|
||||||
|
return &pdu.ReplicationCursorRes{Result: &pdu.ReplicationCursorRes_Guid{Guid: mostRecent.GetGuid()}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h alwaysUpToDateReplicationCursorHistory) ListFilesystems(ctx context.Context, req *pdu.ListFilesystemReq) (*pdu.ListFilesystemRes, error) {
|
||||||
|
return h.target.ListFilesystems(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *SnapJob) doPrune(ctx context.Context) {
|
||||||
|
log := GetLogger(ctx)
|
||||||
|
ctx = logging.WithSubsystemLoggers(ctx, log)
|
||||||
|
sender := endpoint.NewSender(j.fsfilter)
|
||||||
|
j.pruner = j.prunerFactory.BuildLocalPruner(ctx, sender, alwaysUpToDateReplicationCursorHistory{sender})
|
||||||
|
log.Info("start pruning")
|
||||||
|
j.pruner.Prune()
|
||||||
|
log.Info("finished pruning")
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package wakeup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const contextKeyWakeup contextKey = iota
|
||||||
|
|
||||||
|
func Wait(ctx context.Context) <-chan struct{} {
|
||||||
|
wc, ok := ctx.Value(contextKeyWakeup).(chan struct{})
|
||||||
|
if !ok {
|
||||||
|
wc = make(chan struct{})
|
||||||
|
}
|
||||||
|
return wc
|
||||||
|
}
|
||||||
|
|
||||||
|
type Func func() error
|
||||||
|
|
||||||
|
var AlreadyWokenUp = errors.New("already woken up")
|
||||||
|
|
||||||
|
func Context(ctx context.Context) (context.Context, Func) {
|
||||||
|
wc := make(chan struct{})
|
||||||
|
wuf := func() error {
|
||||||
|
select {
|
||||||
|
case wc <- struct{}{}:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return AlreadyWokenUp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, contextKeyWakeup, wc), wuf
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"log/syslog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mattn/go-isatty"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon/pruner"
|
||||||
|
"github.com/zrepl/zrepl/daemon/snapper"
|
||||||
|
"github.com/zrepl/zrepl/endpoint"
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
"github.com/zrepl/zrepl/replication/driver"
|
||||||
|
"github.com/zrepl/zrepl/replication/logic"
|
||||||
|
"github.com/zrepl/zrepl/rpc"
|
||||||
|
"github.com/zrepl/zrepl/rpc/transportmux"
|
||||||
|
"github.com/zrepl/zrepl/tlsconf"
|
||||||
|
"github.com/zrepl/zrepl/transport"
|
||||||
|
)
|
||||||
|
|
||||||
|
func OutletsFromConfig(in config.LoggingOutletEnumList) (*logger.Outlets, error) {
|
||||||
|
|
||||||
|
outlets := logger.NewOutlets()
|
||||||
|
|
||||||
|
if len(in) == 0 {
|
||||||
|
// Default config
|
||||||
|
out := WriterOutlet{&HumanFormatter{}, os.Stdout}
|
||||||
|
outlets.Add(out, logger.Warn)
|
||||||
|
return outlets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var syslogOutlets, stdoutOutlets int
|
||||||
|
for lei, le := range in {
|
||||||
|
|
||||||
|
outlet, minLevel, err := parseOutlet(le)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "cannot parse outlet #%d", lei)
|
||||||
|
}
|
||||||
|
var _ logger.Outlet = WriterOutlet{}
|
||||||
|
var _ logger.Outlet = &SyslogOutlet{}
|
||||||
|
switch outlet.(type) {
|
||||||
|
case *SyslogOutlet:
|
||||||
|
syslogOutlets++
|
||||||
|
case WriterOutlet:
|
||||||
|
stdoutOutlets++
|
||||||
|
}
|
||||||
|
|
||||||
|
outlets.Add(outlet, minLevel)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if syslogOutlets > 1 {
|
||||||
|
return nil, errors.Errorf("can only define one 'syslog' outlet")
|
||||||
|
}
|
||||||
|
if stdoutOutlets > 1 {
|
||||||
|
return nil, errors.Errorf("can only define one 'stdout' outlet")
|
||||||
|
}
|
||||||
|
|
||||||
|
return outlets, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subsystem string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SubsysReplication Subsystem = "repl"
|
||||||
|
SubsyEndpoint Subsystem = "endpoint"
|
||||||
|
SubsysPruning Subsystem = "pruning"
|
||||||
|
SubsysSnapshot Subsystem = "snapshot"
|
||||||
|
SubsysTransport Subsystem = "transport"
|
||||||
|
SubsysTransportMux Subsystem = "transportmux"
|
||||||
|
SubsysRPC Subsystem = "rpc"
|
||||||
|
SubsysRPCControl Subsystem = "rpc.ctrl"
|
||||||
|
SubsysRPCData Subsystem = "rpc.data"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WithSubsystemLoggers(ctx context.Context, log logger.Logger) context.Context {
|
||||||
|
ctx = logic.WithLogger(ctx, log.WithField(SubsysField, SubsysReplication))
|
||||||
|
ctx = driver.WithLogger(ctx, log.WithField(SubsysField, SubsysReplication))
|
||||||
|
ctx = endpoint.WithLogger(ctx, log.WithField(SubsysField, SubsyEndpoint))
|
||||||
|
ctx = pruner.WithLogger(ctx, log.WithField(SubsysField, SubsysPruning))
|
||||||
|
ctx = snapper.WithLogger(ctx, log.WithField(SubsysField, SubsysSnapshot))
|
||||||
|
ctx = transport.WithLogger(ctx, log.WithField(SubsysField, SubsysTransport))
|
||||||
|
ctx = transportmux.WithLogger(ctx, log.WithField(SubsysField, SubsysTransportMux))
|
||||||
|
ctx = rpc.WithLoggers(ctx,
|
||||||
|
rpc.Loggers{
|
||||||
|
General: log.WithField(SubsysField, SubsysRPC),
|
||||||
|
Control: log.WithField(SubsysField, SubsysRPCControl),
|
||||||
|
Data: log.WithField(SubsysField, SubsysRPCData),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogSubsystem(log logger.Logger, subsys Subsystem) logger.Logger {
|
||||||
|
return log.ReplaceField(SubsysField, subsys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLogFormat(i interface{}) (f EntryFormatter, err error) {
|
||||||
|
var is string
|
||||||
|
switch j := i.(type) {
|
||||||
|
case string:
|
||||||
|
is = j
|
||||||
|
default:
|
||||||
|
return nil, errors.Errorf("invalid log format: wrong type: %T", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch is {
|
||||||
|
case "human":
|
||||||
|
return &HumanFormatter{}, nil
|
||||||
|
case "logfmt":
|
||||||
|
return &LogfmtFormatter{}, nil
|
||||||
|
case "json":
|
||||||
|
return &JSONFormatter{}, nil
|
||||||
|
default:
|
||||||
|
return nil, errors.Errorf("invalid log format: '%s'", is)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOutlet(in config.LoggingOutletEnum) (o logger.Outlet, level logger.Level, err error) {
|
||||||
|
|
||||||
|
parseCommon := func(common config.LoggingOutletCommon) (logger.Level, EntryFormatter, error) {
|
||||||
|
if common.Level == "" || common.Format == "" {
|
||||||
|
return 0, nil, errors.Errorf("must specify 'level' and 'format' field")
|
||||||
|
}
|
||||||
|
|
||||||
|
minLevel, err := logger.ParseLevel(common.Level)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, errors.Wrap(err, "cannot parse 'level' field")
|
||||||
|
}
|
||||||
|
formatter, err := parseLogFormat(common.Format)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, errors.Wrap(err, "cannot parse 'formatter' field")
|
||||||
|
}
|
||||||
|
return minLevel, formatter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var f EntryFormatter
|
||||||
|
|
||||||
|
switch v := in.Ret.(type) {
|
||||||
|
case *config.StdoutLoggingOutlet:
|
||||||
|
level, f, err = parseCommon(v.LoggingOutletCommon)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
o, err = parseStdoutOutlet(v, f)
|
||||||
|
case *config.TCPLoggingOutlet:
|
||||||
|
level, f, err = parseCommon(v.LoggingOutletCommon)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
o, err = parseTCPOutlet(v, f)
|
||||||
|
case *config.SyslogLoggingOutlet:
|
||||||
|
level, f, err = parseCommon(v.LoggingOutletCommon)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
o, err = parseSyslogOutlet(v, f)
|
||||||
|
default:
|
||||||
|
panic(v)
|
||||||
|
}
|
||||||
|
return o, level, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStdoutOutlet(in *config.StdoutLoggingOutlet, formatter EntryFormatter) (WriterOutlet, error) {
|
||||||
|
flags := MetadataAll
|
||||||
|
writer := os.Stdout
|
||||||
|
if !isatty.IsTerminal(writer.Fd()) && !in.Time {
|
||||||
|
flags &= ^MetadataTime
|
||||||
|
}
|
||||||
|
if isatty.IsTerminal(writer.Fd()) && !in.Color {
|
||||||
|
flags &= ^MetadataColor
|
||||||
|
}
|
||||||
|
|
||||||
|
formatter.SetMetadataFlags(flags)
|
||||||
|
return WriterOutlet{
|
||||||
|
formatter,
|
||||||
|
os.Stdout,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTCPOutlet(in *config.TCPLoggingOutlet, formatter EntryFormatter) (out *TCPOutlet, err error) {
|
||||||
|
var tlsConfig *tls.Config
|
||||||
|
if in.TLS != nil {
|
||||||
|
tlsConfig, err = func(m *config.TCPLoggingOutletTLS, host string) (*tls.Config, error) {
|
||||||
|
clientCert, err := tls.LoadX509KeyPair(m.Cert, m.Key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot load client cert")
|
||||||
|
}
|
||||||
|
|
||||||
|
var rootCAs *x509.CertPool
|
||||||
|
if m.CA == "" {
|
||||||
|
if rootCAs, err = x509.SystemCertPool(); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot open system cert pool")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rootCAs, err = tlsconf.ParseCAFile(m.CA)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot parse CA cert")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rootCAs == nil {
|
||||||
|
panic("invariant violated")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tlsconf.ClientAuthClient(host, rootCAs, clientCert)
|
||||||
|
}(in.TLS, in.Address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("cannot not parse TLS config in field 'tls'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatter.SetMetadataFlags(MetadataAll)
|
||||||
|
return NewTCPOutlet(formatter, in.Net, in.Address, tlsConfig, in.RetryInterval), nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSyslogOutlet(in *config.SyslogLoggingOutlet, formatter EntryFormatter) (out *SyslogOutlet, err error) {
|
||||||
|
out = &SyslogOutlet{}
|
||||||
|
out.Formatter = formatter
|
||||||
|
out.Formatter.SetMetadataFlags(MetadataNone)
|
||||||
|
out.Facility = syslog.Priority(*in.Facility)
|
||||||
|
out.RetryInterval = in.RetryInterval
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/go-logfmt/logfmt"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FieldLevel = "level"
|
||||||
|
FieldMessage = "msg"
|
||||||
|
FieldTime = "time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
JobField string = "job"
|
||||||
|
SubsysField string = "subsystem"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetadataFlags int64
|
||||||
|
|
||||||
|
const (
|
||||||
|
MetadataTime MetadataFlags = 1 << iota
|
||||||
|
MetadataLevel
|
||||||
|
MetadataColor
|
||||||
|
|
||||||
|
MetadataNone MetadataFlags = 0
|
||||||
|
MetadataAll MetadataFlags = ^0
|
||||||
|
)
|
||||||
|
|
||||||
|
type NoFormatter struct{}
|
||||||
|
|
||||||
|
func (f NoFormatter) SetMetadataFlags(flags MetadataFlags) {}
|
||||||
|
|
||||||
|
func (f NoFormatter) Format(e *logger.Entry) ([]byte, error) {
|
||||||
|
return []byte(e.Message), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type HumanFormatter struct {
|
||||||
|
metadataFlags MetadataFlags
|
||||||
|
ignoreFields map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const HumanFormatterDateFormat = time.RFC3339
|
||||||
|
|
||||||
|
func (f *HumanFormatter) SetMetadataFlags(flags MetadataFlags) {
|
||||||
|
f.metadataFlags = flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *HumanFormatter) SetIgnoreFields(ignore []string) {
|
||||||
|
if ignore == nil {
|
||||||
|
f.ignoreFields = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.ignoreFields = make(map[string]bool, len(ignore))
|
||||||
|
|
||||||
|
for _, field := range ignore {
|
||||||
|
f.ignoreFields[field] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *HumanFormatter) ignored(field string) bool {
|
||||||
|
return f.ignoreFields != nil && f.ignoreFields[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *HumanFormatter) Format(e *logger.Entry) (out []byte, err error) {
|
||||||
|
|
||||||
|
var line bytes.Buffer
|
||||||
|
col := color.New()
|
||||||
|
if f.metadataFlags&MetadataColor != 0 {
|
||||||
|
col = e.Color()
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.metadataFlags&MetadataTime != 0 {
|
||||||
|
fmt.Fprintf(&line, "%s ", e.Time.Format(HumanFormatterDateFormat))
|
||||||
|
}
|
||||||
|
if f.metadataFlags&MetadataLevel != 0 {
|
||||||
|
fmt.Fprintf(&line, "[%s]", col.Sprint(e.Level.Short()))
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixFields := []string{JobField, SubsysField}
|
||||||
|
prefixed := make(map[string]bool, len(prefixFields)+2)
|
||||||
|
for _, field := range prefixFields {
|
||||||
|
val, ok := e.Fields[field].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !f.ignored(field) {
|
||||||
|
fmt.Fprintf(&line, "[%s]", col.Sprint(val))
|
||||||
|
prefixed[field] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.Len() > 0 {
|
||||||
|
fmt.Fprint(&line, ": ")
|
||||||
|
}
|
||||||
|
fmt.Fprint(&line, e.Message)
|
||||||
|
|
||||||
|
if len(e.Fields)-len(prefixed) > 0 {
|
||||||
|
for field, value := range e.Fields {
|
||||||
|
if prefixed[field] || f.ignored(field) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&line, " %s=%q", col.Sprint(field), fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return line.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type JSONFormatter struct {
|
||||||
|
metadataFlags MetadataFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *JSONFormatter) SetMetadataFlags(flags MetadataFlags) {
|
||||||
|
f.metadataFlags = flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *JSONFormatter) Format(e *logger.Entry) ([]byte, error) {
|
||||||
|
data := make(logger.Fields, len(e.Fields)+3)
|
||||||
|
for k, v := range e.Fields {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case error:
|
||||||
|
// Otherwise errors are ignored by `encoding/json`
|
||||||
|
// https://github.com/sirupsen/logrus/issues/137
|
||||||
|
data[k] = v.Error()
|
||||||
|
default:
|
||||||
|
_, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Errorf("field is not JSON encodable: %s", k)
|
||||||
|
}
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data[FieldMessage] = e.Message
|
||||||
|
data[FieldTime] = e.Time.Format(time.RFC3339)
|
||||||
|
data[FieldLevel] = e.Level
|
||||||
|
|
||||||
|
return json.Marshal(data)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogfmtFormatter struct {
|
||||||
|
metadataFlags MetadataFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *LogfmtFormatter) SetMetadataFlags(flags MetadataFlags) {
|
||||||
|
f.metadataFlags = flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *LogfmtFormatter) Format(e *logger.Entry) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := logfmt.NewEncoder(&buf)
|
||||||
|
|
||||||
|
if f.metadataFlags&MetadataTime != 0 {
|
||||||
|
err := enc.EncodeKeyval(FieldTime, e.Time)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "logfmt: encode time")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.metadataFlags&MetadataLevel != 0 {
|
||||||
|
err := enc.EncodeKeyval(FieldLevel, e.Level)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "logfmt: encode level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// at least try and put job and task in front
|
||||||
|
prefixed := make(map[string]bool, 2)
|
||||||
|
prefix := []string{JobField, SubsysField}
|
||||||
|
for _, pf := range prefix {
|
||||||
|
v, ok := e.Fields[pf]
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := logfmtTryEncodeKeyval(enc, pf, v); err != nil {
|
||||||
|
return nil, err // unlikely
|
||||||
|
}
|
||||||
|
prefixed[pf] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
err := enc.EncodeKeyval(FieldMessage, e.Message)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "logfmt: encode message")
|
||||||
|
}
|
||||||
|
for k, v := range e.Fields {
|
||||||
|
if !prefixed[k] {
|
||||||
|
if err := logfmtTryEncodeKeyval(enc, k, v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logfmtTryEncodeKeyval(enc *logfmt.Encoder, field, value interface{}) error {
|
||||||
|
|
||||||
|
err := enc.EncodeKeyval(field, value)
|
||||||
|
switch err {
|
||||||
|
case nil: // ok
|
||||||
|
return nil
|
||||||
|
case logfmt.ErrUnsupportedValueType:
|
||||||
|
err := enc.EncodeKeyval(field, fmt.Sprintf("<%T>", value))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "cannot encode unsuuported value type Go type")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.Wrapf(err, "cannot encode field '%s'", field)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"log/syslog"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EntryFormatter interface {
|
||||||
|
SetMetadataFlags(flags MetadataFlags)
|
||||||
|
Format(e *logger.Entry) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WriterOutlet struct {
|
||||||
|
formatter EntryFormatter
|
||||||
|
writer io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h WriterOutlet) WriteEntry(entry logger.Entry) error {
|
||||||
|
bytes, err := h.formatter.Format(&entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = h.writer.Write(bytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = h.writer.Write([]byte("\n"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type TCPOutlet struct {
|
||||||
|
formatter EntryFormatter
|
||||||
|
// Specifies how much time must pass between a connection error and a reconnection attempt
|
||||||
|
// Log entries written to the outlet during this time interval are silently dropped.
|
||||||
|
connect func(ctx context.Context) (net.Conn, error)
|
||||||
|
entryChan chan *bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTCPOutlet(formatter EntryFormatter, network, address string, tlsConfig *tls.Config, retryInterval time.Duration) *TCPOutlet {
|
||||||
|
|
||||||
|
connect := func(ctx context.Context) (conn net.Conn, err error) {
|
||||||
|
deadl, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
deadl = time.Time{}
|
||||||
|
}
|
||||||
|
dialer := net.Dialer{
|
||||||
|
Deadline: deadl,
|
||||||
|
}
|
||||||
|
if tlsConfig != nil {
|
||||||
|
conn, err = tls.DialWithDialer(&dialer, network, address, tlsConfig)
|
||||||
|
} else {
|
||||||
|
conn, err = dialer.DialContext(ctx, network, address)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entryChan := make(chan *bytes.Buffer, 1) // allow one message in flight while previos is in io.Copy()
|
||||||
|
|
||||||
|
o := &TCPOutlet{
|
||||||
|
formatter: formatter,
|
||||||
|
connect: connect,
|
||||||
|
entryChan: entryChan,
|
||||||
|
}
|
||||||
|
|
||||||
|
go o.outLoop(retryInterval)
|
||||||
|
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: use this method
|
||||||
|
func (h *TCPOutlet) Close() {
|
||||||
|
close(h.entryChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TCPOutlet) outLoop(retryInterval time.Duration) {
|
||||||
|
|
||||||
|
var retry time.Time
|
||||||
|
var conn net.Conn
|
||||||
|
for msg := range h.entryChan {
|
||||||
|
var err error
|
||||||
|
for conn == nil {
|
||||||
|
time.Sleep(time.Until(retry))
|
||||||
|
ctx, cancel := context.WithDeadline(context.TODO(), time.Now().Add(retryInterval))
|
||||||
|
conn, err = h.connect(ctx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
retry = time.Now().Add(retryInterval)
|
||||||
|
conn = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = conn.SetWriteDeadline(time.Now().Add(retryInterval))
|
||||||
|
if err == nil {
|
||||||
|
_, err = io.Copy(conn, msg)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
retry = time.Now().Add(retryInterval)
|
||||||
|
conn.Close()
|
||||||
|
conn = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TCPOutlet) WriteEntry(e logger.Entry) error {
|
||||||
|
|
||||||
|
ebytes, err := h.formatter.Format(&e)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
buf.Write(ebytes)
|
||||||
|
buf.WriteString("\n")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case h.entryChan <- buf:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return errors.New("connection broken or not fast enough")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyslogOutlet struct {
|
||||||
|
Formatter EntryFormatter
|
||||||
|
RetryInterval time.Duration
|
||||||
|
Facility syslog.Priority
|
||||||
|
writer *syslog.Writer
|
||||||
|
lastConnectAttempt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *SyslogOutlet) WriteEntry(entry logger.Entry) error {
|
||||||
|
|
||||||
|
bytes, err := o.Formatter.Format(&entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := string(bytes)
|
||||||
|
|
||||||
|
if o.writer == nil {
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(o.lastConnectAttempt) < o.RetryInterval {
|
||||||
|
return nil // not an error toward logger
|
||||||
|
}
|
||||||
|
o.writer, err = syslog.New(o.Facility, "zrepl")
|
||||||
|
o.lastConnectAttempt = time.Now()
|
||||||
|
if err != nil {
|
||||||
|
o.writer = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch entry.Level {
|
||||||
|
case logger.Debug:
|
||||||
|
return o.writer.Debug(s)
|
||||||
|
case logger.Info:
|
||||||
|
return o.writer.Info(s)
|
||||||
|
case logger.Warn:
|
||||||
|
return o.writer.Warning(s)
|
||||||
|
case logger.Error:
|
||||||
|
return o.writer.Err(s)
|
||||||
|
default:
|
||||||
|
return o.writer.Err(s) // write as error as reaching this case is in fact an error
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/zrepl/zrepl/cli"
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logger = logger.Logger
|
||||||
|
|
||||||
|
var DaemonCmd = &cli.Subcommand{
|
||||||
|
Use: "daemon",
|
||||||
|
Short: "run the zrepl daemon",
|
||||||
|
Run: func(subcommand *cli.Subcommand, args []string) error {
|
||||||
|
return Run(subcommand.Config())
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package nethelpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PreparePrivateSockpath(sockpath string) error {
|
||||||
|
sockdir := filepath.Dir(sockpath)
|
||||||
|
sdstat, err := os.Stat(sockdir)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "cannot stat(2) '%s'", sockdir)
|
||||||
|
}
|
||||||
|
if !sdstat.IsDir() {
|
||||||
|
return errors.Errorf("not a directory: %s", sockdir)
|
||||||
|
}
|
||||||
|
p := sdstat.Mode().Perm()
|
||||||
|
if p&0007 != 0 {
|
||||||
|
return errors.Errorf("socket directory must not be world-accessible: %s (permissions are %#o)", sockdir, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maybe things have not been cleaned up before
|
||||||
|
s, err := os.Stat(sockpath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "cannot stat(2) '%s'", sockpath)
|
||||||
|
}
|
||||||
|
if s.Mode()&os.ModeSocket == 0 {
|
||||||
|
return errors.Errorf("unexpected file type at path '%s'", sockpath)
|
||||||
|
}
|
||||||
|
err = os.Remove(sockpath)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "cannot remove presumably stale socket '%s'", sockpath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListenUnixPrivate(sockaddr *net.UnixAddr) (*net.UnixListener, error) {
|
||||||
|
|
||||||
|
if err := PreparePrivateSockpath(sockaddr.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return net.ListenUnix("unix", sockaddr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
// FIXME: importing this package has the side-effect of poisoning the http.DefaultServeMux
|
||||||
|
// FIXME: with the /debug/pprof endpoints
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http/pprof"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/daemon/job"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pprofServer struct {
|
||||||
|
cc chan PprofServerControlMsg
|
||||||
|
listener net.Listener
|
||||||
|
}
|
||||||
|
|
||||||
|
type PprofServerControlMsg struct {
|
||||||
|
// Whether the server should listen for requests on the given address
|
||||||
|
Run bool
|
||||||
|
// Must be set if Run is true, undefined otherwise
|
||||||
|
HttpListenAddress string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPProfServer(ctx context.Context) *pprofServer {
|
||||||
|
|
||||||
|
s := &pprofServer{
|
||||||
|
cc: make(chan PprofServerControlMsg),
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.controlLoop(ctx)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pprofServer) controlLoop(ctx context.Context) {
|
||||||
|
outer:
|
||||||
|
for {
|
||||||
|
|
||||||
|
var msg PprofServerControlMsg
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if s.listener != nil {
|
||||||
|
s.listener.Close()
|
||||||
|
}
|
||||||
|
break outer
|
||||||
|
case msg = <-s.cc:
|
||||||
|
// proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if msg.Run && s.listener == nil {
|
||||||
|
|
||||||
|
s.listener, err = net.Listen("tcp", msg.HttpListenAddress)
|
||||||
|
if err != nil {
|
||||||
|
s.listener = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: because net/http/pprof does not provide a mux,
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/debug/pprof/", http.HandlerFunc(pprof.Index))
|
||||||
|
mux.Handle("/debug/pprof/cmdline", http.HandlerFunc(pprof.Cmdline))
|
||||||
|
mux.Handle("/debug/pprof/profile", http.HandlerFunc(pprof.Profile))
|
||||||
|
mux.Handle("/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol))
|
||||||
|
mux.Handle("/debug/pprof/trace", http.HandlerFunc(pprof.Trace))
|
||||||
|
go func() {
|
||||||
|
err := http.Serve(s.listener, mux)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
job.GetLogger(ctx).WithError(err).Error("pprof server serve error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !msg.Run && s.listener != nil {
|
||||||
|
s.listener.Close()
|
||||||
|
s.listener = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *pprofServer) Control(msg PprofServerControlMsg) {
|
||||||
|
s.cc <- msg
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package daemon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon/job"
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
"github.com/zrepl/zrepl/rpc/dataconn/frameconn"
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type prometheusJob struct {
|
||||||
|
listen string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPrometheusJobFromConfig(in *config.PrometheusMonitoring) (*prometheusJob, error) {
|
||||||
|
if _, _, err := net.SplitHostPort(in.Listen); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &prometheusJob{in.Listen}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var prom struct {
|
||||||
|
taskLogEntries *prometheus.CounterVec
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
prom.taskLogEntries = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "zrepl",
|
||||||
|
Subsystem: "daemon",
|
||||||
|
Name: "log_entries",
|
||||||
|
Help: "number of log entries per job task and level",
|
||||||
|
}, []string{"zrepl_job", "level"})
|
||||||
|
prometheus.MustRegister(prom.taskLogEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *prometheusJob) Name() string { return jobNamePrometheus }
|
||||||
|
|
||||||
|
func (j *prometheusJob) Status() *job.Status { return &job.Status{Type: job.TypeInternal} }
|
||||||
|
|
||||||
|
func (j *prometheusJob) OwnedDatasetSubtreeRoot() (p *zfs.DatasetPath, ok bool) { return nil, false }
|
||||||
|
|
||||||
|
func (j *prometheusJob) RegisterMetrics(registerer prometheus.Registerer) {}
|
||||||
|
|
||||||
|
func (j *prometheusJob) Run(ctx context.Context) {
|
||||||
|
|
||||||
|
if err := zfs.PrometheusRegister(prometheus.DefaultRegisterer); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := frameconn.PrometheusRegister(prometheus.DefaultRegisterer); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log := job.GetLogger(ctx)
|
||||||
|
|
||||||
|
l, err := net.Listen("tcp", j.listen)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("cannot listen")
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
l.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
|
|
||||||
|
err = http.Serve(l, mux)
|
||||||
|
if err != nil && ctx.Err() == nil {
|
||||||
|
log.WithError(err).Error("error while serving")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type prometheusJobOutlet struct {
|
||||||
|
jobName string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ logger.Outlet = prometheusJobOutlet{}
|
||||||
|
|
||||||
|
func newPrometheusLogOutlet(jobName string) prometheusJobOutlet {
|
||||||
|
return prometheusJobOutlet{jobName}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o prometheusJobOutlet) WriteEntry(entry logger.Entry) error {
|
||||||
|
prom.taskLogEntries.WithLabelValues(o.jobName, entry.Level.String()).Inc()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
package pruner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
"github.com/zrepl/zrepl/pruning"
|
||||||
|
"github.com/zrepl/zrepl/replication/logic/pdu"
|
||||||
|
"github.com/zrepl/zrepl/util/envconst"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try to keep it compatible with gitub.com/zrepl/zrepl/endpoint.Endpoint
|
||||||
|
type History interface {
|
||||||
|
ReplicationCursor(ctx context.Context, req *pdu.ReplicationCursorReq) (*pdu.ReplicationCursorRes, error)
|
||||||
|
ListFilesystems(ctx context.Context, req *pdu.ListFilesystemReq) (*pdu.ListFilesystemRes, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to keep it compatible with gitub.com/zrepl/zrepl/endpoint.Endpoint
|
||||||
|
type Target interface {
|
||||||
|
ListFilesystems(ctx context.Context, req *pdu.ListFilesystemReq) (*pdu.ListFilesystemRes, error)
|
||||||
|
ListFilesystemVersions(ctx context.Context, req *pdu.ListFilesystemVersionsReq) (*pdu.ListFilesystemVersionsRes, error)
|
||||||
|
DestroySnapshots(ctx context.Context, req *pdu.DestroySnapshotsReq) (*pdu.DestroySnapshotsRes, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Logger = logger.Logger
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const contextKeyLogger contextKey = 0
|
||||||
|
|
||||||
|
func WithLogger(ctx context.Context, log Logger) context.Context {
|
||||||
|
return context.WithValue(ctx, contextKeyLogger, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLogger(ctx context.Context) Logger {
|
||||||
|
if l, ok := ctx.Value(contextKeyLogger).(Logger); ok {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return logger.NewNullLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
ctx context.Context
|
||||||
|
target Target
|
||||||
|
receiver History
|
||||||
|
rules []pruning.KeepRule
|
||||||
|
retryWait time.Duration
|
||||||
|
considerSnapAtCursorReplicated bool
|
||||||
|
promPruneSecs prometheus.Observer
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pruner struct {
|
||||||
|
args args
|
||||||
|
|
||||||
|
mtx sync.RWMutex
|
||||||
|
|
||||||
|
state State
|
||||||
|
|
||||||
|
// State PlanErr
|
||||||
|
err error
|
||||||
|
|
||||||
|
// State Exec
|
||||||
|
execQueue *execQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrunerFactory struct {
|
||||||
|
senderRules []pruning.KeepRule
|
||||||
|
receiverRules []pruning.KeepRule
|
||||||
|
retryWait time.Duration
|
||||||
|
considerSnapAtCursorReplicated bool
|
||||||
|
promPruneSecs *prometheus.HistogramVec
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalPrunerFactory struct {
|
||||||
|
keepRules []pruning.KeepRule
|
||||||
|
retryWait time.Duration
|
||||||
|
promPruneSecs *prometheus.HistogramVec
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLocalPrunerFactory(in config.PruningLocal, promPruneSecs *prometheus.HistogramVec) (*LocalPrunerFactory, error) {
|
||||||
|
rules, err := pruning.RulesFromConfig(in.Keep)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot build pruning rules")
|
||||||
|
}
|
||||||
|
for _, r := range in.Keep {
|
||||||
|
if _, ok := r.Ret.(*config.PruneKeepNotReplicated); ok {
|
||||||
|
// rule NotReplicated for a local pruner doesn't make sense
|
||||||
|
// because no replication happens with that job type
|
||||||
|
return nil, fmt.Errorf("single-site pruner cannot support `not_replicated` keep rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f := &LocalPrunerFactory{
|
||||||
|
keepRules: rules,
|
||||||
|
retryWait: envconst.Duration("ZREPL_PRUNER_RETRY_INTERVAL", 10*time.Second),
|
||||||
|
promPruneSecs: promPruneSecs,
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPrunerFactory(in config.PruningSenderReceiver, promPruneSecs *prometheus.HistogramVec) (*PrunerFactory, error) {
|
||||||
|
keepRulesReceiver, err := pruning.RulesFromConfig(in.KeepReceiver)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot build receiver pruning rules")
|
||||||
|
}
|
||||||
|
|
||||||
|
keepRulesSender, err := pruning.RulesFromConfig(in.KeepSender)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "cannot build sender pruning rules")
|
||||||
|
}
|
||||||
|
|
||||||
|
considerSnapAtCursorReplicated := false
|
||||||
|
for _, r := range in.KeepSender {
|
||||||
|
knr, ok := r.Ret.(*config.PruneKeepNotReplicated)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
considerSnapAtCursorReplicated = considerSnapAtCursorReplicated || !knr.KeepSnapshotAtCursor
|
||||||
|
}
|
||||||
|
f := &PrunerFactory{
|
||||||
|
senderRules: keepRulesSender,
|
||||||
|
receiverRules: keepRulesReceiver,
|
||||||
|
retryWait: envconst.Duration("ZREPL_PRUNER_RETRY_INTERVAL", 10*time.Second),
|
||||||
|
considerSnapAtCursorReplicated: considerSnapAtCursorReplicated,
|
||||||
|
promPruneSecs: promPruneSecs,
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *PrunerFactory) BuildSenderPruner(ctx context.Context, target Target, receiver History) *Pruner {
|
||||||
|
p := &Pruner{
|
||||||
|
args: args{
|
||||||
|
WithLogger(ctx, GetLogger(ctx).WithField("prune_side", "sender")),
|
||||||
|
target,
|
||||||
|
receiver,
|
||||||
|
f.senderRules,
|
||||||
|
f.retryWait,
|
||||||
|
f.considerSnapAtCursorReplicated,
|
||||||
|
f.promPruneSecs.WithLabelValues("sender"),
|
||||||
|
},
|
||||||
|
state: Plan,
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *PrunerFactory) BuildReceiverPruner(ctx context.Context, target Target, receiver History) *Pruner {
|
||||||
|
p := &Pruner{
|
||||||
|
args: args{
|
||||||
|
WithLogger(ctx, GetLogger(ctx).WithField("prune_side", "receiver")),
|
||||||
|
target,
|
||||||
|
receiver,
|
||||||
|
f.receiverRules,
|
||||||
|
f.retryWait,
|
||||||
|
false, // senseless here anyways
|
||||||
|
f.promPruneSecs.WithLabelValues("receiver"),
|
||||||
|
},
|
||||||
|
state: Plan,
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *LocalPrunerFactory) BuildLocalPruner(ctx context.Context, target Target, receiver History) *Pruner {
|
||||||
|
p := &Pruner{
|
||||||
|
args: args{
|
||||||
|
ctx,
|
||||||
|
target,
|
||||||
|
receiver,
|
||||||
|
f.keepRules,
|
||||||
|
f.retryWait,
|
||||||
|
false, // considerSnapAtCursorReplicated is not relevant for local pruning
|
||||||
|
f.promPruneSecs.WithLabelValues("local"),
|
||||||
|
},
|
||||||
|
state: Plan,
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:generate enumer -type=State
|
||||||
|
type State int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Plan State = 1 << iota
|
||||||
|
PlanErr
|
||||||
|
Exec
|
||||||
|
ExecErr
|
||||||
|
Done
|
||||||
|
)
|
||||||
|
|
||||||
|
type updater func(func(*Pruner))
|
||||||
|
|
||||||
|
func (p *Pruner) Prune() {
|
||||||
|
p.prune(p.args)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pruner) prune(args args) {
|
||||||
|
u := func(f func(*Pruner)) {
|
||||||
|
p.mtx.Lock()
|
||||||
|
defer p.mtx.Unlock()
|
||||||
|
f(p)
|
||||||
|
}
|
||||||
|
// TODO support automatic retries
|
||||||
|
// It is advisable to merge this code with package replication/driver before
|
||||||
|
// That will likely require re-modelling struct fs like replication/driver.attempt,
|
||||||
|
// including figuring out how to resume a plan after being interrupted by network errors
|
||||||
|
// The non-retrying code in this package should move straight to replication/logic.
|
||||||
|
doOneAttempt(&args, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
State string
|
||||||
|
Error string
|
||||||
|
Pending, Completed []FSReport
|
||||||
|
}
|
||||||
|
|
||||||
|
type FSReport struct {
|
||||||
|
Filesystem string
|
||||||
|
SnapshotList, DestroyList []SnapshotReport
|
||||||
|
SkipReason FSSkipReason
|
||||||
|
LastError string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnapshotReport struct {
|
||||||
|
Name string
|
||||||
|
Replicated bool
|
||||||
|
Date time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pruner) Report() *Report {
|
||||||
|
p.mtx.Lock()
|
||||||
|
defer p.mtx.Unlock()
|
||||||
|
|
||||||
|
r := Report{State: p.state.String()}
|
||||||
|
|
||||||
|
if p.err != nil {
|
||||||
|
r.Error = p.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.execQueue != nil {
|
||||||
|
r.Pending, r.Completed = p.execQueue.Report()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pruner) State() State {
|
||||||
|
p.mtx.Lock()
|
||||||
|
defer p.mtx.Unlock()
|
||||||
|
return p.state
|
||||||
|
}
|
||||||
|
|
||||||
|
type fs struct {
|
||||||
|
path string
|
||||||
|
|
||||||
|
// permanent error during planning
|
||||||
|
planErr error
|
||||||
|
planErrContext string
|
||||||
|
|
||||||
|
// if != "", the fs was skipped for planning and the field
|
||||||
|
// contains the reason
|
||||||
|
skipReason FSSkipReason
|
||||||
|
|
||||||
|
// snapshots presented by target
|
||||||
|
// (type snapshot)
|
||||||
|
snaps []pruning.Snapshot
|
||||||
|
// destroy list returned by pruning.PruneSnapshots(snaps)
|
||||||
|
// (type snapshot)
|
||||||
|
destroyList []pruning.Snapshot
|
||||||
|
|
||||||
|
mtx sync.RWMutex
|
||||||
|
|
||||||
|
// only during Exec state, also used by execQueue
|
||||||
|
execErrLast error
|
||||||
|
}
|
||||||
|
|
||||||
|
type FSSkipReason string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NotSkipped = ""
|
||||||
|
SkipPlaceholder = "filesystem is placeholder"
|
||||||
|
SkipNoCorrespondenceOnSender = "filesystem has no correspondence on sender"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r FSSkipReason) NotSkipped() bool {
|
||||||
|
return r == NotSkipped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fs) Report() FSReport {
|
||||||
|
f.mtx.Lock()
|
||||||
|
defer f.mtx.Unlock()
|
||||||
|
|
||||||
|
r := FSReport{}
|
||||||
|
r.Filesystem = f.path
|
||||||
|
r.SkipReason = f.skipReason
|
||||||
|
if !r.SkipReason.NotSkipped() {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.planErr != nil {
|
||||||
|
r.LastError = f.planErr.Error()
|
||||||
|
} else if f.execErrLast != nil {
|
||||||
|
r.LastError = f.execErrLast.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
r.SnapshotList = make([]SnapshotReport, len(f.snaps))
|
||||||
|
for i, snap := range f.snaps {
|
||||||
|
r.SnapshotList[i] = snap.(snapshot).Report()
|
||||||
|
}
|
||||||
|
|
||||||
|
r.DestroyList = make([]SnapshotReport, len(f.destroyList))
|
||||||
|
for i, snap := range f.destroyList {
|
||||||
|
r.DestroyList[i] = snap.(snapshot).Report()
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshot struct {
|
||||||
|
replicated bool
|
||||||
|
date time.Time
|
||||||
|
fsv *pdu.FilesystemVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s snapshot) Report() SnapshotReport {
|
||||||
|
return SnapshotReport{
|
||||||
|
Name: s.Name(),
|
||||||
|
Replicated: s.Replicated(),
|
||||||
|
Date: s.Date(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ pruning.Snapshot = snapshot{}
|
||||||
|
|
||||||
|
func (s snapshot) Name() string { return s.fsv.Name }
|
||||||
|
|
||||||
|
func (s snapshot) Replicated() bool { return s.replicated }
|
||||||
|
|
||||||
|
func (s snapshot) Date() time.Time { return s.date }
|
||||||
|
|
||||||
|
func doOneAttempt(a *args, u updater) {
|
||||||
|
|
||||||
|
ctx, target, receiver := a.ctx, a.target, a.receiver
|
||||||
|
|
||||||
|
sfssres, err := receiver.ListFilesystems(ctx, &pdu.ListFilesystemReq{})
|
||||||
|
if err != nil {
|
||||||
|
u(func(p *Pruner) {
|
||||||
|
p.state = PlanErr
|
||||||
|
p.err = err
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sfss := make(map[string]*pdu.Filesystem)
|
||||||
|
for _, sfs := range sfssres.GetFilesystems() {
|
||||||
|
sfss[sfs.GetPath()] = sfs
|
||||||
|
}
|
||||||
|
|
||||||
|
tfssres, err := target.ListFilesystems(ctx, &pdu.ListFilesystemReq{})
|
||||||
|
if err != nil {
|
||||||
|
u(func(p *Pruner) {
|
||||||
|
p.state = PlanErr
|
||||||
|
p.err = err
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tfss := tfssres.GetFilesystems()
|
||||||
|
|
||||||
|
pfss := make([]*fs, len(tfss))
|
||||||
|
tfss_loop:
|
||||||
|
for i, tfs := range tfss {
|
||||||
|
|
||||||
|
l := GetLogger(ctx).WithField("fs", tfs.Path)
|
||||||
|
l.Debug("plan filesystem")
|
||||||
|
|
||||||
|
pfs := &fs{
|
||||||
|
path: tfs.Path,
|
||||||
|
}
|
||||||
|
pfss[i] = pfs
|
||||||
|
|
||||||
|
if tfs.GetIsPlaceholder() {
|
||||||
|
pfs.skipReason = SkipPlaceholder
|
||||||
|
l.WithField("skip_reason", pfs.skipReason).Debug("skipping filesystem")
|
||||||
|
continue
|
||||||
|
} else if sfs := sfss[tfs.GetPath()]; sfs == nil {
|
||||||
|
pfs.skipReason = SkipNoCorrespondenceOnSender
|
||||||
|
l.WithField("skip_reason", pfs.skipReason).WithField("sfs", sfs.GetPath()).Debug("skipping filesystem")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pfsPlanErrAndLog := func(err error, message string) {
|
||||||
|
t := fmt.Sprintf("%T", err)
|
||||||
|
pfs.planErr = err
|
||||||
|
pfs.planErrContext = message
|
||||||
|
l.WithField("orig_err_type", t).WithError(err).Error(fmt.Sprintf("%s: plan error, skipping filesystem", message))
|
||||||
|
}
|
||||||
|
|
||||||
|
tfsvsres, err := target.ListFilesystemVersions(ctx, &pdu.ListFilesystemVersionsReq{Filesystem: tfs.Path})
|
||||||
|
if err != nil {
|
||||||
|
pfsPlanErrAndLog(err, "cannot list filesystem versions")
|
||||||
|
continue tfss_loop
|
||||||
|
}
|
||||||
|
tfsvs := tfsvsres.GetVersions()
|
||||||
|
// no progress here since we could run in a live-lock (must have used target AND receiver before progress)
|
||||||
|
|
||||||
|
pfs.snaps = make([]pruning.Snapshot, 0, len(tfsvs))
|
||||||
|
|
||||||
|
rcReq := &pdu.ReplicationCursorReq{
|
||||||
|
Filesystem: tfs.Path,
|
||||||
|
Op: &pdu.ReplicationCursorReq_Get{
|
||||||
|
Get: &pdu.ReplicationCursorReq_GetOp{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rc, err := receiver.ReplicationCursor(ctx, rcReq)
|
||||||
|
if err != nil {
|
||||||
|
pfsPlanErrAndLog(err, "cannot get replication cursor bookmark")
|
||||||
|
continue tfss_loop
|
||||||
|
}
|
||||||
|
if rc.GetNotexist() {
|
||||||
|
err := errors.New("replication cursor bookmark does not exist (one successful replication is required before pruning works)")
|
||||||
|
pfsPlanErrAndLog(err, "")
|
||||||
|
continue tfss_loop
|
||||||
|
}
|
||||||
|
|
||||||
|
// scan from older to newer, all snapshots older than cursor are interpreted as replicated
|
||||||
|
sort.Slice(tfsvs, func(i, j int) bool {
|
||||||
|
return tfsvs[i].CreateTXG < tfsvs[j].CreateTXG
|
||||||
|
})
|
||||||
|
|
||||||
|
haveCursorSnapshot := false
|
||||||
|
for _, tfsv := range tfsvs {
|
||||||
|
if tfsv.Type != pdu.FilesystemVersion_Snapshot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tfsv.Guid == rc.GetGuid() {
|
||||||
|
haveCursorSnapshot = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preCursor := haveCursorSnapshot
|
||||||
|
for _, tfsv := range tfsvs {
|
||||||
|
if tfsv.Type != pdu.FilesystemVersion_Snapshot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
creation, err := tfsv.CreationAsTime()
|
||||||
|
if err != nil {
|
||||||
|
err := fmt.Errorf("%s: %s", tfsv.RelName(), err)
|
||||||
|
pfsPlanErrAndLog(err, "fs version with invalid creation date")
|
||||||
|
continue tfss_loop
|
||||||
|
}
|
||||||
|
// note that we cannot use CreateTXG because target and receiver could be on different pools
|
||||||
|
atCursor := tfsv.Guid == rc.GetGuid()
|
||||||
|
preCursor = preCursor && !atCursor
|
||||||
|
pfs.snaps = append(pfs.snaps, snapshot{
|
||||||
|
replicated: preCursor || (a.considerSnapAtCursorReplicated && atCursor),
|
||||||
|
date: creation,
|
||||||
|
fsv: tfsv,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if preCursor {
|
||||||
|
pfsPlanErrAndLog(fmt.Errorf("replication cursor not found in prune target filesystem versions"), "")
|
||||||
|
continue tfss_loop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply prune rules
|
||||||
|
pfs.destroyList = pruning.PruneSnapshots(pfs.snaps, a.rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
u(func(pruner *Pruner) {
|
||||||
|
pruner.execQueue = newExecQueue(len(pfss))
|
||||||
|
for _, pfs := range pfss {
|
||||||
|
pruner.execQueue.Put(pfs, nil, false)
|
||||||
|
}
|
||||||
|
pruner.state = Exec
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
var pfs *fs
|
||||||
|
u(func(pruner *Pruner) {
|
||||||
|
pfs = pruner.execQueue.Pop()
|
||||||
|
})
|
||||||
|
if pfs == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
doOneAttemptExec(a, u, pfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rep *Report
|
||||||
|
{
|
||||||
|
// must not hold lock for report
|
||||||
|
var pruner *Pruner
|
||||||
|
u(func(p *Pruner) {
|
||||||
|
pruner = p
|
||||||
|
})
|
||||||
|
rep = pruner.Report()
|
||||||
|
}
|
||||||
|
u(func(p *Pruner) {
|
||||||
|
if len(rep.Pending) > 0 {
|
||||||
|
panic("queue should not have pending items at this point")
|
||||||
|
}
|
||||||
|
hadErr := false
|
||||||
|
for _, fsr := range rep.Completed {
|
||||||
|
hadErr = hadErr || fsr.SkipReason.NotSkipped() && fsr.LastError != ""
|
||||||
|
}
|
||||||
|
if hadErr {
|
||||||
|
p.state = ExecErr
|
||||||
|
} else {
|
||||||
|
p.state = Done
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempts to exec pfs, puts it back into the queue with the result
|
||||||
|
func doOneAttemptExec(a *args, u updater, pfs *fs) {
|
||||||
|
|
||||||
|
destroyList := make([]*pdu.FilesystemVersion, len(pfs.destroyList))
|
||||||
|
for i := range destroyList {
|
||||||
|
destroyList[i] = pfs.destroyList[i].(snapshot).fsv
|
||||||
|
GetLogger(a.ctx).
|
||||||
|
WithField("fs", pfs.path).
|
||||||
|
WithField("destroy_snap", destroyList[i].Name).
|
||||||
|
Debug("policy destroys snapshot")
|
||||||
|
}
|
||||||
|
req := pdu.DestroySnapshotsReq{
|
||||||
|
Filesystem: pfs.path,
|
||||||
|
Snapshots: destroyList,
|
||||||
|
}
|
||||||
|
GetLogger(a.ctx).WithField("fs", pfs.path).Debug("destroying snapshots")
|
||||||
|
res, err := a.target.DestroySnapshots(a.ctx, &req)
|
||||||
|
if err != nil {
|
||||||
|
u(func(pruner *Pruner) {
|
||||||
|
pruner.execQueue.Put(pfs, err, false)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// check if all snapshots were destroyed
|
||||||
|
destroyResults := make(map[string]*pdu.DestroySnapshotRes)
|
||||||
|
for _, fsres := range res.Results {
|
||||||
|
destroyResults[fsres.Snapshot.Name] = fsres
|
||||||
|
}
|
||||||
|
err = nil
|
||||||
|
destroyFails := make([]*pdu.DestroySnapshotRes, 0)
|
||||||
|
for _, reqDestroy := range destroyList {
|
||||||
|
res, ok := destroyResults[reqDestroy.Name]
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("missing destroy-result for %s", reqDestroy.RelName())
|
||||||
|
break
|
||||||
|
} else if res.Error != "" {
|
||||||
|
destroyFails = append(destroyFails, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil && len(destroyFails) > 0 {
|
||||||
|
names := make([]string, len(destroyFails))
|
||||||
|
pairs := make([]string, len(destroyFails))
|
||||||
|
allSame := true
|
||||||
|
lastMsg := destroyFails[0].Error
|
||||||
|
for i := 0; i < len(destroyFails); i++ {
|
||||||
|
allSame = allSame && destroyFails[i].Error == lastMsg
|
||||||
|
relname := destroyFails[i].Snapshot.RelName()
|
||||||
|
names[i] = relname
|
||||||
|
pairs[i] = fmt.Sprintf("(%s: %s)", relname, destroyFails[i].Error)
|
||||||
|
}
|
||||||
|
if allSame {
|
||||||
|
err = fmt.Errorf("destroys failed %s: %s",
|
||||||
|
strings.Join(names, ", "), lastMsg)
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("destroys failed: %s", strings.Join(pairs, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u(func(pruner *Pruner) {
|
||||||
|
pruner.execQueue.Put(pfs, err, err == nil)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
GetLogger(a.ctx).WithError(err).Error("target could not destroy snapshots")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package pruner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type execQueue struct {
|
||||||
|
mtx sync.Mutex
|
||||||
|
pending, completed []*fs
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExecQueue(cap int) *execQueue {
|
||||||
|
q := execQueue{
|
||||||
|
pending: make([]*fs, 0, cap),
|
||||||
|
completed: make([]*fs, 0, cap),
|
||||||
|
}
|
||||||
|
return &q
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *execQueue) Report() (pending, completed []FSReport) {
|
||||||
|
q.mtx.Lock()
|
||||||
|
defer q.mtx.Unlock()
|
||||||
|
|
||||||
|
pending = make([]FSReport, len(q.pending))
|
||||||
|
for i, fs := range q.pending {
|
||||||
|
pending[i] = fs.Report()
|
||||||
|
}
|
||||||
|
completed = make([]FSReport, len(q.completed))
|
||||||
|
for i, fs := range q.completed {
|
||||||
|
completed[i] = fs.Report()
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending, completed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *execQueue) HasCompletedFSWithErrors() bool {
|
||||||
|
q.mtx.Lock()
|
||||||
|
defer q.mtx.Unlock()
|
||||||
|
for _, fs := range q.completed {
|
||||||
|
if fs.execErrLast != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *execQueue) Pop() *fs {
|
||||||
|
if len(q.pending) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fs := q.pending[0]
|
||||||
|
q.pending = q.pending[1:]
|
||||||
|
return fs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *execQueue) Put(fs *fs, err error, done bool) {
|
||||||
|
fs.mtx.Lock()
|
||||||
|
fs.execErrLast = err
|
||||||
|
if done || err != nil {
|
||||||
|
fs.mtx.Unlock()
|
||||||
|
q.mtx.Lock()
|
||||||
|
q.completed = append(q.completed, fs)
|
||||||
|
q.mtx.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fs.mtx.Unlock()
|
||||||
|
|
||||||
|
q.mtx.Lock()
|
||||||
|
// inefficient priority q
|
||||||
|
q.pending = append(q.pending, fs)
|
||||||
|
sort.SliceStable(q.pending, func(i, j int) bool {
|
||||||
|
q.pending[i].mtx.Lock()
|
||||||
|
defer q.pending[i].mtx.Unlock()
|
||||||
|
q.pending[j].mtx.Lock()
|
||||||
|
defer q.pending[j].mtx.Unlock()
|
||||||
|
return strings.Compare(q.pending[i].path, q.pending[j].path) == -1
|
||||||
|
})
|
||||||
|
q.mtx.Unlock()
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Code generated by "enumer -type=State"; DO NOT EDIT.
|
||||||
|
|
||||||
|
package pruner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
_StateName_0 = "PlanPlanErr"
|
||||||
|
_StateName_1 = "Exec"
|
||||||
|
_StateName_2 = "ExecErr"
|
||||||
|
_StateName_3 = "Done"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_StateIndex_0 = [...]uint8{0, 4, 11}
|
||||||
|
_StateIndex_1 = [...]uint8{0, 4}
|
||||||
|
_StateIndex_2 = [...]uint8{0, 7}
|
||||||
|
_StateIndex_3 = [...]uint8{0, 4}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i State) String() string {
|
||||||
|
switch {
|
||||||
|
case 1 <= i && i <= 2:
|
||||||
|
i -= 1
|
||||||
|
return _StateName_0[_StateIndex_0[i]:_StateIndex_0[i+1]]
|
||||||
|
case i == 4:
|
||||||
|
return _StateName_1
|
||||||
|
case i == 8:
|
||||||
|
return _StateName_2
|
||||||
|
case i == 16:
|
||||||
|
return _StateName_3
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("State(%d)", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _StateValues = []State{1, 2, 4, 8, 16}
|
||||||
|
|
||||||
|
var _StateNameToValueMap = map[string]State{
|
||||||
|
_StateName_0[0:4]: 1,
|
||||||
|
_StateName_0[4:11]: 2,
|
||||||
|
_StateName_1[0:4]: 4,
|
||||||
|
_StateName_2[0:7]: 8,
|
||||||
|
_StateName_3[0:4]: 16,
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateString retrieves an enum value from the enum constants string name.
|
||||||
|
// Throws an error if the param is not part of the enum.
|
||||||
|
func StateString(s string) (State, error) {
|
||||||
|
if val, ok := _StateNameToValueMap[s]; ok {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("%s does not belong to State values", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateValues returns all values of the enum
|
||||||
|
func StateValues() []State {
|
||||||
|
return _StateValues
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAState returns "true" if the value is listed in the enum definition. "false" otherwise
|
||||||
|
func (i State) IsAState() bool {
|
||||||
|
for _, v := range _StateValues {
|
||||||
|
if i == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package snapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon/filters"
|
||||||
|
"github.com/zrepl/zrepl/logger"
|
||||||
|
"github.com/zrepl/zrepl/zfs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate stringer -type=SnapState
|
||||||
|
type SnapState uint
|
||||||
|
|
||||||
|
const (
|
||||||
|
SnapPending SnapState = 1 << iota
|
||||||
|
SnapStarted
|
||||||
|
SnapDone
|
||||||
|
SnapError
|
||||||
|
)
|
||||||
|
|
||||||
|
type snapProgress struct {
|
||||||
|
state SnapState
|
||||||
|
|
||||||
|
// SnapStarted, SnapDone, SnapError
|
||||||
|
name string
|
||||||
|
startAt time.Time
|
||||||
|
|
||||||
|
// SnapDone
|
||||||
|
doneAt time.Time
|
||||||
|
|
||||||
|
// SnapErr
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
ctx context.Context
|
||||||
|
log Logger
|
||||||
|
prefix string
|
||||||
|
interval time.Duration
|
||||||
|
fsf *filters.DatasetMapFilter
|
||||||
|
snapshotsTaken chan<- struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Snapper struct {
|
||||||
|
args args
|
||||||
|
|
||||||
|
mtx sync.Mutex
|
||||||
|
state State
|
||||||
|
|
||||||
|
// set in state Plan, used in Waiting
|
||||||
|
lastInvocation time.Time
|
||||||
|
|
||||||
|
// valid for state Snapshotting
|
||||||
|
plan map[*zfs.DatasetPath]snapProgress
|
||||||
|
|
||||||
|
// valid for state SyncUp and Waiting
|
||||||
|
sleepUntil time.Time
|
||||||
|
|
||||||
|
// valid for state Err
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:generate stringer -type=State
|
||||||
|
type State uint
|
||||||
|
|
||||||
|
const (
|
||||||
|
SyncUp State = 1 << iota
|
||||||
|
SyncUpErrWait
|
||||||
|
Planning
|
||||||
|
Snapshotting
|
||||||
|
Waiting
|
||||||
|
ErrorWait
|
||||||
|
Stopped
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s State) sf() state {
|
||||||
|
m := map[State]state{
|
||||||
|
SyncUp: syncUp,
|
||||||
|
SyncUpErrWait: wait,
|
||||||
|
Planning: plan,
|
||||||
|
Snapshotting: snapshot,
|
||||||
|
Waiting: wait,
|
||||||
|
ErrorWait: wait,
|
||||||
|
Stopped: nil,
|
||||||
|
}
|
||||||
|
return m[s]
|
||||||
|
}
|
||||||
|
|
||||||
|
type updater func(u func(*Snapper)) State
|
||||||
|
type state func(a args, u updater) state
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
contextKeyLog contextKey = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logger = logger.Logger
|
||||||
|
|
||||||
|
func WithLogger(ctx context.Context, log Logger) context.Context {
|
||||||
|
return context.WithValue(ctx, contextKeyLog, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogger(ctx context.Context) Logger {
|
||||||
|
if log, ok := ctx.Value(contextKeyLog).(Logger); ok {
|
||||||
|
return log
|
||||||
|
}
|
||||||
|
return logger.NewNullLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
func PeriodicFromConfig(g *config.Global, fsf *filters.DatasetMapFilter, in *config.SnapshottingPeriodic) (*Snapper, error) {
|
||||||
|
if in.Prefix == "" {
|
||||||
|
return nil, errors.New("prefix must not be empty")
|
||||||
|
}
|
||||||
|
if in.Interval <= 0 {
|
||||||
|
return nil, errors.New("interval must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := args{
|
||||||
|
prefix: in.Prefix,
|
||||||
|
interval: in.Interval,
|
||||||
|
fsf: fsf,
|
||||||
|
// ctx and log is set in Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Snapper{state: SyncUp, args: args}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Snapper) Run(ctx context.Context, snapshotsTaken chan<- struct{}) {
|
||||||
|
|
||||||
|
getLogger(ctx).Debug("start")
|
||||||
|
defer getLogger(ctx).Debug("stop")
|
||||||
|
|
||||||
|
s.args.snapshotsTaken = snapshotsTaken
|
||||||
|
s.args.ctx = ctx
|
||||||
|
s.args.log = getLogger(ctx)
|
||||||
|
|
||||||
|
u := func(u func(*Snapper)) State {
|
||||||
|
s.mtx.Lock()
|
||||||
|
defer s.mtx.Unlock()
|
||||||
|
if u != nil {
|
||||||
|
u(s)
|
||||||
|
}
|
||||||
|
return s.state
|
||||||
|
}
|
||||||
|
|
||||||
|
var st state = syncUp
|
||||||
|
|
||||||
|
for st != nil {
|
||||||
|
pre := u(nil)
|
||||||
|
st = st(s.args, u)
|
||||||
|
post := u(nil)
|
||||||
|
getLogger(ctx).
|
||||||
|
WithField("transition", fmt.Sprintf("%s=>%s", pre, post)).
|
||||||
|
Debug("state transition")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func onErr(err error, u updater) state {
|
||||||
|
return u(func(s *Snapper) {
|
||||||
|
s.err = err
|
||||||
|
preState := s.state
|
||||||
|
switch s.state {
|
||||||
|
case SyncUp:
|
||||||
|
s.state = SyncUpErrWait
|
||||||
|
case Planning:
|
||||||
|
fallthrough
|
||||||
|
case Snapshotting:
|
||||||
|
s.state = ErrorWait
|
||||||
|
}
|
||||||
|
s.args.log.WithError(err).WithField("pre_state", preState).WithField("post_state", s.state).Error("snapshotting error")
|
||||||
|
}).sf()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onMainCtxDone(ctx context.Context, u updater) state {
|
||||||
|
return u(func(s *Snapper) {
|
||||||
|
s.err = ctx.Err()
|
||||||
|
s.state = Stopped
|
||||||
|
}).sf()
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncUp(a args, u updater) state {
|
||||||
|
u(func(snapper *Snapper) {
|
||||||
|
snapper.lastInvocation = time.Now()
|
||||||
|
})
|
||||||
|
fss, err := listFSes(a.ctx, a.fsf)
|
||||||
|
if err != nil {
|
||||||
|
return onErr(err, u)
|
||||||
|
}
|
||||||
|
syncPoint, err := findSyncPoint(a.log, fss, a.prefix, a.interval)
|
||||||
|
if err != nil {
|
||||||
|
return onErr(err, u)
|
||||||
|
}
|
||||||
|
u(func(s *Snapper) {
|
||||||
|
s.sleepUntil = syncPoint
|
||||||
|
})
|
||||||
|
t := time.NewTimer(time.Until(syncPoint))
|
||||||
|
defer t.Stop()
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
return u(func(s *Snapper) {
|
||||||
|
s.state = Planning
|
||||||
|
}).sf()
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return onMainCtxDone(a.ctx, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plan(a args, u updater) state {
|
||||||
|
u(func(snapper *Snapper) {
|
||||||
|
snapper.lastInvocation = time.Now()
|
||||||
|
})
|
||||||
|
fss, err := listFSes(a.ctx, a.fsf)
|
||||||
|
if err != nil {
|
||||||
|
return onErr(err, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan := make(map[*zfs.DatasetPath]snapProgress, len(fss))
|
||||||
|
for _, fs := range fss {
|
||||||
|
plan[fs] = snapProgress{state: SnapPending}
|
||||||
|
}
|
||||||
|
return u(func(s *Snapper) {
|
||||||
|
s.state = Snapshotting
|
||||||
|
s.plan = plan
|
||||||
|
}).sf()
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(a args, u updater) state {
|
||||||
|
|
||||||
|
var plan map[*zfs.DatasetPath]snapProgress
|
||||||
|
u(func(snapper *Snapper) {
|
||||||
|
plan = snapper.plan
|
||||||
|
})
|
||||||
|
|
||||||
|
hadErr := false
|
||||||
|
// TODO channel programs -> allow a little jitter?
|
||||||
|
for fs, progress := range plan {
|
||||||
|
suffix := time.Now().In(time.UTC).Format("20060102_150405_000")
|
||||||
|
snapname := fmt.Sprintf("%s%s", a.prefix, suffix)
|
||||||
|
|
||||||
|
l := a.log.
|
||||||
|
WithField("fs", fs.ToString()).
|
||||||
|
WithField("snap", snapname)
|
||||||
|
|
||||||
|
u(func(snapper *Snapper) {
|
||||||
|
progress.name = snapname
|
||||||
|
progress.startAt = time.Now()
|
||||||
|
progress.state = SnapStarted
|
||||||
|
})
|
||||||
|
|
||||||
|
l.Debug("create snapshot")
|
||||||
|
err := zfs.ZFSSnapshot(fs, snapname, false)
|
||||||
|
if err != nil {
|
||||||
|
hadErr = true
|
||||||
|
l.WithError(err).Error("cannot create snapshot")
|
||||||
|
}
|
||||||
|
doneAt := time.Now()
|
||||||
|
|
||||||
|
u(func(snapper *Snapper) {
|
||||||
|
progress.doneAt = doneAt
|
||||||
|
progress.state = SnapDone
|
||||||
|
if err != nil {
|
||||||
|
progress.state = SnapError
|
||||||
|
progress.err = err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case a.snapshotsTaken <- struct{}{}:
|
||||||
|
default:
|
||||||
|
if a.snapshotsTaken != nil {
|
||||||
|
a.log.Warn("callback channel is full, discarding snapshot update event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return u(func(snapper *Snapper) {
|
||||||
|
if hadErr {
|
||||||
|
snapper.state = ErrorWait
|
||||||
|
snapper.err = errors.New("one or more snapshots could not be created, check logs for details")
|
||||||
|
} else {
|
||||||
|
snapper.state = Waiting
|
||||||
|
}
|
||||||
|
}).sf()
|
||||||
|
}
|
||||||
|
|
||||||
|
func wait(a args, u updater) state {
|
||||||
|
var sleepUntil time.Time
|
||||||
|
u(func(snapper *Snapper) {
|
||||||
|
lastTick := snapper.lastInvocation
|
||||||
|
snapper.sleepUntil = lastTick.Add(a.interval)
|
||||||
|
sleepUntil = snapper.sleepUntil
|
||||||
|
log := a.log.WithField("sleep_until", sleepUntil).WithField("duration", a.interval)
|
||||||
|
logFunc := log.Debug
|
||||||
|
if snapper.state == ErrorWait || snapper.state == SyncUpErrWait {
|
||||||
|
logFunc = log.Error
|
||||||
|
}
|
||||||
|
logFunc("enter wait-state after error")
|
||||||
|
})
|
||||||
|
|
||||||
|
t := time.NewTimer(time.Until(sleepUntil))
|
||||||
|
defer t.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-t.C:
|
||||||
|
return u(func(snapper *Snapper) {
|
||||||
|
snapper.state = Planning
|
||||||
|
}).sf()
|
||||||
|
case <-a.ctx.Done():
|
||||||
|
return onMainCtxDone(a.ctx, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listFSes(ctx context.Context, mf *filters.DatasetMapFilter) (fss []*zfs.DatasetPath, err error) {
|
||||||
|
return zfs.ZFSListMapping(ctx, mf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSyncPoint(log Logger, fss []*zfs.DatasetPath, prefix string, interval time.Duration) (syncPoint time.Time, err error) {
|
||||||
|
type snapTime struct {
|
||||||
|
ds *zfs.DatasetPath
|
||||||
|
time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fss) == 0 {
|
||||||
|
return time.Now(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
snaptimes := make([]snapTime, 0, len(fss))
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
log.Debug("examine filesystem state")
|
||||||
|
for _, d := range fss {
|
||||||
|
|
||||||
|
l := log.WithField("fs", d.ToString())
|
||||||
|
|
||||||
|
fsvs, err := zfs.ZFSListFilesystemVersions(d, filters.NewTypedPrefixFilter(prefix, zfs.Snapshot))
|
||||||
|
if err != nil {
|
||||||
|
l.WithError(err).Error("cannot list filesystem versions")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(fsvs) <= 0 {
|
||||||
|
l.WithField("prefix", prefix).Debug("no filesystem versions with prefix")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort versions by creation
|
||||||
|
sort.SliceStable(fsvs, func(i, j int) bool {
|
||||||
|
return fsvs[i].CreateTXG < fsvs[j].CreateTXG
|
||||||
|
})
|
||||||
|
|
||||||
|
latest := fsvs[len(fsvs)-1]
|
||||||
|
l.WithField("creation", latest.Creation).
|
||||||
|
Debug("found latest snapshot")
|
||||||
|
|
||||||
|
since := now.Sub(latest.Creation)
|
||||||
|
if since < 0 {
|
||||||
|
l.WithField("snapshot", latest.Name).
|
||||||
|
WithField("creation", latest.Creation).
|
||||||
|
Error("snapshot is from the future")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next := now
|
||||||
|
if since < interval {
|
||||||
|
next = latest.Creation.Add(interval)
|
||||||
|
}
|
||||||
|
snaptimes = append(snaptimes, snapTime{d, next})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(snaptimes) == 0 {
|
||||||
|
snaptimes = append(snaptimes, snapTime{nil, now})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(snaptimes, func(i, j int) bool {
|
||||||
|
return snaptimes[i].time.Before(snaptimes[j].time)
|
||||||
|
})
|
||||||
|
|
||||||
|
return snaptimes[0].time, nil
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package snapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/zrepl/zrepl/config"
|
||||||
|
"github.com/zrepl/zrepl/daemon/filters"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FIXME: properly abstract snapshotting:
|
||||||
|
// - split up things that trigger snapshotting from the mechanism
|
||||||
|
// - timer-based trigger (periodic)
|
||||||
|
// - call from control socket (manual)
|
||||||
|
// - mixed modes?
|
||||||
|
// - support a `zrepl snapshot JOBNAME` subcommand for config.SnapshottingManual
|
||||||
|
type PeriodicOrManual struct {
|
||||||
|
s *Snapper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PeriodicOrManual) Run(ctx context.Context, wakeUpCommon chan<- struct{}) {
|
||||||
|
if s.s != nil {
|
||||||
|
s.s.Run(ctx, wakeUpCommon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromConfig(g *config.Global, fsf *filters.DatasetMapFilter, in config.SnapshottingEnum) (*PeriodicOrManual, error) {
|
||||||
|
switch v := in.Ret.(type) {
|
||||||
|
case *config.SnapshottingPeriodic:
|
||||||
|
snapper, err := PeriodicFromConfig(g, fsf, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PeriodicOrManual{snapper}, nil
|
||||||
|
case *config.SnapshottingManual:
|
||||||
|
return &PeriodicOrManual{}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown snapshotting type %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Code generated by "stringer -type=SnapState"; DO NOT EDIT.
|
||||||
|
|
||||||
|
package snapper
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
const (
|
||||||
|
_SnapState_name_0 = "SnapPendingSnapStarted"
|
||||||
|
_SnapState_name_1 = "SnapDone"
|
||||||
|
_SnapState_name_2 = "SnapError"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_SnapState_index_0 = [...]uint8{0, 11, 22}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i SnapState) String() string {
|
||||||
|
switch {
|
||||||
|
case 1 <= i && i <= 2:
|
||||||
|
i -= 1
|
||||||
|
return _SnapState_name_0[_SnapState_index_0[i]:_SnapState_index_0[i+1]]
|
||||||
|
case i == 4:
|
||||||
|
return _SnapState_name_1
|
||||||
|
case i == 8:
|
||||||
|
return _SnapState_name_2
|
||||||
|
default:
|
||||||
|
return "SnapState(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Code generated by "stringer -type=State"; DO NOT EDIT.
|
||||||
|
|
||||||
|
package snapper
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
const (
|
||||||
|
_State_name_0 = "SyncUpSyncUpErrWait"
|
||||||
|
_State_name_1 = "Planning"
|
||||||
|
_State_name_2 = "Snapshotting"
|
||||||
|
_State_name_3 = "Waiting"
|
||||||
|
_State_name_4 = "ErrorWait"
|
||||||
|
_State_name_5 = "Stopped"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_State_index_0 = [...]uint8{0, 6, 19}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i State) String() string {
|
||||||
|
switch {
|
||||||
|
case 1 <= i && i <= 2:
|
||||||
|
i -= 1
|
||||||
|
return _State_name_0[_State_index_0[i]:_State_index_0[i+1]]
|
||||||
|
case i == 4:
|
||||||
|
return _State_name_1
|
||||||
|
case i == 8:
|
||||||
|
return _State_name_2
|
||||||
|
case i == 16:
|
||||||
|
return _State_name_3
|
||||||
|
case i == 32:
|
||||||
|
return _State_name_4
|
||||||
|
case i == 64:
|
||||||
|
return _State_name_5
|
||||||
|
default:
|
||||||
|
return "State(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
+595
@@ -0,0 +1,595 @@
|
|||||||
|
{
|
||||||
|
"annotations": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"$$hashKey": "object:3351",
|
||||||
|
"builtIn": 1,
|
||||||
|
"datasource": "-- Grafana --",
|
||||||
|
"enable": true,
|
||||||
|
"hide": true,
|
||||||
|
"iconColor": "rgba(0, 211, 255, 1)",
|
||||||
|
"name": "Annotations & Alerts",
|
||||||
|
"type": "dashboard"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"gnetId": null,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": 7,
|
||||||
|
"iteration": 1552746418826,
|
||||||
|
"links": [],
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"content": "# zrepl Prometheus Metrics\n\nzrepl exposes Prometheus metrics and ships with this Grafana dashboard.\nThe exported metrics are suitable for health checks:\n\n* The log should generally be warning & error-free\n * The `Log Messages that require attention` graph visualizes log message counts indicating problems.\n* The number of goroutines should not grow unboundedly over time.\n * During replication, the number of goroutines can be way higher than during idle time.\n * If the goroutine count grows with each replication, there is clearly a goroutine leak. Please open a bug report.\n* The sys memory consumption should not grow unboundedly over time.\n * Note that the Go runtime pre-allocates some of its heap from the OS.\n * zrepl actually uses much less memory than allocated from the OS.\n * Since Go 1.11, Go pre-allocates more aggressively.\n* Monitor that some data is replicated, although that metric does not guarantee that replication was successful.\n\n**In general, note that the exported metrics are not stable unless declared otherwise.**",
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 24,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"id": 35,
|
||||||
|
"mode": "markdown",
|
||||||
|
"title": "Panel Title",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 9
|
||||||
|
},
|
||||||
|
"id": 15,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"links": [],
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 5,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": true,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"$$hashKey": "object:3436",
|
||||||
|
"expr": "up{job='$prom_job_name'}",
|
||||||
|
"format": "time_series",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "zrepl Instances Up",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": "5",
|
||||||
|
"min": "0",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 9
|
||||||
|
},
|
||||||
|
"id": 22,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"links": [],
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 5,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "increase(zrepl_daemon_log_entries{job='$prom_job_name',level=~'warn|error'}[$__interval])",
|
||||||
|
"format": "time_series",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Log Messages that require attention",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": "0",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 18
|
||||||
|
},
|
||||||
|
"id": 33,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"links": [],
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 5,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [
|
||||||
|
{
|
||||||
|
"alias": "/replicated bytes in last.*/",
|
||||||
|
"yaxis": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(zrepl_replication_bytes_replicated{job='$prom_job_name'}[$__interval])) by (zrepl_job)",
|
||||||
|
"format": "time_series",
|
||||||
|
"hide": false,
|
||||||
|
"interval": "",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"legendFormat": "replication data rate zrepl_job={{zrepl_job}}",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(zrepl_replication_bytes_replicated{job='$prom_job_name'}[10m])) by (zrepl_job)",
|
||||||
|
"format": "time_series",
|
||||||
|
"hide": false,
|
||||||
|
"interval": "",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"legendFormat": "replicated bytes in last 10min zrepl_job={{zrepl_job}}",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Replication Data Rate and Volume(integrates last 10min)",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "Bps",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "bytes",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 18
|
||||||
|
},
|
||||||
|
"id": 23,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"links": [],
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 5,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(increase(zrepl_daemon_log_entries{job='$prom_job_name',zrepl_job=~\"^[^_].*\"}[$__interval])) by (instance,zrepl_job)",
|
||||||
|
"format": "time_series",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Log Activity (without internal jobs)",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": "0",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 12,
|
||||||
|
"x": 0,
|
||||||
|
"y": 27
|
||||||
|
},
|
||||||
|
"id": 17,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"links": [],
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 5,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"$$hashKey": "object:3535",
|
||||||
|
"expr": "go_memstats_sys_bytes{job='$prom_job_name'}",
|
||||||
|
"format": "time_series",
|
||||||
|
"hide": false,
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "Memory Allocated by the Go runtime from the OS (should not grow unboundedly)",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "bytes",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": "0",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"aliasColors": {},
|
||||||
|
"bars": false,
|
||||||
|
"dashLength": 10,
|
||||||
|
"dashes": false,
|
||||||
|
"datasource": null,
|
||||||
|
"fill": 1,
|
||||||
|
"gridPos": {
|
||||||
|
"h": 9,
|
||||||
|
"w": 12,
|
||||||
|
"x": 12,
|
||||||
|
"y": 27
|
||||||
|
},
|
||||||
|
"id": 19,
|
||||||
|
"legend": {
|
||||||
|
"avg": false,
|
||||||
|
"current": false,
|
||||||
|
"max": false,
|
||||||
|
"min": false,
|
||||||
|
"show": true,
|
||||||
|
"total": false,
|
||||||
|
"values": false
|
||||||
|
},
|
||||||
|
"lines": true,
|
||||||
|
"linewidth": 1,
|
||||||
|
"links": [],
|
||||||
|
"nullPointMode": "null",
|
||||||
|
"percentage": false,
|
||||||
|
"pointradius": 5,
|
||||||
|
"points": false,
|
||||||
|
"renderer": "flot",
|
||||||
|
"seriesOverrides": [],
|
||||||
|
"spaceLength": 10,
|
||||||
|
"stack": false,
|
||||||
|
"steppedLine": false,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "go_goroutines{job='$prom_job_name'}",
|
||||||
|
"format": "time_series",
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thresholds": [],
|
||||||
|
"timeFrom": null,
|
||||||
|
"timeShift": null,
|
||||||
|
"title": "number of goroutines (should not grow unboundedly)",
|
||||||
|
"tooltip": {
|
||||||
|
"shared": true,
|
||||||
|
"sort": 0,
|
||||||
|
"value_type": "individual"
|
||||||
|
},
|
||||||
|
"type": "graph",
|
||||||
|
"xaxis": {
|
||||||
|
"buckets": null,
|
||||||
|
"mode": "time",
|
||||||
|
"name": null,
|
||||||
|
"show": true,
|
||||||
|
"values": []
|
||||||
|
},
|
||||||
|
"yaxes": [
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": "0",
|
||||||
|
"show": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"format": "short",
|
||||||
|
"label": null,
|
||||||
|
"logBase": 1,
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"show": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "1m",
|
||||||
|
"schemaVersion": 16,
|
||||||
|
"style": "dark",
|
||||||
|
"tags": [],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"allValue": null,
|
||||||
|
"current": {
|
||||||
|
"text": "zrepl",
|
||||||
|
"value": "zrepl"
|
||||||
|
},
|
||||||
|
"datasource": "prometheus",
|
||||||
|
"hide": 0,
|
||||||
|
"includeAll": false,
|
||||||
|
"label": "Prometheus Job Name",
|
||||||
|
"multi": false,
|
||||||
|
"name": "prom_job_name",
|
||||||
|
"options": [],
|
||||||
|
"query": "label_values(up, job)",
|
||||||
|
"refresh": 1,
|
||||||
|
"regex": "",
|
||||||
|
"sort": 1,
|
||||||
|
"tagValuesQuery": "",
|
||||||
|
"tags": [],
|
||||||
|
"tagsQuery": "",
|
||||||
|
"type": "query",
|
||||||
|
"useTags": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-2d",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {
|
||||||
|
"refresh_intervals": [
|
||||||
|
"5s",
|
||||||
|
"10s",
|
||||||
|
"30s",
|
||||||
|
"1m",
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h",
|
||||||
|
"2h",
|
||||||
|
"1d"
|
||||||
|
],
|
||||||
|
"time_options": [
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"1h",
|
||||||
|
"6h",
|
||||||
|
"12h",
|
||||||
|
"24h",
|
||||||
|
"2d",
|
||||||
|
"7d",
|
||||||
|
"30d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timezone": "",
|
||||||
|
"title": "zrepl 0.1",
|
||||||
|
"uid": "xTljn4qmk",
|
||||||
|
"version": 6
|
||||||
|
}
|
||||||
Vendored
+41
@@ -0,0 +1,41 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=zrepl daemon
|
||||||
|
Documentation=https://zrepl.github.io
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/zrepl --config /etc/zrepl/zrepl.yml daemon
|
||||||
|
RuntimeDirectory=zrepl
|
||||||
|
RuntimeDirectoryMode=0700
|
||||||
|
|
||||||
|
ProtectSystem=strict
|
||||||
|
#PrivateDevices=yes # TODO ZFS needs access to /dev/zfs, could we limit this?
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
#PrivateUsers=yes # TODO Does not work, why?
|
||||||
|
ProtectKernelModules=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictRealtime=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
|
||||||
|
# BEGIN ProtectHome
|
||||||
|
ProtectHome=read-only # DEBIAN STRETCH
|
||||||
|
# ProtectHome=tmpfs # FEDORA 28 / 29
|
||||||
|
# END ProtectHome
|
||||||
|
|
||||||
|
# BEGIN SystemCallFilter
|
||||||
|
## BEGIN DEBIAN STRETCH
|
||||||
|
SystemCallFilter=~ @mount @cpu-emulation @keyring @module @obsolete @privileged @raw-io @debug @clock @resources
|
||||||
|
## END DEBIAN STRETCH
|
||||||
|
## BEGIN FEDORA 28/29
|
||||||
|
## Syscall blacklist (should be fairly stable)
|
||||||
|
#SystemCallFilter=~ @mount @aio @cpu-emulation @keyring @memlock @module @obsolete @privileged @raw-io @reboot @setuid @swap @sync @timer @debug @clock @chown @resources
|
||||||
|
## Syscall whitelist (not sure how stable)
|
||||||
|
#SystemCallFilter=@default @file-system @process @basic-io @ipc @network-io @signal @io-event brk mprotect sched_getaffinity ioctl getrandom
|
||||||
|
## END END FEDORA 28/29
|
||||||
|
# END SystemCallFilter
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+2
-1
@@ -1 +1,2 @@
|
|||||||
public_git
|
_build/
|
||||||
|
public_git/
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Minimal makefile for Sphinx documentation
|
||||||
|
#
|
||||||
|
|
||||||
|
# You can set these variables from the command line.
|
||||||
|
SPHINXOPTS =
|
||||||
|
SPHINXBUILD = sphinx-build
|
||||||
|
SPHINXPROJ = zrepl
|
||||||
|
SOURCEDIR = .
|
||||||
|
BUILDDIR = _build
|
||||||
|
|
||||||
|
# Put it first so that "make" without argument is like "make help".
|
||||||
|
help:
|
||||||
|
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" -c sphinxconf $(SPHINXOPTS) $(O)
|
||||||
|
|
||||||
|
.PHONY: help Makefile
|
||||||
|
|
||||||
|
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||||
|
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||||
|
%: Makefile
|
||||||
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" -c sphinxconf $(SPHINXOPTS) $(O)
|
||||||
Vendored
+147
@@ -0,0 +1,147 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
id="svg8"
|
||||||
|
version="1.1"
|
||||||
|
viewBox="0 0 70.555558 24.694445"
|
||||||
|
height="70pt"
|
||||||
|
width="200pt"
|
||||||
|
sodipodi:docname="zrepl.svg"
|
||||||
|
inkscape:version="0.92.2 5c3e80d, 2017-08-06">
|
||||||
|
<sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1405"
|
||||||
|
id="namedview10"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="3.1350001"
|
||||||
|
inkscape:cx="6.2268563"
|
||||||
|
inkscape:cy="-2.6953765"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="35"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs2" />
|
||||||
|
<metadata
|
||||||
|
id="metadata5">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
transform="translate(0,-272.30556)"
|
||||||
|
id="layer1">
|
||||||
|
<g
|
||||||
|
aria-label="zrepl"
|
||||||
|
transform="matrix(0.26458334,0,0,0.26458334,0.52916655,273.36389)"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:40px;line-height:25px;font-family:monospace;-inkscape-font-specification:'monospace, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:center;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:middle;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
id="flowRoot829">
|
||||||
|
<path
|
||||||
|
d="m 74.504883,26.943359 11.523437,-13.125 H 74.973633 V 9.5410156 h 17.480469 v 4.4726564 l -11.503907,13.125 h 11.503907 v 4.277344 H 74.504883 Z"
|
||||||
|
style="stroke:#f8f8f8;stroke-opacity:1;fill:#f8f8f8;fill-opacity:1"
|
||||||
|
id="path873" />
|
||||||
|
<path
|
||||||
|
d="m 100.11035,9.5410156 h 5.15625 l 0.56641,3.3984374 q 0.83984,-1.875 2.57812,-2.910156 1.73828,-1.0156251 4.00391,-1.0156251 1.26953,0 2.36328,0.2734375 1.25,0.3515625 2.12891,0.8398436 v 5.449219 q -0.9375,-0.859375 -2.1875,-1.289063 -1.23047,-0.429687 -2.75391,-0.429687 -1.81641,0 -3.18359,0.625 -1.36719,0.605469 -2.10938,1.835937 -0.19531,0.3125 -0.37109,0.761719 -0.17578,0.449219 -0.27344,1.054688 -0.0977,0.566406 -0.15625,1.308593 -0.0391,0.742188 -0.0391,1.894532 v 10.078125 h -5.72266 z"
|
||||||
|
style="stroke:#f8f8f8;stroke-opacity:1;fill:#f8f8f8;fill-opacity:1"
|
||||||
|
id="path875" />
|
||||||
|
<path
|
||||||
|
d="m 132.4541,31.982422 q -2.85156,0 -4.9414,-0.722656 -2.07032,-0.742188 -3.53516,-2.226563 -2.91016,-2.96875 -2.91016,-8.4375 0,-5.371094 2.79297,-8.476562 2.79297,-3.1054691 7.65625,-3.1054691 3.10547,-0.097656 5.42969,1.2500001 2.34375,1.347656 3.59375,3.867187 1.26953,2.519532 1.17187,5.878907 v 2.324218 H 126.8877 q 0.0195,2.578125 1.52343,3.847657 1.50391,1.269531 4.47266,1.269531 1.93359,0 3.90625,-0.566406 0.89844,-0.273438 1.89453,-0.703125 0.99609,-0.429688 2.10938,-1.09375 v 5.253906 q -1.99219,0.820312 -4.0625,1.230469 -1.05469,0.214843 -2.14844,0.3125 -1.07422,0.09766 -2.12891,0.09766 z m 3.4961,-13.945313 q -0.0195,-2.285156 -1.15235,-3.4375 -1.11328,-1.152343 -3.28125,-1.152343 -2.01172,0 -3.18359,1.191406 -1.17188,1.210937 -1.38672,3.417969 z"
|
||||||
|
style="stroke:#f8f8f8;stroke-opacity:1;fill:#f8f8f8;fill-opacity:1"
|
||||||
|
id="path877" />
|
||||||
|
<path
|
||||||
|
d="m 146.45801,9.5410156 h 5.13672 l 0.5664,3.2812504 q 0.89844,-1.894532 2.36328,-2.8515629 1.42579,-0.9570312 3.45704,-0.9570312 3.71093,0 5.80078,2.9882811 1.05468,1.523438 1.5625,3.691406 0.50781,2.148438 0.50781,4.824219 0,2.851563 -0.54688,4.980469 -0.54687,2.109375 -1.54296,3.515625 -1.07422,1.523437 -2.59766,2.246094 -1.50391,0.722656 -3.28125,0.722656 -0.91797,0 -1.73828,-0.214844 -0.80078,-0.195312 -1.54297,-0.703125 -0.625,-0.410156 -1.28906,-1.09375 -0.64453,-0.703125 -1.15235,-1.640625 v 11.40625 h -5.70312 z m 9.70703,17.6757814 q 1.85547,0 2.91016,-1.796875 1.05468,-1.777344 1.05468,-4.960938 0,-3.164062 -1.05468,-4.960937 -1.05469,-1.796875 -2.91016,-1.796875 -1.83594,0 -2.92969,1.796875 -1.07422,1.777344 -1.07422,4.960937 0,3.183594 1.07422,4.960938 1.09375,1.796875 2.92969,1.796875 z"
|
||||||
|
style="stroke:#f8f8f8;stroke-opacity:1;fill:#f8f8f8;fill-opacity:1"
|
||||||
|
id="path879" />
|
||||||
|
<path
|
||||||
|
d="m 182.98145,31.572266 q -4.0625,0 -5.85938,-2.109375 -1.77734,-2.070313 -1.77734,-7.011719 V 5.4199219 h -5.80078 V 1.0253906 h 11.52343 V 22.451172 q 0,2.558594 0.80078,3.652344 0.78125,1.074218 2.71485,1.074218 h 4.57031 v 4.394532 z"
|
||||||
|
style="stroke:#f8f8f8;stroke-opacity:1;fill:#f8f8f8;fill-opacity:1"
|
||||||
|
id="path881" />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path837"
|
||||||
|
d="M 26.170418,286.30982 H 43.35751"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:medium;line-height:25px;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#f8f8f8;fill-opacity:1;fill-rule:evenodd;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
|
||||||
|
<g
|
||||||
|
aria-label="ZFS replication"
|
||||||
|
style="font-style:normal;font-weight:normal;font-size:10.58333397px;line-height:6.61458349px;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
id="text820">
|
||||||
|
<path
|
||||||
|
d="m 14.758548,294.66467 h -3.493327 v -0.47887 l 2.652724,-3.99286 h -2.570041 v -0.565 h 3.338297 v 0.47887 l -2.652723,3.99287 h 2.72507 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path844" />
|
||||||
|
<path
|
||||||
|
d="M 16.353627,294.66467 H 15.71973 v -5.03673 h 2.814643 v 0.55811 h -2.180746 v 1.78456 h 2.046387 v 0.55466 h -2.046387 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path846" />
|
||||||
|
<path
|
||||||
|
d="m 22.241296,293.31764 q 0,0.65801 -0.475423,1.03697 -0.475424,0.37896 -1.322917,0.37896 -0.895725,0 -1.384929,-0.23771 v -0.60633 q 0.320394,0.1378 0.699355,0.21704 0.382406,0.0792 0.713135,0.0792 0.564995,0 0.850938,-0.21704 0.285943,-0.21704 0.285943,-0.596 0,-0.25149 -0.103353,-0.41341 -0.103352,-0.16192 -0.347954,-0.30317 -0.241157,-0.14125 -0.733806,-0.32039 -0.699354,-0.25494 -0.999077,-0.59945 -0.299724,-0.34795 -0.299724,-0.89572 0,-0.58911 0.440973,-0.93707 0.444417,-0.34795 1.167887,-0.34795 0.754476,0 1.388373,0.28249 l -0.19637,0.54433 q -0.647678,-0.26872 -1.205783,-0.26872 -0.447863,0 -0.7028,0.19293 -0.251492,0.19292 -0.251492,0.54088 0,0.24804 0.09646,0.41341 0.09991,0.16192 0.316949,0.29628 0.220486,0.13436 0.689019,0.30661 0.56155,0.20671 0.837158,0.40308 0.275608,0.19292 0.406521,0.44786 0.130914,0.25149 0.130914,0.60289 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path848" />
|
||||||
|
<path
|
||||||
|
d="m 26.768151,290.81306 q 0.251492,0 0.444418,0.0413 l -0.07579,0.57188 q -0.21015,-0.0482 -0.409966,-0.0482 -0.299723,0 -0.558105,0.16537 -0.254937,0.16536 -0.399631,0.45819 -0.144694,0.28939 -0.144694,0.64424 v 2.01882 h -0.620118 v -3.78271 h 0.509874 l 0.0689,0.69246 h 0.02756 q 0.217041,-0.37551 0.513319,-0.56844 0.296279,-0.19292 0.644233,-0.19292 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path850" />
|
||||||
|
<path
|
||||||
|
d="m 29.465661,294.73357 q -0.850939,0 -1.336697,-0.51331 -0.482313,-0.51677 -0.482313,-1.41938 0,-0.90951 0.451307,-1.44694 0.451308,-0.54088 1.219564,-0.54088 0.713135,0 1.136881,0.46164 0.423747,0.4582 0.423747,1.24368 v 0.37551 h -2.590712 q 0.01723,0.64424 0.327284,0.97841 0.310059,0.33418 0.8785,0.33418 0.303168,0 0.575331,-0.0517 0.272162,-0.0551 0.637342,-0.21015 v 0.54432 q -0.313503,0.13436 -0.589111,0.18948 -0.275608,0.0551 -0.651123,0.0551 z m -0.155029,-3.41408 q -0.444418,0 -0.7028,0.28594 -0.258382,0.28594 -0.306613,0.79582 h 1.925808 q -0.0069,-0.53055 -0.244602,-0.80616 -0.237711,-0.2756 -0.671793,-0.2756 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path852" />
|
||||||
|
<path
|
||||||
|
d="m 33.630782,294.73357 q -0.761366,0 -1.171333,-0.54777 h -0.04134 l 0.01378,0.14125 q 0.02756,0.26872 0.02756,0.47887 v 1.55374 h -0.620117 v -5.4777 h 0.509874 l 0.08268,0.51676 h 0.02756 q 0.220486,-0.31006 0.509874,-0.44786 0.289388,-0.1378 0.668349,-0.1378 0.73725,0 1.143772,0.51332 0.409966,0.50987 0.409966,1.44005 0,0.92673 -0.409966,1.44694 -0.409967,0.5202 -1.150662,0.5202 z m -0.103353,-3.4003 q -0.561551,0 -0.813043,0.31694 -0.248047,0.31695 -0.254937,0.99564 v 0.12058 q 0,0.76825 0.254937,1.11276 0.254937,0.34107 0.826823,0.34107 0.475423,0 0.740696,-0.38586 0.268717,-0.38585 0.268717,-1.07487 0,-0.69246 -0.268717,-1.05764 -0.265273,-0.36862 -0.754476,-0.36862 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path854" />
|
||||||
|
<path
|
||||||
|
d="m 36.800269,294.66467 h -0.620117 v -5.36057 h 0.620117 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path856" />
|
||||||
|
<path
|
||||||
|
d="m 38.619281,294.66467 h -0.620117 v -3.78271 h 0.620117 z m -0.668348,-4.80591 q 0,-0.2067 0.103352,-0.29972 0.106798,-0.093 0.261828,-0.093 0.144694,0 0.251492,0.093 0.110243,0.093 0.110243,0.29972 0,0.20327 -0.110243,0.29973 -0.106798,0.0965 -0.251492,0.0965 -0.15503,0 -0.261828,-0.0965 -0.103352,-0.0965 -0.103352,-0.29973 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path858" />
|
||||||
|
<path
|
||||||
|
d="m 41.334016,294.73357 q -0.816488,0 -1.27124,-0.49953 -0.454753,-0.50299 -0.454753,-1.44005 0,-0.95085 0.458198,-1.46417 0.461642,-0.51676 1.312581,-0.51676 0.275608,0 0.544325,0.0586 0.272163,0.0551 0.447863,0.1447 l -0.189481,0.51676 q -0.478868,-0.17914 -0.816487,-0.17914 -0.571886,0 -0.844049,0.36173 -0.272162,0.35829 -0.272162,1.07143 0,0.68557 0.272162,1.05075 0.272163,0.36173 0.806153,0.36173 0.499538,0 0.981852,-0.22048 v 0.55121 q -0.392741,0.20326 -0.974962,0.20326 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path860" />
|
||||||
|
<path
|
||||||
|
d="m 45.543923,294.66467 -0.120578,-0.53743 h -0.02756 q -0.282498,0.35484 -0.564996,0.48231 -0.279052,0.12402 -0.706244,0.12402 -0.558106,0 -0.875055,-0.29283 -0.316948,-0.29283 -0.316948,-0.82682 0,-1.15066 1.815565,-1.20578 l 0.640788,-0.0241 v -0.22393 q 0,-0.43064 -0.186035,-0.6339 -0.186035,-0.2067 -0.596002,-0.2067 -0.299723,0 -0.568441,0.0896 -0.265272,0.0896 -0.499538,0.19981 l -0.189481,-0.46508 q 0.285943,-0.15159 0.623563,-0.23772 0.337619,-0.0861 0.668348,-0.0861 0.685574,0 1.019748,0.30316 0.334175,0.30317 0.334175,0.96463 v 2.57693 z m -1.27813,-0.43063 q 0.520209,0 0.816487,-0.27906 0.299724,-0.28249 0.299724,-0.79926 v -0.34106 l -0.558106,0.0241 q -0.651123,0.0241 -0.950846,0.20671 -0.296278,0.18259 -0.296278,0.57533 0,0.29628 0.179145,0.45475 0.18259,0.15848 0.509874,0.15848 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path862" />
|
||||||
|
<path
|
||||||
|
d="m 48.427466,294.22715 q 0.120578,0 0.282498,-0.0241 0.161919,-0.0241 0.244602,-0.0551 v 0.47542 q -0.08613,0.0379 -0.268718,0.0723 -0.179145,0.0379 -0.36518,0.0379 -1.109321,0 -1.109321,-1.16788 v -2.20142 h -0.53399 v -0.29628 l 0.54088,-0.24804 0.248047,-0.80616 h 0.368625 v 0.86817 h 1.092096 v 0.48231 h -1.092096 v 2.18419 q 0,0.32729 0.15503,0.50299 0.158474,0.1757 0.437527,0.1757 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path864" />
|
||||||
|
<path
|
||||||
|
d="m 50.332607,294.66467 h -0.620118 v -3.78271 h 0.620118 z m -0.668349,-4.80591 q 0,-0.2067 0.103353,-0.29972 0.106798,-0.093 0.261827,-0.093 0.144694,0 0.251492,0.093 0.110243,0.093 0.110243,0.29972 0,0.20327 -0.110243,0.29973 -0.106798,0.0965 -0.251492,0.0965 -0.155029,0 -0.261827,-0.0965 -0.103353,-0.0965 -0.103353,-0.29973 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path866" />
|
||||||
|
<path
|
||||||
|
d="m 54.818119,292.76643 q 0,0.92673 -0.468533,1.44694 -0.468533,0.5202 -1.295356,0.5202 -0.51332,0 -0.909506,-0.23771 -0.396186,-0.24115 -0.609782,-0.68902 -0.213595,-0.44786 -0.213595,-1.04041 0,-0.92329 0.461642,-1.43661 0.461643,-0.51676 1.295356,-0.51676 0.799262,0 1.267795,0.5271 0.471979,0.52365 0.471979,1.42627 z m -2.855985,0 q 0,0.71313 0.279053,1.0852 0.282498,0.36863 0.830268,0.36863 0.54088,0 0.823378,-0.36863 0.282498,-0.37207 0.282498,-1.0852 0,-0.71314 -0.285943,-1.07143 -0.282498,-0.36173 -0.830268,-0.36173 -1.098986,0 -1.098986,1.43316 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path868" />
|
||||||
|
<path
|
||||||
|
d="m 58.380348,294.66467 v -2.41845 q 0,-0.46165 -0.206706,-0.68558 -0.20326,-0.22737 -0.640787,-0.22737 -0.582222,0 -0.847494,0.32383 -0.261827,0.3204 -0.261827,1.04731 v 1.96026 h -0.620117 v -3.78271 h 0.499538 l 0.09302,0.51676 h 0.03445 q 0.1757,-0.27905 0.492649,-0.43063 0.316948,-0.15503 0.699354,-0.15503 0.695909,0 1.033529,0.33762 0.337619,0.33761 0.337619,1.05075 v 2.46324 z"
|
||||||
|
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:7.05555582px;font-family:sans-serif;-inkscape-font-specification:'sans-serif, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;fill:#f8f8f8;fill-opacity:1;stroke:#f8f8f8;stroke-width:0.26458335px;stroke-opacity:1"
|
||||||
|
id="path870" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -1,6 +0,0 @@
|
|||||||
---
|
|
||||||
title: "{{ replace .TranslationBaseName "-" " " | title }}"
|
|
||||||
date: {{ .Date }}
|
|
||||||
draft: true
|
|
||||||
---
|
|
||||||
|
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
.. |break_config| replace:: **[CONFIG]**
|
||||||
|
.. |break| replace:: **[BREAK]**
|
||||||
|
.. |bugfix| replace:: [BUG]
|
||||||
|
.. |docs| replace:: [DOCS]
|
||||||
|
.. |feature| replace:: [FEATURE]
|
||||||
|
.. |mig| replace:: **[MIGRATION]**
|
||||||
|
|
||||||
|
.. _changelog:
|
||||||
|
|
||||||
|
Changelog
|
||||||
|
=========
|
||||||
|
|
||||||
|
The changelog summarizes bugfixes that are deemed relevant for users and package maintainers.
|
||||||
|
Developers should consult the git commit log or GitHub issue tracker.
|
||||||
|
|
||||||
|
We use the following annotations for classifying changes:
|
||||||
|
|
||||||
|
* |break_config| Change that breaks the config.
|
||||||
|
As a package maintainer, make sure to warn your users about config breakage somehow.
|
||||||
|
* |break| Change that breaks interoperability or persistent state representation with previous releases.
|
||||||
|
As a package maintainer, make sure to warn your users about config breakage somehow.
|
||||||
|
Note that even updating the package on both sides might not be sufficient, e.g. if persistent state needs to be migrated to a new format.
|
||||||
|
* |mig| Migration that must be run by the user.
|
||||||
|
* |feature| Change that introduces new functionality.
|
||||||
|
* |bugfix| Change that fixes a bug, no regressions or incompatibilities expected.
|
||||||
|
* |docs| Change to the documentation.
|
||||||
|
|
||||||
|
0.1.1
|
||||||
|
-----
|
||||||
|
|
||||||
|
* |bugfix| :issue:`162` :commit:`d6304f4` : fix I/O timeout errors on variable receive rate
|
||||||
|
|
||||||
|
* A significant reduction or sudden stall of the receive rate (e.g. recv pool has other I/O to do)
|
||||||
|
would cause a ``writev I/O timeout`` error after approximately ten seconds.
|
||||||
|
|
||||||
|
0.1
|
||||||
|
---
|
||||||
|
|
||||||
|
This release is a milestone for zrepl and required significant refactoring if not rewrites of substantial parts of the application.
|
||||||
|
It breaks both configuration and transport format, and thus requires manual intervention and updates on both sides of a replication setup.
|
||||||
|
|
||||||
|
.. DANGER::
|
||||||
|
The changes in the pruning system for this release require you to explicitly define **keep rules**:
|
||||||
|
for any snapshot that you want to keep, at least one rule must match.
|
||||||
|
This is different from previous releases where pruning only affected snapshots with the configured snapshotting prefix.
|
||||||
|
Make sure that snapshots to be kept or ignored by zrepl are covered, e.g. by using the ``regex`` keep rule.
|
||||||
|
:ref:`Learn more in the config docs... <prune>`
|
||||||
|
|
||||||
|
|
||||||
|
Notes to Package Maintainers
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
* Notify users about config changes and migrations (see changes attributed with |break| and |mig| below)
|
||||||
|
* If the daemon crashes, the stack trace produced by the Go runtime and possibly diagnostic output of zrepl will be written to stderr.
|
||||||
|
This behavior is independent from the ``stdout`` outlet type.
|
||||||
|
Please make sure the stderr output of the daemon is captured somewhere.
|
||||||
|
To conserve precious stack traces, make sure that multiple service restarts do not directly discard previous stderr output.
|
||||||
|
* Make it obvious for users how to set the ``GOTRACEBACK`` environment variable to ``GOTRACEBACK=crash``.
|
||||||
|
This functionality will cause SIGABRT on panics and can be used to capture a coredump of the panicking process.
|
||||||
|
To that extend, make sure that your package build system, your OS's coredump collection and the Go delve debugger work together.
|
||||||
|
Use your build system to package the Go program in `this tutorial on Go coredumps and the delve debugger <https://rakyll.org/coredumps/>`_ , and make sure the symbol resolution etc. work on coredumps captured from the binary produced by your build system. (Special focus on symbol stripping, etc.)
|
||||||
|
* Consider using the ``zrepl configcheck`` subcommand in startup scripts to abort a restart that would fail due to an invalid config.
|
||||||
|
|
||||||
|
Changes
|
||||||
|
~~~~~~~
|
||||||
|
|
||||||
|
* |break| |mig| Placeholder property representation changed
|
||||||
|
|
||||||
|
* The :ref:`placeholder property <replication-placeholder-property>` now uses ``on|off`` as values
|
||||||
|
instead of hashes of the dataset path. This permits renames of the sink filesystem without
|
||||||
|
updating all placeholder properties.
|
||||||
|
* Relevant for 0.0.X-0.1-rc* to 0.1 migrations
|
||||||
|
* Make sure your config is valid with ``zrepl configcheck``
|
||||||
|
* Run ``zrepl migrate 0.0.X:0.1:placeholder``
|
||||||
|
|
||||||
|
* |feature| :issue:`55` : Push replication (see :ref:`push job <job-push>` and :ref:`sink job <job-sink>`)
|
||||||
|
* |feature| :ref:`TCP Transport <transport-tcp>`
|
||||||
|
* |feature| :ref:`TCP + TLS client authentication transport <transport-tcp+tlsclientauth>`
|
||||||
|
* |feature| :issue:`111`: RPC protocol rewrite
|
||||||
|
|
||||||
|
* |break| Protocol breakage; Update and restart of all zrepl daemons is required.
|
||||||
|
* Use `gRPC <https://grpc.io/>`_ for control RPCs and a custom protocol for bulk data transfer.
|
||||||
|
* Automatic retries for network-temporary errors
|
||||||
|
|
||||||
|
* Limited to errors during replication for this release.
|
||||||
|
Addresses the common problem of ISP-forced reconnection at night, but will become
|
||||||
|
way more useful with resumable send & recv support.
|
||||||
|
Pruning errors are handled per FS, i.e., a prune RPC is attempted at least once per FS.
|
||||||
|
|
||||||
|
* |feature| Proper timeout handling for the :ref:`SSH transport <transport-ssh+stdinserver>`
|
||||||
|
|
||||||
|
* |break| Requires Go 1.11 or later.
|
||||||
|
|
||||||
|
* |break| |break_config|: mappings are no longer supported
|
||||||
|
|
||||||
|
* Receiving sides (``pull`` and ``sink`` job) specify a single ``root_fs``.
|
||||||
|
Received filesystems are then stored *per client* in ``${root_fs}/${client_identity}``.
|
||||||
|
See :ref:`job-overview` for details.
|
||||||
|
|
||||||
|
* |feature| |break| |break_config| Manual snapshotting + triggering of replication
|
||||||
|
|
||||||
|
* |feature| :issue:`69`: include manually created snapshots in replication
|
||||||
|
* |break_config| ``manual`` and ``periodic`` :ref:`snapshotting types <job-snapshotting-spec>`
|
||||||
|
* |feature| ``zrepl signal wakeup JOB`` subcommand to trigger replication + pruning
|
||||||
|
* |feature| ``zrepl signal reset JOB`` subcommand to abort current replication + pruning
|
||||||
|
|
||||||
|
* |feature| |break| |break_config| New pruning system
|
||||||
|
|
||||||
|
* The active side of a replication (pull or push) decides what to prune for both sender and receiver.
|
||||||
|
The RPC protocol is used to execute the destroy operations on the remote side.
|
||||||
|
* New pruning policies (see :ref:`configuration documentation <prune>` )
|
||||||
|
|
||||||
|
* The decision what snapshots shall be pruned is now made based on *keep rules*
|
||||||
|
* |feature| :issue:`68`: keep rule ``not_replicated`` prevents divergence of sender and receiver
|
||||||
|
|
||||||
|
* |feature| |break| Bookmark pruning is no longer necessary
|
||||||
|
|
||||||
|
* Per filesystem, zrepl creates a single bookmark (``#zrepl_replication_cursor``) and moves it forward with the most recent successfully replicated snapshot on the receiving side.
|
||||||
|
* Old bookmarks created by prior versions of zrepl (named like their corresponding snapshot) must be deleted manually.
|
||||||
|
* |break_config| ``keep_bookmarks`` parameter of the ``grid`` keep rule has been removed
|
||||||
|
|
||||||
|
* |feature| ``zrepl status`` for live-updating replication progress (it's really cool!)
|
||||||
|
* |feature| :ref:`Snapshot- & pruning-only job type <job-snap>` (for local snapshot management)
|
||||||
|
* |feature| :issue:`67`: Expose `Prometheus <https://prometheus.io>`_ metrics via HTTP (:ref:`config docs <monitoring-prometheus>`)
|
||||||
|
|
||||||
|
* Compatible Grafana dashboard shipping in ``dist/grafana``
|
||||||
|
|
||||||
|
* |break_config| Logging outlet types must be specified using the ``type`` instead of ``outlet`` key
|
||||||
|
* |break| :issue:`53`: CLI: ``zrepl control *`` subcommands have been made direct subcommands of ``zrepl *``
|
||||||
|
* |bugfix| Goroutine leak on ssh transport connection timeouts
|
||||||
|
* |bugfix| :issue:`81` :issue:`77` : handle failed accepts correctly (``source`` job)
|
||||||
|
* |bugfix| :issue:`100`: fix incompatibility with ZoL 0.8
|
||||||
|
* |feature| :issue:`115`: logging: configurable syslog facility
|
||||||
|
* |feature| Systemd unit file in ``dist/systemd``
|
||||||
|
|
||||||
|
.. |lastrelease| replace:: 0.0.3
|
||||||
|
|
||||||
|
Previous Releases
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. NOTE::
|
||||||
|
Due to limitations in our documentation system, we only show the changelog since the last release and the time this documentation is built.
|
||||||
|
For the changelog of previous releases, use the version selection in the hosted version of these docs at `zrepl.github.io <https://zrepl.github.io>`_.
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
sphinxconf/conf.py
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
baseURL = "https://zrepl.github.io"
|
|
||||||
languageCode = "en-us"
|
|
||||||
title = "zrepl - ZFS replication"
|
|
||||||
theme = "docdock"
|
|
||||||
|
|
||||||
[[menu.shortcuts]]
|
|
||||||
pre = "<h3>More</h3>"
|
|
||||||
name = "<i class='fa fa-github'></i> GitHub repo"
|
|
||||||
identifier = "ds"
|
|
||||||
url = "https://github.com/zrepl/zrepl"
|
|
||||||
weight = 10
|
|
||||||
|
|
||||||
[[menu.shortcuts]]
|
|
||||||
name = "<i class='fa fa-twitter'></i> Primary Author on Twitter"
|
|
||||||
url = "https://twitter.com/problame"
|
|
||||||
weight = 11
|
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
.. _configuration_toc:
|
||||||
|
|
||||||
|
*************
|
||||||
|
Configuration
|
||||||
|
*************
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
|
||||||
|
configuration/preface
|
||||||
|
configuration/jobs
|
||||||
|
configuration/transports
|
||||||
|
configuration/filter_syntax
|
||||||
|
configuration/prune
|
||||||
|
configuration/logging
|
||||||
|
configuration/monitoring
|
||||||
|
configuration/misc
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
.. include:: ../global.rst.inc
|
||||||
|
|
||||||
|
.. _pattern-filter:
|
||||||
|
|
||||||
|
Filter Syntax
|
||||||
|
=============
|
||||||
|
|
||||||
|
For :ref:`source jobs <job-source>` and :ref:`push jobs <job-push>`, a filesystem filter must be defined (field ``filesystems``).
|
||||||
|
A filter takes a filesystem path (in the ZFS filesystem hierarchy) as parameter and returns ``true`` (pass) or ``false`` (block).
|
||||||
|
|
||||||
|
A filter is specified as a **YAML dictionary** with patterns as keys and booleans as values.
|
||||||
|
The following rules determine which result is chosen for a given filesystem path:
|
||||||
|
|
||||||
|
* More specific path patterns win over less specific ones
|
||||||
|
* Non-wildcard patterns (full path patterns) win over *subtree wildcards* (`<` at end of pattern)
|
||||||
|
* If the path in question does not match any pattern, the result is ``false``.
|
||||||
|
|
||||||
|
The **subtree wildcard** ``<`` means "the dataset left of ``<`` and all its children".
|
||||||
|
|
||||||
|
.. TIP::
|
||||||
|
You can try out patterns for a configured job using the ``zrepl test filesystems`` subcommand for push and source jobs.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
|
||||||
|
Full Access
|
||||||
|
~~~~~~~~~~~
|
||||||
|
|
||||||
|
The following configuration will allow access to all filesystems.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: source
|
||||||
|
filesystems: {
|
||||||
|
"<": true,
|
||||||
|
}
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
Fine-grained
|
||||||
|
~~~~~~~~~~~~
|
||||||
|
|
||||||
|
The following configuration demonstrates all rules presented above.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: source
|
||||||
|
filesystems: {
|
||||||
|
"tank<": true, # rule 1
|
||||||
|
"tank/foo<": false, # rule 2
|
||||||
|
"tank/foo/bar": true, # rule 3
|
||||||
|
}
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
Which rule applies to given path, and what is the result?
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
tank/foo/bar/loo => 2 false
|
||||||
|
tank/bar => 1 true
|
||||||
|
tank/foo/bar => 3 true
|
||||||
|
zroot => NONE false
|
||||||
|
tank/var/log => 1 true
|
||||||
|
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
.. include:: ../global.rst.inc
|
||||||
|
|
||||||
|
.. |serve-transport| replace:: :ref:`serve specification<transport>`
|
||||||
|
.. |connect-transport| replace:: :ref:`connect specification<transport>`
|
||||||
|
.. |snapshotting-spec| replace:: :ref:`snapshotting specification <job-snapshotting-spec>`
|
||||||
|
.. |pruning-spec| replace:: :ref:`pruning specification <prune>`
|
||||||
|
.. |filter-spec| replace:: :ref:`filter specification<pattern-filter>`
|
||||||
|
|
||||||
|
.. _job:
|
||||||
|
|
||||||
|
Job Types & Replication
|
||||||
|
=======================
|
||||||
|
|
||||||
|
.. _job-overview:
|
||||||
|
|
||||||
|
Overview & Terminology
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
A *job* is the unit of activity tracked by the zrepl daemon.
|
||||||
|
Every job has a unique ``name``, a ``type`` and type-dependent fields which are documented on this page.
|
||||||
|
|
||||||
|
Replication always happens between a pair of jobs: one is the **active side**, and one the **passive side**.
|
||||||
|
The active side executes the replication logic whereas the passive side responds to requests after checking the active side's permissions.
|
||||||
|
For communication, the active side connects to the passive side using a :ref:`transport <transport>` and starts issuing remote procedure calls (RPCs).
|
||||||
|
|
||||||
|
The following table shows how different job types can be combined to achieve both push and pull mode setups:
|
||||||
|
|
||||||
|
+-----------------------+--------------+----------------------------------+------------------------------------------------------------------------------------+
|
||||||
|
| Setup name | active side | passive side | use case |
|
||||||
|
+=======================+==============+==================================+====================================================================================+
|
||||||
|
| Push mode | ``push`` | ``sink`` | * Laptop backup |
|
||||||
|
| | | | * NAS behind NAT to offsite |
|
||||||
|
+-----------------------+--------------+----------------------------------+------------------------------------------------------------------------------------+
|
||||||
|
| Pull mode | ``pull`` | ``source`` | * Central backup-server for many nodes |
|
||||||
|
| | | | * Remote server to NAS behind NAT |
|
||||||
|
+-----------------------+--------------+----------------------------------+------------------------------------------------------------------------------------+
|
||||||
|
| Local replication | | ``push`` + ``sink`` in one config | * Backup FreeBSD boot pool |
|
||||||
|
| | | with :ref:`local transport <transport-local>` | |
|
||||||
|
+-----------------------+--------------+----------------------------------+------------------------------------------------------------------------------------+
|
||||||
|
| Snap & prune-only | ``snap`` | N/A | * | Snapshots & pruning but no replication |
|
||||||
|
| | | | | required |
|
||||||
|
| | | | * Workaround for :ref:`source-side pruning <prune-workaround-source-side-pruning>` |
|
||||||
|
+-----------------------+--------------+----------------------------------+------------------------------------------------------------------------------------+
|
||||||
|
|
||||||
|
How the Active Side Works
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
The active side (:ref:`push <job-push>` and :ref:`pull <job-pull>` job) executes the replication and pruning logic:
|
||||||
|
|
||||||
|
* Wakeup because of finished snapshotting (``push`` job) or pull interval ticker (``pull`` job).
|
||||||
|
* Connect to the corresponding passive side using a :ref:`transport <transport>` and instantiate an RPC client.
|
||||||
|
* Replicate data from the sending to the receiving side.
|
||||||
|
* Prune on sender & receiver.
|
||||||
|
|
||||||
|
.. TIP::
|
||||||
|
The progress of the active side can be watched live using the ``zrepl status`` subcommand.
|
||||||
|
|
||||||
|
How the Passive Side Works
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
The passive side (:ref:`sink <job-sink>` and :ref:`source <job-source>`) waits for connections from the corresponding active side,
|
||||||
|
using the transport listener type specified in the ``serve`` field of the job configuration.
|
||||||
|
Each transport listener provides a client's identity to the passive side job.
|
||||||
|
It uses the client identity for access control:
|
||||||
|
|
||||||
|
* The ``sink`` job maps requests from different client identities to their respective sub-filesystem tree ``root_fs/${client_identity}``.
|
||||||
|
* The ``source`` job has a whitelist of client identities that are allowed pull access.
|
||||||
|
|
||||||
|
.. TIP::
|
||||||
|
The implementation of the ``sink`` job requires that the connecting client identities be a valid ZFS filesystem name components.
|
||||||
|
|
||||||
|
How Replication Works
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
One of the major design goals of the replication module is to avoid any duplication of the nontrivial logic.
|
||||||
|
As such, the code works on abstract senders and receiver **endpoints**, where typically one will be implemented by a local program object and the other is an RPC client instance.
|
||||||
|
Regardless of push- or pull-style setup, the logic executes on the active side, i.e. in the ``push`` or ``pull`` job.
|
||||||
|
|
||||||
|
The following steps take place during replication and can be monitored using the ``zrepl status`` subcommand:
|
||||||
|
|
||||||
|
* Plan the replication:
|
||||||
|
|
||||||
|
* Compare sender and receiver filesystem snapshots
|
||||||
|
* Build the **replication plan**
|
||||||
|
|
||||||
|
* Per filesystem, compute a diff between sender and receiver snapshots
|
||||||
|
* Build a list of replication steps
|
||||||
|
|
||||||
|
* If possible, use incremental sends (``zfs send -i``)
|
||||||
|
* Otherwise, use full send of most recent snapshot on sender
|
||||||
|
* Give up on filesystems that cannot be replicated without data loss
|
||||||
|
|
||||||
|
* Retry on errors that are likely temporary (i.e. network failures).
|
||||||
|
* Give up on filesystems where a permanent error was received over RPC.
|
||||||
|
|
||||||
|
* Execute the plan
|
||||||
|
|
||||||
|
* Perform replication steps in the following order:
|
||||||
|
Among all filesystems with pending replication steps, pick the filesystem whose next replication step's snapshot is the oldest.
|
||||||
|
* Create placeholder filesystems on the receiving side to mirror the dataset paths on the sender to ``root_fs/${client_identity}``.
|
||||||
|
* After a successful replication step, update the replication cursor bookmark (see below).
|
||||||
|
|
||||||
|
The idea behind the execution order of replication steps is that if the sender snapshots all filesystems simultaneously at fixed intervals, the receiver will have all filesystems snapshotted at time ``T1`` before the first snapshot at ``T2 = T1 + $interval`` is replicated.
|
||||||
|
|
||||||
|
.. _replication-cursor-bookmark:
|
||||||
|
|
||||||
|
The **replication cursor bookmark** ``#zrepl_replication_cursor`` is kept per filesystem on the sending side of a replication setup:
|
||||||
|
It is a bookmark of the most recent successfully replicated snapshot to the receiving side.
|
||||||
|
It is is used by the :ref:`not_replicated <prune-keep-not-replicated>` keep rule to identify all snapshots that have not yet been replicated to the receiving side.
|
||||||
|
Regardless of whether that keep rule is used, the bookmark ensures that replication can always continue incrementally.
|
||||||
|
Note that there is only one cursor bookmark per filesystem, which prohibits multiple jobs to replicate the same filesystem (:ref:`see below<jobs-multiple-jobs>`).
|
||||||
|
|
||||||
|
.. _replication-placeholder-property:
|
||||||
|
|
||||||
|
**Placeholder filesystems** on the receiving side are regular ZFS filesystems with the placeholder property ``zrepl:placeholder=on``.
|
||||||
|
Placeholders allow the receiving side to mirror the sender's ZFS dataset hierachy without replicating every filesystem at every intermediary dataset path component.
|
||||||
|
Consider the following example: ``S/H/J`` shall be replicated to ``R/sink/job/S/H/J``, but neither ``S/H`` nor ``S`` shall be replicated.
|
||||||
|
ZFS requires the existence of ``R/sink/job/S`` and ``R/sink/job/S/H`` in order to receive into ``R/sink/job/S/H/J``.
|
||||||
|
Thus, zrepl creates the parent filesystems as placeholders on the receiving side.
|
||||||
|
If at some point ``S/H`` and ``S`` shall be replicated, the receiving side invalidates the placeholder flag automatically.
|
||||||
|
The ``zrepl test placeholder`` command can be used to check whether a filesystem is a placeholder.
|
||||||
|
|
||||||
|
.. ATTENTION::
|
||||||
|
|
||||||
|
Currently, zrepl does not replicate filesystem properties.
|
||||||
|
Whe receiving a filesystem, it is never mounted (`-u` flag) and `mountpoint=none` is set.
|
||||||
|
This is temporary and being worked on :issue:`24`.
|
||||||
|
|
||||||
|
|
||||||
|
.. _job-snapshotting-spec:
|
||||||
|
|
||||||
|
Taking Snaphots
|
||||||
|
---------------
|
||||||
|
|
||||||
|
The ``push``, ``source`` and ``snap`` jobs can automatically take periodic snapshots of the filesystems matched by the ``filesystems`` filter field.
|
||||||
|
The snapshot names are composed of a user-defined prefix followed by a UTC date formatted like ``20060102_150405_000``.
|
||||||
|
We use UTC because it will avoid name conflicts when switching time zones or between summer and winter time.
|
||||||
|
|
||||||
|
For ``push`` jobs, replication is automatically triggered after all filesystems have been snapshotted.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: push
|
||||||
|
filesystems: {
|
||||||
|
"<": true,
|
||||||
|
"tmp": false
|
||||||
|
}
|
||||||
|
snapshotting:
|
||||||
|
type: periodic
|
||||||
|
prefix: zrepl_
|
||||||
|
interval: 10m
|
||||||
|
...
|
||||||
|
|
||||||
|
There is also a ``manual`` snapshotting type, which covers the following use cases:
|
||||||
|
|
||||||
|
* Existing infrastructure for automatic snapshots: you only want to use this zrepl job for replication.
|
||||||
|
* Run scripts before and after taking snapshots (like locking database tables).
|
||||||
|
We are working on better integration for this use case: see :issue:`74`.
|
||||||
|
* Handling snapshotting through a separate ``snap`` job.
|
||||||
|
|
||||||
|
Note that you will have to trigger replication manually using the ``zrepl signal wakeup JOB`` subcommand in that case.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: push
|
||||||
|
filesystems: {
|
||||||
|
"<": true,
|
||||||
|
"tmp": false
|
||||||
|
}
|
||||||
|
snapshotting:
|
||||||
|
type: manual
|
||||||
|
...
|
||||||
|
|
||||||
|
.. _jobs-multiple-jobs:
|
||||||
|
|
||||||
|
Multiple Jobs & More than 2 Machines
|
||||||
|
------------------------------------
|
||||||
|
|
||||||
|
.. ATTENTION::
|
||||||
|
|
||||||
|
When using multiple jobs across single or multiple machines, the following rules are critical to avoid race conditions & data loss:
|
||||||
|
|
||||||
|
1. The sets of ZFS filesystems matched by the ``filesystems`` filter fields must be disjoint across all jobs configured on a machine.
|
||||||
|
2. The ZFS filesystem subtrees of jobs with ``root_fs`` must be disjoint.
|
||||||
|
3. Across all zrepl instances on all machines in the replication domain, there must be a 1:1 correspondence between active and passive jobs.
|
||||||
|
|
||||||
|
Explanations & exceptions to above rules are detailed below.
|
||||||
|
|
||||||
|
If you would like to see improvements to multi-job setups, please `open an issue on GitHub <https://github.com/zrepl/zrepl/issues/new>`_.
|
||||||
|
|
||||||
|
No Overlapping
|
||||||
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Jobs run independently of each other.
|
||||||
|
If two jobs match the same filesystem with their ``filesystems`` filter, they will operate on that filesystem independently and potentially in parallel.
|
||||||
|
For example, if job A prunes snapshots that job B is planning to replicate, the replication will fail because B asssumed the snapshot to still be present.
|
||||||
|
More subtle race conditions can occur with the :ref:`replication cursor bookmark <replication-cursor-bookmark>`, which currently only exists once per filesystem.
|
||||||
|
|
||||||
|
N push jobs to 1 sink
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
The :ref:`sink job <job-sink>` namespaces by client identity.
|
||||||
|
It is thus safe to push to one sink job with different client identities.
|
||||||
|
If the push jobs have the same client identity, the filesystems matched by the push jobs must be disjoint to avoid races.
|
||||||
|
|
||||||
|
N pull jobs from 1 source
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Multiple pull jobs pulling from the same source have potential for race conditions during pruning:
|
||||||
|
each pull job prunes the source side independently, causing replication-prune and prune-prune races.
|
||||||
|
|
||||||
|
There is currently no way for a pull job to filter which snapshots it should attempt to replicate.
|
||||||
|
Thus, it is not possibe to just manually assert that the prune rules of all pull jobs are disjoint to avoid replication-prune and prune-prune races.
|
||||||
|
|
||||||
|
|
||||||
|
------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
.. _job-push:
|
||||||
|
|
||||||
|
Job Type ``push``
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 20 80
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Parameter
|
||||||
|
- Comment
|
||||||
|
* - ``type``
|
||||||
|
- = ``push``
|
||||||
|
* - ``name``
|
||||||
|
- unique name of the job
|
||||||
|
* - ``connect``
|
||||||
|
- |connect-transport|
|
||||||
|
* - ``filesystems``
|
||||||
|
- |filter-spec| for filesystems to be snapshotted and pushed to the sink
|
||||||
|
* - ``snapshotting``
|
||||||
|
- |snapshotting-spec|
|
||||||
|
* - ``pruning``
|
||||||
|
- |pruning-spec|
|
||||||
|
|
||||||
|
Example config: :sampleconf:`/push.yml`
|
||||||
|
|
||||||
|
.. _job-sink:
|
||||||
|
|
||||||
|
Job Type ``sink``
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 20 80
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Parameter
|
||||||
|
- Comment
|
||||||
|
* - ``type``
|
||||||
|
- = ``sink``
|
||||||
|
* - ``name``
|
||||||
|
- unique name of the job
|
||||||
|
* - ``serve``
|
||||||
|
- |serve-transport|
|
||||||
|
* - ``root_fs``
|
||||||
|
- ZFS filesystems are received to
|
||||||
|
``$root_fs/$client_identity/$source_path``
|
||||||
|
|
||||||
|
Example config: :sampleconf:`/sink.yml`
|
||||||
|
|
||||||
|
.. _job-pull:
|
||||||
|
|
||||||
|
Job Type ``pull``
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 20 80
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Parameter
|
||||||
|
- Comment
|
||||||
|
* - ``type``
|
||||||
|
- = ``pull``
|
||||||
|
* - ``name``
|
||||||
|
- unique name of the job
|
||||||
|
* - ``connect``
|
||||||
|
- |connect-transport|
|
||||||
|
* - ``root_fs``
|
||||||
|
- ZFS filesystems are received to
|
||||||
|
``$root_fs/$source_path``
|
||||||
|
* - ``interval``
|
||||||
|
- | Interval at which to pull from the source job (e.g. ``10m``).
|
||||||
|
| ``manual`` disables periodic pulling, replication then only happens on :ref:`wakeup <cli-signal-wakeup>`.
|
||||||
|
* - ``pruning``
|
||||||
|
- |pruning-spec|
|
||||||
|
|
||||||
|
Example config: :sampleconf:`/pull.yml`
|
||||||
|
|
||||||
|
.. _job-source:
|
||||||
|
|
||||||
|
Job Type ``source``
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 20 80
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Parameter
|
||||||
|
- Comment
|
||||||
|
* - ``type``
|
||||||
|
- = ``source``
|
||||||
|
* - ``name``
|
||||||
|
- unique name of the job
|
||||||
|
* - ``serve``
|
||||||
|
- |serve-transport|
|
||||||
|
* - ``filesystems``
|
||||||
|
- |filter-spec| for filesystems to be snapshotted and exposed to connecting clients
|
||||||
|
* - ``snapshotting``
|
||||||
|
- |snapshotting-spec|
|
||||||
|
|
||||||
|
Example config: :sampleconf:`/source.yml`
|
||||||
|
|
||||||
|
|
||||||
|
.. _replication-local:
|
||||||
|
|
||||||
|
Local replication
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
If you have the need for local replication (most likely between two local storage pools), you can use the :ref:`local transport type <transport-local>` to connect a local push job to a local sink job.
|
||||||
|
|
||||||
|
Example config: :sampleconf:`/local.yml`.
|
||||||
|
|
||||||
|
|
||||||
|
.. _job-snap:
|
||||||
|
|
||||||
|
Job Type ``snap`` (snapshot & prune only)
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
|
Job type that only takes snapshots and performs pruning on the local machine.
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 20 80
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Parameter
|
||||||
|
- Comment
|
||||||
|
* - ``type``
|
||||||
|
- = ``snap``
|
||||||
|
* - ``name``
|
||||||
|
- unique name of the job
|
||||||
|
* - ``filesystems``
|
||||||
|
- |filter-spec| for filesystems to be snapshotted
|
||||||
|
* - ``snapshotting``
|
||||||
|
- |snapshotting-spec|
|
||||||
|
* - ``pruning``
|
||||||
|
- |pruning-spec|
|
||||||
|
|
||||||
|
Example config: :sampleconf:`/snap.yml`
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
.. include:: ../global.rst.inc
|
||||||
|
|
||||||
|
.. _logging:
|
||||||
|
|
||||||
|
Logging
|
||||||
|
=======
|
||||||
|
|
||||||
|
zrepl uses structured logging to provide users with easily processable log messages.
|
||||||
|
|
||||||
|
Logging outlets are configured in the ``global`` section of the config file.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
global:
|
||||||
|
logging:
|
||||||
|
|
||||||
|
- type: OUTLET_TYPE
|
||||||
|
level: MINIMUM_LEVEL
|
||||||
|
format: FORMAT
|
||||||
|
|
||||||
|
- type: OUTLET_TYPE
|
||||||
|
level: MINIMUM_LEVEL
|
||||||
|
format: FORMAT
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
jobs: ...
|
||||||
|
|
||||||
|
.. _logging-error-outlet:
|
||||||
|
|
||||||
|
.. ATTENTION::
|
||||||
|
The **first outlet is special**: if an error writing to any outlet occurs, the first outlet receives the error and can print it.
|
||||||
|
Thus, the first outlet must be the one that always works and does not block, e.g. ``stdout``, which is the default.
|
||||||
|
|
||||||
|
.. _logging-default-config:
|
||||||
|
|
||||||
|
Default Configuration
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
By default, the following logging configuration is used
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
global:
|
||||||
|
logging:
|
||||||
|
|
||||||
|
- type: "stdout"
|
||||||
|
level: "warn"
|
||||||
|
format: "human"
|
||||||
|
|
||||||
|
Building Blocks
|
||||||
|
---------------
|
||||||
|
|
||||||
|
The following sections document the semantics of the different log levels, formats and outlet types.
|
||||||
|
|
||||||
|
.. _logging-levels:
|
||||||
|
|
||||||
|
Levels
|
||||||
|
~~~~~~
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 10 10 80
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Level
|
||||||
|
- SHORT
|
||||||
|
- Description
|
||||||
|
* - ``error``
|
||||||
|
- ``ERRO``
|
||||||
|
- immediate action required
|
||||||
|
* - ``warn``
|
||||||
|
- ``WARN``
|
||||||
|
- symptoms for misconfiguration, soon expected failure, etc.
|
||||||
|
* - ``info``
|
||||||
|
- ``INFO``
|
||||||
|
- explains what happens without too much detail
|
||||||
|
* - ``debug``
|
||||||
|
- ``DEBG``
|
||||||
|
- tracing information, state dumps, etc. useful for debugging.
|
||||||
|
|
||||||
|
Incorrectly classified messages are considered a bug and should be reported.
|
||||||
|
|
||||||
|
.. _logging-formats:
|
||||||
|
|
||||||
|
Formats
|
||||||
|
~~~~~~~
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 10 90
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Format
|
||||||
|
- Description
|
||||||
|
* - ``human``
|
||||||
|
- prints job and subsystem into brackets before the actual message,
|
||||||
|
followed by remaining fields in logfmt style
|
||||||
|
* - ``logfmt``
|
||||||
|
- `logfmt <https://brandur.org/logfmt>`_ output. zrepl uses `this Go package <https://github.com/go-logfmt/logfmt>`_.
|
||||||
|
* - ``json``
|
||||||
|
- JSON formatted output. Each line is a valid JSON document. Fields are marshaled by
|
||||||
|
``encoding/json.Marshal()``, which is particularly useful for processing in
|
||||||
|
log aggregation or when processing state dumps.
|
||||||
|
|
||||||
|
Outlets
|
||||||
|
~~~~~~~
|
||||||
|
|
||||||
|
Outlets are the destination for log entries.
|
||||||
|
|
||||||
|
.. _logging-outlet-stdout:
|
||||||
|
|
||||||
|
``stdout`` Outlet
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 10 90
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Parameter
|
||||||
|
- Comment
|
||||||
|
* - ``type``
|
||||||
|
- ``stdout``
|
||||||
|
* - ``level``
|
||||||
|
- minimum :ref:`log level <logging-levels>`
|
||||||
|
* - ``format``
|
||||||
|
- output :ref:`format <logging-formats>`
|
||||||
|
* - ``time``
|
||||||
|
- always include time in output (``true`` or ``false``)
|
||||||
|
* - ``color``
|
||||||
|
- colorize output according to log level (``true`` or ``false``)
|
||||||
|
|
||||||
|
Writes all log entries with minimum level ``level`` formatted by ``format`` to stdout.
|
||||||
|
If stdout is a tty, interactive usage is assumed and both ``time`` and ``color`` are set to ``true``.
|
||||||
|
|
||||||
|
Can only be specified once.
|
||||||
|
|
||||||
|
``syslog`` Outlet
|
||||||
|
-----------------
|
||||||
|
.. list-table::
|
||||||
|
:widths: 10 90
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Parameter
|
||||||
|
- Comment
|
||||||
|
* - ``type``
|
||||||
|
- ``syslog``
|
||||||
|
* - ``level``
|
||||||
|
- minimum :ref:`log level <logging-levels>`
|
||||||
|
* - ``format``
|
||||||
|
- output :ref:`format <logging-formats>`
|
||||||
|
* - ``facility``
|
||||||
|
- Which syslog facility to use (default = ``local0``)
|
||||||
|
* - ``retry_interval``
|
||||||
|
- Interval between reconnection attempts to syslog (default = 0)
|
||||||
|
|
||||||
|
Writes all log entries formatted by ``format`` to syslog.
|
||||||
|
On normal setups, you should not need to change the ``retry_interval``.
|
||||||
|
|
||||||
|
Can only be specified once.
|
||||||
|
|
||||||
|
``tcp`` Outlet
|
||||||
|
--------------
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 10 90
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Parameter
|
||||||
|
- Comment
|
||||||
|
* - ``type``
|
||||||
|
- ``tcp``
|
||||||
|
* - ``level``
|
||||||
|
- minimum :ref:`log level <logging-levels>`
|
||||||
|
* - ``format``
|
||||||
|
- output :ref:`format <logging-formats>`
|
||||||
|
* - ``net``
|
||||||
|
- ``tcp`` in most cases
|
||||||
|
* - ``address``
|
||||||
|
- remote network, e.g. ``logs.example.com:10202``
|
||||||
|
* - ``retry_interval``
|
||||||
|
- Interval between reconnection attempts to ``address``
|
||||||
|
* - ``tls``
|
||||||
|
- TLS config (see below)
|
||||||
|
|
||||||
|
Establishes a TCP connection to ``address`` and sends log messages with minimum level ``level`` formatted by ``format``.
|
||||||
|
If ``tls`` is not specified, an unencrypted connection is established.
|
||||||
|
If ``tls`` is specified, the TCP connection is secured with TLS + Client Authentication.
|
||||||
|
The latter is particularly useful in combination with log aggregation services.
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 10 90
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Parameter
|
||||||
|
- Description
|
||||||
|
* - ``ca``
|
||||||
|
- PEM-encoded certificate authority that signed the remote server's TLS certificate
|
||||||
|
* - ``cert``
|
||||||
|
- PEM-encoded client certificate identifying this zrepl daemon toward the remote server
|
||||||
|
* - ``key``
|
||||||
|
- PEM-encoded, unencrypted client private key identifying this zrepl daemon toward the remote server
|
||||||
|
|
||||||
|
.. WARNING::
|
||||||
|
|
||||||
|
zrepl drops log messages to the TCP outlet if the underlying connection is not fast enough.
|
||||||
|
Note that TCP buffering in the kernel must first run full becfore messages are dropped.
|
||||||
|
|
||||||
|
Make sure to always configure a ``stdout`` outlet as the special error outlet to be informed about problems
|
||||||
|
with the TCP outlet (see :ref:`above <logging-error-outlet>` ).
|
||||||
|
|
||||||
|
|
||||||
|
.. NOTE::
|
||||||
|
|
||||||
|
zrepl uses Go's ``crypto/tls`` and ``crypto/x509`` packages and leaves all but the required fields in ``tls.Config`` at their default values.
|
||||||
|
In case of a security defect in these packages, zrepl has to be rebuilt because Go binaries are statically linked.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
Miscellaneous
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. _conf-runtime-directories:
|
||||||
|
|
||||||
|
Runtime Directories & UNIX Sockets
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
zrepl daemon creates various UNIX sockets to allow communicating with it:
|
||||||
|
|
||||||
|
* the :ref:`transport-ssh+stdinserver` transport connects to a socket named after ``client_identity`` parameter
|
||||||
|
* the ``control`` CLI subcommand connects to a defined control socket
|
||||||
|
|
||||||
|
There is no further authentication on these sockets.
|
||||||
|
Therefore we have to make sure they can only be created and accessed by ``zrepl daemon``.
|
||||||
|
In fact, ``zrepl daemon`` will not bind a socket to a path in a directory that is world-accessible.
|
||||||
|
|
||||||
|
The directories can be configured in the main configuration file, the defaults are provided below:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
global:
|
||||||
|
control:
|
||||||
|
sockpath: /var/run/zrepl/control
|
||||||
|
serve:
|
||||||
|
stdinserver:
|
||||||
|
sockdir: /var/run/zrepl/stdinserver
|
||||||
|
|
||||||
|
|
||||||
|
Durations & Intervals
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
Interval & duration fields in job definitions, pruning configurations, etc. must match the following regex:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
var durationStringRegex *regexp.Regexp = regexp.MustCompile(`^\s*(\d+)\s*(s|m|h|d|w)\s*$`)
|
||||||
|
// s = second, m = minute, h = hour, d = day, w = week (7 days)
|
||||||
|
|
||||||
|
Super-Verbose Job Debugging
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
You have probably landed here because you opened an issue on GitHub and some developer told you to do this...
|
||||||
|
So just read the annotated comments ;)
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
job:
|
||||||
|
- name: ...
|
||||||
|
...
|
||||||
|
# JOB DEBUGGING OPTIONS
|
||||||
|
# should be equal for all job types, but each job implements the debugging itself
|
||||||
|
debug:
|
||||||
|
conn: # debug the io.ReadWriteCloser connection
|
||||||
|
read_dump: /tmp/connlog_read # dump results of Read() invocations to this file
|
||||||
|
write_dump: /tmp/connlog_write # dump results of Write() invocations to this file
|
||||||
|
rpc: # debug the RPC protocol implementation
|
||||||
|
log: true # log output from rpc layer to the job log
|
||||||
|
|
||||||
|
.. ATTENTION::
|
||||||
|
|
||||||
|
Connection dumps will almost certainly contain your or other's private data. Do not share it in a bug report.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
.. include:: ../global.rst.inc
|
||||||
|
|
||||||
|
.. _monitoring:
|
||||||
|
|
||||||
|
Monitoring
|
||||||
|
==========
|
||||||
|
|
||||||
|
Monitoring endpoints are configured in the ``global.monitoring`` section of the config file.
|
||||||
|
|
||||||
|
.. _monitoring-prometheus:
|
||||||
|
|
||||||
|
Prometheus & Grafana
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
zrepl can expose `Prometheus metrics <https://prometheus.io/docs/instrumenting/exposition_formats/>`_ via HTTP.
|
||||||
|
The ``listen`` attribute is a `net.Listen <https://golang.org/pkg/net/#Listen>`_ string for tcp, e.g. ``:9091`` or ``127.0.0.1:9091``.
|
||||||
|
The Prometheues monitoring job appears in the ``zrepl control`` job list and may be specified **at most once**.
|
||||||
|
|
||||||
|
zrepl also ships with an importable `Grafana <https://grafana.com>`_ dashboard that consumes the Prometheus metrics:
|
||||||
|
see :repomasterlink:`dist/grafana`.
|
||||||
|
The dashboard also contains some advice on which metrics are important to monitor.
|
||||||
|
|
||||||
|
.. NOTE::
|
||||||
|
|
||||||
|
At the time of writing, there is no stability guarantee on the exported metrics.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
global:
|
||||||
|
monitoring:
|
||||||
|
- type: prometheus
|
||||||
|
listen: ':9091'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
.. _configuration_preface:
|
||||||
|
|
||||||
|
=======
|
||||||
|
Preface
|
||||||
|
=======
|
||||||
|
|
||||||
|
-----------------------
|
||||||
|
Configuration File Path
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
zrepl searches for its main configuration file in the following locations (in that order):
|
||||||
|
|
||||||
|
* If set, the location specified via the global ``--config`` flag
|
||||||
|
* ``/etc/zrepl/zrepl.yml``
|
||||||
|
* ``/usr/local/etc/zrepl/zrepl.yml``
|
||||||
|
|
||||||
|
The examples in the :ref:`tutorial` or the :sampleconf:`/` directory should provide a good starting point.
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
Runtime Directories
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
zrepl requires runtime directories for various UNIX sockets --- they are documented in the :ref:`config file<conf-runtime-directories>`.
|
||||||
|
Your package maintainer / init script should take care of creating them.
|
||||||
|
Alternatively, for default settings, the following should to the trick.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
mkdir -p /var/run/zrepl/stdinserver
|
||||||
|
chmod -R 0700 /var/run/zrepl
|
||||||
|
|
||||||
|
|
||||||
|
----------
|
||||||
|
Validating
|
||||||
|
----------
|
||||||
|
|
||||||
|
The config can be validated using the ``zrepl configcheck`` subcommand.
|
||||||
|
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
.. _prune:
|
||||||
|
|
||||||
|
Pruning Policies
|
||||||
|
================
|
||||||
|
|
||||||
|
In zrepl, *pruning* means *destroying snapshots*.
|
||||||
|
Pruning must happen on both sides of a replication or the systems would inevitable run out of disk space at some point.
|
||||||
|
|
||||||
|
Typically, the requirements to temporal resolution and maximum retention time differ per side.
|
||||||
|
For example, when using zrepl to back up a busy database server, you will want high temporal resolution (snapshots every 10 min) for the last 24h in case of administrative disasters, but cannot afford to store them for much longer because you might have high turnover volume in the database.
|
||||||
|
On the receiving side, you may have more disk space available, or need to comply with other backup retention policies.
|
||||||
|
|
||||||
|
zrepl uses a set of **keep rules** per sending and receiving side to determine which snapshots shall be kept per filesystem.
|
||||||
|
**A snapshot that is not kept by any rule is destroyed.**
|
||||||
|
The keep rules are **evaluated on the active side** (:ref:`push <job-push>` or :ref:`pull job <job-pull>`) of the replication setup, for both active and passive side, after replication completed or was determined to have failed permanently.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Example Configuration:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: push
|
||||||
|
name: ...
|
||||||
|
connect: ...
|
||||||
|
filesystems: {
|
||||||
|
"<": true,
|
||||||
|
"tmp": false
|
||||||
|
}
|
||||||
|
snapshotting:
|
||||||
|
type: periodic
|
||||||
|
prefix: zrepl_
|
||||||
|
interval: 10m
|
||||||
|
pruning:
|
||||||
|
keep_sender:
|
||||||
|
- type: not_replicated
|
||||||
|
# make sure manually created snapshots by the administrator are kept
|
||||||
|
- type: regex
|
||||||
|
regex: "^manual_.*"
|
||||||
|
- type: grid
|
||||||
|
grid: 1x1h(keep=all) | 24x1h | 14x1d
|
||||||
|
regex: "^zrepl_.*"
|
||||||
|
keep_receiver:
|
||||||
|
- type: grid
|
||||||
|
grid: 1x1h(keep=all) | 24x1h | 35x1d | 6x30d
|
||||||
|
regex: "^zrepl_.*"
|
||||||
|
# manually created snapshots will be kept forever on receiver
|
||||||
|
|
||||||
|
.. DANGER::
|
||||||
|
You might have **existing snapshots** of filesystems affected by pruning which you want to keep, i.e. not be destroyed by zrepl.
|
||||||
|
Make sure to actually add the necessary ``regex`` keep rules on both sides, like with ``manual`` in the example above.
|
||||||
|
|
||||||
|
.. _prune-keep-not-replicated:
|
||||||
|
|
||||||
|
Policy ``not_replicated``
|
||||||
|
-------------------------
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: push
|
||||||
|
pruning:
|
||||||
|
keep_sender:
|
||||||
|
- type: not_replicated
|
||||||
|
...
|
||||||
|
|
||||||
|
``not_replicated`` keeps all snapshots that have not been replicated to the receiving side.
|
||||||
|
It only makes sense to specify this rule on a sender (source or push job).
|
||||||
|
The state required to evaluate this rule is stored in the :ref:`replication cursor bookmark <replication-cursor-bookmark>` on the sending side.
|
||||||
|
|
||||||
|
.. _prune-keep-retention-grid:
|
||||||
|
|
||||||
|
Policy ``grid``
|
||||||
|
---------------
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: pull
|
||||||
|
pruning:
|
||||||
|
keep_receiver:
|
||||||
|
- type: grid
|
||||||
|
regex: "^zrepl_.*"
|
||||||
|
grid: 1x1h(keep=all) | 24x1h | 35x1d | 6x30d
|
||||||
|
│ │
|
||||||
|
└─ one hour interval
|
||||||
|
│
|
||||||
|
└─ 24 adjacent one-hour intervals
|
||||||
|
...
|
||||||
|
|
||||||
|
The retention grid can be thought of as a time-based sieve:
|
||||||
|
The ``grid`` field specifies a list of adjacent time intervals:
|
||||||
|
the left edge of the leftmost (first) interval is the ``creation`` date of the youngest snapshot.
|
||||||
|
All intervals to its right describe time intervals further in the past.
|
||||||
|
|
||||||
|
Each interval carries a maximum number of snapshots to keep.
|
||||||
|
It is specified via ``(keep=N)``, where ``N`` is either ``all`` (all snapshots are kept) or a positive integer.
|
||||||
|
The default value is **keep=1**.
|
||||||
|
|
||||||
|
The following procedure happens during pruning:
|
||||||
|
|
||||||
|
#. The list of snapshots is filtered by the regular expression in ``regex``.
|
||||||
|
Only snapshots names that match the regex are considered for this rule, all others are not affected.
|
||||||
|
#. The filtered list of snapshots is sorted by ``creation``
|
||||||
|
#. The left edge of the first interval is aligned to the ``creation`` date of the youngest snapshot
|
||||||
|
#. A list of buckets is created, one for each interval
|
||||||
|
#. The list of snapshots is split up into the buckets.
|
||||||
|
#. For each bucket
|
||||||
|
|
||||||
|
#. the contained snapshot list is sorted by creation.
|
||||||
|
#. snapshots from the list, oldest first, are destroyed until the specified ``keep`` count is reached.
|
||||||
|
#. all remaining snapshots on the list are kept.
|
||||||
|
|
||||||
|
|
||||||
|
.. _prune-keep-last-n:
|
||||||
|
|
||||||
|
Policy ``last_n``
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: push
|
||||||
|
pruning:
|
||||||
|
keep_receiver:
|
||||||
|
- type: last_n
|
||||||
|
count: 10
|
||||||
|
...
|
||||||
|
|
||||||
|
``last_n`` keeps the last ``count`` snapshots (last = youngest = most recent creation date).
|
||||||
|
|
||||||
|
.. _prune-keep-regex:
|
||||||
|
|
||||||
|
Policy ``regex``
|
||||||
|
----------------
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: push
|
||||||
|
pruning:
|
||||||
|
keep_receiver:
|
||||||
|
# keep all snapshots with prefix zrepl_ or manual_
|
||||||
|
- type: regex
|
||||||
|
regex: "^(zrepl|manual)_.*"
|
||||||
|
|
||||||
|
- type: push
|
||||||
|
snapshotting:
|
||||||
|
prefix: zrepl_
|
||||||
|
pruning:
|
||||||
|
keep_sender:
|
||||||
|
# keep all snapshots that were not created by zrepl
|
||||||
|
- type: regex
|
||||||
|
negate: true
|
||||||
|
regex: "^zrepl_.*"
|
||||||
|
|
||||||
|
``regex`` keeps all snapshots whose names are matched by the regular expressionin ``regex``.
|
||||||
|
Like all other regular expression fields in prune policies, zrepl uses Go's `regexp.Regexp <https://golang.org/pkg/regexp/#Compile>`_ Perl-compatible regular expressions (`Syntax <https://golang.org/pkg/regexp/syntax>`_).
|
||||||
|
The optional `negate` boolean field inverts the semantics: Use it if you want to keep all snapshots that *do not* match the given regex.
|
||||||
|
|
||||||
|
.. _prune-workaround-source-side-pruning:
|
||||||
|
|
||||||
|
Source-side snapshot pruning
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
A :ref:`source jobs<job-source>` takes snapshots on the system it runs on.
|
||||||
|
The corresponding :ref:`pull job <job-pull>` on the replication target connects to the source job and replicates the snapshots.
|
||||||
|
Afterwards, the pull job coordinates pruning on both sender (the source job side) and receiver (the pull job side).
|
||||||
|
|
||||||
|
There is no built-in way to define and execute pruning on the source side independently of the pull side.
|
||||||
|
The source job will continue taking snapshots which will not be pruned until the pull side connects.
|
||||||
|
This means that **extended replication downtime will fill up the source's zpool with snapshots**.
|
||||||
|
|
||||||
|
If the above is a conceivable situation for you, consider using :ref:`push mode <job-push>`, where pruning happens on the same side where snapshots are taken.
|
||||||
|
|
||||||
|
Workaround using ``snap`` job
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
As a workaround (see GitHub :issue:`102` for development progress), a pruning-only :ref:`snap job <job-snap>` can be defined on the source side:
|
||||||
|
The snap job is in charge of snapshot creation & destruction, whereas the source job's role is reduced to just serving snapshots.
|
||||||
|
However, since, jobs are run independently, it is possible that the snap job will prune snapshots that are queued for replication / destruction by the remote pull job that connects to the source job.
|
||||||
|
Symptoms of such race conditions are spurious replication and destroy errors.
|
||||||
|
|
||||||
|
Example configuration:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
# source side
|
||||||
|
jobs:
|
||||||
|
- type: snap
|
||||||
|
snapshotting:
|
||||||
|
type: periodic
|
||||||
|
pruning:
|
||||||
|
keep:
|
||||||
|
# source side pruning rules go here
|
||||||
|
...
|
||||||
|
|
||||||
|
- type: source
|
||||||
|
snapshotting:
|
||||||
|
type: manual
|
||||||
|
root_fs: ...
|
||||||
|
|
||||||
|
# pull side
|
||||||
|
jobs:
|
||||||
|
- type: pull
|
||||||
|
pruning:
|
||||||
|
keep_sender:
|
||||||
|
# let the source-side snap job do the pruning
|
||||||
|
- type: regex
|
||||||
|
regex: ".*"
|
||||||
|
...
|
||||||
|
keep_receiver:
|
||||||
|
# feel free to prune on the pull side as desired
|
||||||
|
...
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
.. highlight:: bash
|
||||||
|
|
||||||
|
.. _transport:
|
||||||
|
|
||||||
|
Transports
|
||||||
|
==========
|
||||||
|
|
||||||
|
The zrepl RPC layer uses **transports** to establish a single, bidirectional data stream between an active and passive job.
|
||||||
|
On the passive (serving) side, the transport also provides the **client identity** to the upper layers:
|
||||||
|
this string is used for access control and separation of filesystem sub-trees in :ref:`sink jobs <job-sink>`.
|
||||||
|
Transports are specified in the ``connect`` or ``serve`` section of a job definition.
|
||||||
|
|
||||||
|
.. contents::
|
||||||
|
|
||||||
|
.. ATTENTION::
|
||||||
|
|
||||||
|
The **client identities must be valid ZFS dataset path components**
|
||||||
|
because the :ref:`sink job <job-sink>` uses ``${root_fs}/${client_identity}`` to determine the client's subtree.
|
||||||
|
|
||||||
|
.. _transport-tcp:
|
||||||
|
|
||||||
|
``tcp`` Transport
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
The ``tcp`` transport uses plain TCP, which means that the data is **not encrypted** on the wire.
|
||||||
|
Clients are identified by their IPv4 or IPv6 addresses, and the client identity is established through a mapping on the server.
|
||||||
|
|
||||||
|
This transport may also be used in conjunction with network-layer encryption and/or VPN tunnels to provide encryption on the wire.
|
||||||
|
To make the IP-based client authentication effective, such solutions should provide authenticated IP addresses.
|
||||||
|
Some options to consider:
|
||||||
|
|
||||||
|
.. _transport-tcp-tunneling:
|
||||||
|
|
||||||
|
* `WireGuard <https://www.wireguard.com/>`_: Linux-focussed, in-kernel TLS
|
||||||
|
* `OpenVPN <https://openvpn.net/>`_: Cross-platform VPN, uses tun on \*nix
|
||||||
|
* `IPSec <https://en.wikipedia.org/wiki/IPsec>`_: Properly standardized, in-kernel network-layer VPN
|
||||||
|
* `spiped <http://www.tarsnap.com/spiped.html>`_: think of it as an encrypted pipe between two servers
|
||||||
|
* SSH
|
||||||
|
|
||||||
|
* `sshuttle <https://sshuttle.readthedocs.io/en/stable/overview.html>`_: VPN-like solution, but using SSH
|
||||||
|
* `SSH port forwarding <https://help.ubuntu.com/community/SSH/OpenSSH/PortForwarding>`_: Systemd user unit & make it start before the zrepl service.
|
||||||
|
|
||||||
|
Serve
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: sink
|
||||||
|
serve:
|
||||||
|
type: tcp
|
||||||
|
listen: ":8888"
|
||||||
|
clients: {
|
||||||
|
"192.168.122.123" : "mysql01"
|
||||||
|
"192.168.122.123" : "mx01"
|
||||||
|
}
|
||||||
|
...
|
||||||
|
|
||||||
|
Connect
|
||||||
|
~~~~~~~
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: push
|
||||||
|
connect:
|
||||||
|
type: tcp
|
||||||
|
address: "10.23.42.23:8888"
|
||||||
|
dial_timeout: # optional, default 10s
|
||||||
|
...
|
||||||
|
|
||||||
|
.. _transport-tcp+tlsclientauth:
|
||||||
|
|
||||||
|
``tls`` Transport
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
The ``tls`` transport uses TCP + TLS with client authentication using client certificates.
|
||||||
|
The client identity is the common name (CN) presented in the client certificate.
|
||||||
|
|
||||||
|
It is recommended to set up a dedicated CA infrastructure for this transport, e.g. using OpenVPN's `EasyRSA <https://github.com/OpenVPN/easy-rsa>`_.
|
||||||
|
For a simple 2-machine setup, see the :ref:`instructions below<transport-tcp+tlsclientauth-2machineopenssl>`.
|
||||||
|
|
||||||
|
The implementation uses `Go's TLS library <https://golang.org/pkg/crypto/tls/>`_.
|
||||||
|
Since Go binaries are statically linked, you or your distribution need to recompile zrepl when vulnerabilities in that library are disclosed.
|
||||||
|
|
||||||
|
All file paths are resolved relative to the zrepl daemon's working directory.
|
||||||
|
Specify absolute paths if you are unsure what directory that is (or find out from your init system).
|
||||||
|
|
||||||
|
If intermediate CAs are used, the **full chain** must be present in either in the ``ca`` file or the individual ``cert`` files.
|
||||||
|
Regardless, the client's certificate must be first in the ``cert`` file, with each following certificate directly certifying the one preceding it (see `TLS's specification <https://tools.ietf.org/html/rfc5246#section-7.4.2>`_).
|
||||||
|
This is the common default when using a CA management tool.
|
||||||
|
|
||||||
|
Serve
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: sink
|
||||||
|
root_fs: "pool2/backup_laptops"
|
||||||
|
serve:
|
||||||
|
type: tls
|
||||||
|
listen: ":8888"
|
||||||
|
ca: /etc/zrepl/ca.crt
|
||||||
|
cert: /etc/zrepl/prod.fullchain
|
||||||
|
key: /etc/zrepl/prod.key
|
||||||
|
client_cns:
|
||||||
|
- "laptop1"
|
||||||
|
- "homeserver"
|
||||||
|
|
||||||
|
The ``ca`` field specified the certificate authority used to validate client certificates.
|
||||||
|
The ``client_cns`` list specifies a list of accepted client common names (which are also the client identities for this transport).
|
||||||
|
|
||||||
|
Connect
|
||||||
|
~~~~~~~
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: pull
|
||||||
|
connect:
|
||||||
|
type: tls
|
||||||
|
address: "server1.foo.bar:8888"
|
||||||
|
ca: /etc/zrepl/ca.crt
|
||||||
|
cert: /etc/zrepl/backupserver.fullchain
|
||||||
|
key: /etc/zrepl/backupserver.key
|
||||||
|
server_cn: "server1"
|
||||||
|
dial_timeout: # optional, default 10s
|
||||||
|
|
||||||
|
The ``ca`` field specifies the CA which signed the server's certificate (``serve.cert``).
|
||||||
|
The ``server_cn`` specifies the expected common name (CN) of the server's certificate.
|
||||||
|
It overrides the hostname specified in ``address``.
|
||||||
|
The connection fails if either do not match.
|
||||||
|
|
||||||
|
.. _transport-tcp+tlsclientauth-2machineopenssl:
|
||||||
|
|
||||||
|
Self-Signed Certificates
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Tools like `EasyRSA <https://github.com/OpenVPN/easy-rsa>`_ make it easy to manage CA infrastructure for multiple clients, e.g. a central zrepl backup server (in sink mode).
|
||||||
|
However, for a two-machine setup, self-signed certificates distributed using an out-of-band mechanism will also work just fine:
|
||||||
|
|
||||||
|
Suppose you have a push-mode setup, with `backups.example.com` running the :ref:`sink job <job-sink>`, and `prod.example.com` running the :ref:`push job <job-push>`.
|
||||||
|
Run the following OpenSSL commands on each host, substituting HOSTNAME in both filenames and the interactive input prompt by OpenSSL:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
:emphasize-lines: 1-5,24
|
||||||
|
|
||||||
|
openssl req -x509 -sha256 -nodes \
|
||||||
|
-newkey rsa:4096 \
|
||||||
|
-days 365 \
|
||||||
|
-keyout HOSTNAME.key \
|
||||||
|
-out HOSTNAME.crt
|
||||||
|
|
||||||
|
#Generating a 4096 bit RSA private key
|
||||||
|
#................++++
|
||||||
|
#.++++
|
||||||
|
#writing new private key to 'backups.key'
|
||||||
|
#-----
|
||||||
|
#You are about to be asked to enter information that will be incorporated
|
||||||
|
#into your certificate request.
|
||||||
|
#What you are about to enter is what is called a Distinguished Name or a DN.
|
||||||
|
#There are quite a few fields but you can leave some blank
|
||||||
|
#For some fields there will be a default value,
|
||||||
|
#If you enter '.', the field will be left blank.
|
||||||
|
#-----
|
||||||
|
#Country Name (2 letter code) [XX]:
|
||||||
|
#State or Province Name (full name) []:
|
||||||
|
#Locality Name (eg, city) [Default City]:
|
||||||
|
#Organization Name (eg, company) [Default Company Ltd]:
|
||||||
|
#Organizational Unit Name (eg, section) []:
|
||||||
|
#Common Name (eg, your name or your server's hostname) []:HOSTNAME
|
||||||
|
#Email Address []:
|
||||||
|
|
||||||
|
Now copy each machine's ``HOSTNAME.crt`` to the other machine's ``/etc/zrepl/HOSTNAME.crt``, for example using `scp`.
|
||||||
|
The serve & connect configuration will thus look like the following:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
# on backups.example.com
|
||||||
|
- type: sink
|
||||||
|
serve:
|
||||||
|
type: tls
|
||||||
|
listen: ":8888"
|
||||||
|
ca: "/etc/zrepl/prod.example.com.crt"
|
||||||
|
cert: "/etc/zrepl/backups.example.com.crt"
|
||||||
|
key: "/etc/zrepl/backups.example.com.key"
|
||||||
|
client_cns:
|
||||||
|
- "prod.example.com"
|
||||||
|
...
|
||||||
|
|
||||||
|
# on prod.example.com
|
||||||
|
- type: push
|
||||||
|
connect:
|
||||||
|
type: tls
|
||||||
|
address:"backups.example.com:8888"
|
||||||
|
ca: /etc/zrepl/backups.example.com.crt
|
||||||
|
cert: /etc/zrepl/prod.example.com.crt
|
||||||
|
key: /etc/zrepl/prod.example.com.key
|
||||||
|
server_cn: "backups.example.com"
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
.. _transport-ssh+stdinserver:
|
||||||
|
|
||||||
|
``ssh+stdinserver`` Transport
|
||||||
|
-----------------------------
|
||||||
|
|
||||||
|
``ssh+stdinserver`` uses the ``ssh`` command and some features of the server-side SSH ``authorized_keys`` file.
|
||||||
|
It is less efficient than other transports because the data passes through two more pipes.
|
||||||
|
However, it is fairly convenient to set up and allows the zrepl daemon to not be directly exposed to the internet, because all traffic passes through the system's SSH server.
|
||||||
|
|
||||||
|
The concept is inspired by `git shell <https://git-scm.com/docs/git-shell>`_ and `Borg Backup <https://borgbackup.readthedocs.io/en/stable/deployment.html>`_.
|
||||||
|
The implementation is provided by the Go package ``github.com/problame/go-netssh``.
|
||||||
|
|
||||||
|
.. NOTE::
|
||||||
|
|
||||||
|
``ssh+stdinserver`` generally provides inferior error detection and handling compared to the ``tcp`` and ``tls`` transports.
|
||||||
|
When encountering such problems, consider using ``tcp`` or ``tls`` transports, or help improve package go-netssh.
|
||||||
|
|
||||||
|
.. _transport-ssh+stdinserver-serve:
|
||||||
|
|
||||||
|
Serve
|
||||||
|
~~~~~
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: source
|
||||||
|
serve:
|
||||||
|
type: stdinserver
|
||||||
|
client_identities:
|
||||||
|
- "client1"
|
||||||
|
- "client2"
|
||||||
|
...
|
||||||
|
|
||||||
|
First of all, note that ``type=stdinserver`` in this case:
|
||||||
|
Currently, only ``connect.type=ssh+stdinserver`` can connect to a ``serve.type=stdinserver``, but we want to keep that option open for future extensions.
|
||||||
|
|
||||||
|
The serving job opens a UNIX socket named after ``client_identity`` in the runtime directory.
|
||||||
|
In our example above, that is ``/var/run/zrepl/stdinserver/client1`` and ``/var/run/zrepl/stdinserver/client2``.
|
||||||
|
|
||||||
|
On the same machine, the ``zrepl stdinserver $client_identity`` command connects to ``/var/run/zrepl/stdinserver/$client_identity``.
|
||||||
|
It then passes its stdin and stdout file descriptors to the zrepl daemon via *cmsg(3)*.
|
||||||
|
zrepl daemon in turn combines them into an object implementing ``net.Conn``:
|
||||||
|
a ``Write()`` turns into a write to stdout, a ``Read()`` turns into a read from stdin.
|
||||||
|
|
||||||
|
Interactive use of the ``stdinserver`` subcommand does not make much sense.
|
||||||
|
However, we can force its execution when a user with a particular SSH pubkey connects via SSH.
|
||||||
|
This can be achieved with an entry in the ``authorized_keys`` file of the serving zrepl daemon.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
# for OpenSSH >= 7.2
|
||||||
|
command="zrepl stdinserver CLIENT_IDENTITY",restrict CLIENT_SSH_KEY
|
||||||
|
# for older OpenSSH versions
|
||||||
|
command="zrepl stdinserver CLIENT_IDENTITY",no-port-forwarding,no-X11-forwarding,no-pty,no-agent-forwarding,no-user-rc CLIENT_SSH_KEY
|
||||||
|
|
||||||
|
* CLIENT_IDENTITY is substituted with an entry from ``client_identities`` in our example
|
||||||
|
* CLIENT_SSH_KEY is substituted with the public part of the SSH keypair specified in the ``connect.identity_file`` directive on the connecting host.
|
||||||
|
|
||||||
|
.. NOTE::
|
||||||
|
|
||||||
|
You may need to adjust the ``PermitRootLogin`` option in ``/etc/ssh/sshd_config`` to ``forced-commands-only`` or higher for this to work.
|
||||||
|
Refer to sshd_config(5) for details.
|
||||||
|
|
||||||
|
To recap, this is of how client authentication works with the ``ssh+stdinserver`` transport:
|
||||||
|
|
||||||
|
* Connections to the ``/var/run/zrepl/stdinserver/${client_identity}`` UNIX socket are blindly trusted by zrepl daemon.
|
||||||
|
The connection client identity is the name of the socket, i.e. ``${client_identity}``.
|
||||||
|
* Thus, the runtime directory must be private to the zrepl user (this is checked by zrepl daemon)
|
||||||
|
* The admin of the host with the serving zrepl daemon controls the ``authorized_keys`` file.
|
||||||
|
* Thus, the administrator controls the mapping ``PUBKEY -> CLIENT_IDENTITY``.
|
||||||
|
|
||||||
|
.. _transport-ssh+stdinserver-connect:
|
||||||
|
|
||||||
|
Connect
|
||||||
|
~~~~~~~
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: pull
|
||||||
|
connect:
|
||||||
|
type: ssh+stdinserver
|
||||||
|
host: prod.example.com
|
||||||
|
user: root
|
||||||
|
port: 22
|
||||||
|
identity_file: /etc/zrepl/ssh/identity
|
||||||
|
options: # optional, default [], `-o` arguments passed to ssh
|
||||||
|
- "Compression=on"
|
||||||
|
dial_timeout: # optional, default 10s, max time.Duration until initial handshake is completed
|
||||||
|
|
||||||
|
The connecting zrepl daemon
|
||||||
|
|
||||||
|
#. Creates a pipe
|
||||||
|
#. Forks
|
||||||
|
#. In the forked process
|
||||||
|
|
||||||
|
#. Replaces forked stdin and stdout with the corresponding pipe ends
|
||||||
|
#. Executes the ``ssh`` binary found in ``$PATH``.
|
||||||
|
|
||||||
|
#. The identity file (``-i``) is set to ``$identity_file``.
|
||||||
|
#. The remote user, host and port correspond to those configured.
|
||||||
|
#. Further options can be specified using the ``options`` field, which appends each entry in the list to the command line using ``-o $entry``.
|
||||||
|
|
||||||
|
#. Wraps the pipe ends in a ``net.Conn`` and returns it to the RPC layer.
|
||||||
|
|
||||||
|
As discussed in the section above, the connecting zrepl daemon expects that ``zrepl stdinserver $client_identity`` is executed automatically via an ``authorized_keys`` file entry.
|
||||||
|
|
||||||
|
The ``known_hosts`` file used by the ssh command must contain an entry for ``connect.host`` prior to starting zrepl.
|
||||||
|
Thus, run the following on the pulling host's command line (substituting ``connect.host``):
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
ssh -i /etc/zrepl/ssh/identity root@prod.example.com
|
||||||
|
|
||||||
|
.. NOTE::
|
||||||
|
|
||||||
|
The environment variables of the underlying SSH process are cleared. ``$SSH_AUTH_SOCK`` will not be available.
|
||||||
|
It is suggested to create a separate, unencrypted SSH key solely for that purpose.
|
||||||
|
|
||||||
|
|
||||||
|
.. _transport-local:
|
||||||
|
|
||||||
|
``local`` Transport
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
The local transport can be used to implement :ref:`local replication <replication-local>`, i.e., push replication between a push and sink job defined in the same configuration file.
|
||||||
|
|
||||||
|
The ``listener_name`` is analogous to a hostname and must match between ``serve`` and ``connect``.
|
||||||
|
The ``client_identity`` is used by the sink as documented above.
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
- type: sink
|
||||||
|
serve:
|
||||||
|
type: local
|
||||||
|
listener_name: localsink
|
||||||
|
...
|
||||||
|
|
||||||
|
- type: push
|
||||||
|
connect:
|
||||||
|
type: local
|
||||||
|
listener_name: localsink
|
||||||
|
client_identity: local_backup
|
||||||
|
...
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
zrepl Documentation
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
+++
|
|
||||||
title = "zrepl - ZFS replication"
|
|
||||||
+++
|
|
||||||
|
|
||||||
# zrepl - ZFS replication
|
|
||||||
|
|
||||||
`zrepl` is a tool for replicating ZFS filesystems.
|
|
||||||
|
|
||||||
{{% panel theme="danger" header="Important" %}}
|
|
||||||
`zrepl` as well as this documentation is still under active
|
|
||||||
development. Some of the features below are not implemented yet. Use & test at your own risk ;)
|
|
||||||
{{% / panel %}}
|
|
||||||
|
|
||||||
## Main Features
|
|
||||||
|
|
||||||
* filesystem replication
|
|
||||||
* local & over network (SSH)
|
|
||||||
* push & pull mode
|
|
||||||
* snapshots & bookmarks support
|
|
||||||
* feature-negotiation for
|
|
||||||
* resumable `send & receive`
|
|
||||||
* compressed `send & receive`
|
|
||||||
* raw encrypted `send & receive` (as soon as it is available)
|
|
||||||
* access control checks when pulling datasets
|
|
||||||
* [flexible mappings]({{< ref "configuration/overview.md#mapping-filter-syntax" >}}) for filesystems
|
|
||||||
* automatic snapshot creation
|
|
||||||
* periodic interval
|
|
||||||
* automatic snapshot pruning
|
|
||||||
* [Retention Grid]({{< ref "configuration/snapshots.md#retention-grid" >}})
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
`zrepl` is usable but nowhere near a stable release right now - we are happy
|
|
||||||
about contributors!
|
|
||||||
|
|
||||||
* Explore the codebase
|
|
||||||
* These docs live in the `docs/` subdirectory
|
|
||||||
* Document non-obvious / confusing / plain broken things you encounter when using `zrepl` for the first time
|
|
||||||
* Check the *Issues* and *Projects* sections for things to do ;)
|
|
||||||
|
|
||||||
{{% panel header="<i class='fa fa-github'></i> Getting your code merged"%}}
|
|
||||||
[The <i class='fa fa-github'></i> GitHub repository](https://github.com/zrepl/zrepl) is where all development happens.
|
|
||||||
Open your issue / PR there.
|
|
||||||
{{% /panel %}}
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
+++
|
|
||||||
title = "Configuration"
|
|
||||||
alwaysopen = true
|
|
||||||
+++
|
|
||||||
|
|
||||||
{{% children description="true" %}}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
+++
|
|
||||||
title = "Example: Pull Backup"
|
|
||||||
description = "Example configuration for a typical pull-backup scenario, e.g. server to server"
|
|
||||||
+++
|
|
||||||
|
|
||||||
Example configuration for a typical pull-backup scenario, e.g. server to server
|
|
||||||
|
|
||||||
{{% alert theme="warning"%}}TBD{{% /alert %}}
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
+++
|
|
||||||
title = "Example: Push Backup"
|
|
||||||
description = "Example configuration for a typical push-backup scenario, e.g. laptop to NAS"
|
|
||||||
+++
|
|
||||||
|
|
||||||
Example configuration for a typical push-backup scenario, e.g. laptop to NAS
|
|
||||||
|
|
||||||
{{% alert theme="warning"%}}TBD{{% /alert %}}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
+++
|
|
||||||
title = "Overview"
|
|
||||||
weight = 100
|
|
||||||
description = "Configuration format, SSH authentication, etc."
|
|
||||||
+++
|
|
||||||
|
|
||||||
{{% panel header="Recommendation" %}}
|
|
||||||
Keep the [sample configuration file](https://github.com/zrepl/zrepl/blob/master/cmd/sampleconf/zrepl.yml) open on the side while reading this document!
|
|
||||||
{{% / panel %}}
|
|
||||||
|
|
||||||
All configuration is managed in a single YAML file.<br />
|
|
||||||
It is structured by sections roughly corresponding to `zrepl` subcommands:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# REPLICATION
|
|
||||||
# Remote zrepl instances where pull and push jobs connect to
|
|
||||||
remotes:
|
|
||||||
name_of_remote: #...
|
|
||||||
# Push jobs (replication from local to remote)
|
|
||||||
pushs:
|
|
||||||
name_of_push_job: #...
|
|
||||||
name_of_other_push_job: #...
|
|
||||||
# pull jobs (replication from remote to local & local to local)
|
|
||||||
pulls:
|
|
||||||
name_of_pull_job: #...
|
|
||||||
# mapping incoming pushs to local datasets
|
|
||||||
sinks:
|
|
||||||
client_identity: #...
|
|
||||||
# access control for remote pull jobs
|
|
||||||
pull_acls:
|
|
||||||
client_identity: #...
|
|
||||||
|
|
||||||
# SNAPSHOT MANAGEMENT
|
|
||||||
# Automatic snapshotting of filesystems
|
|
||||||
autosnap:
|
|
||||||
name_of_autosnap_job: #...
|
|
||||||
# Automatic pruning of snapshots based on creation date
|
|
||||||
prune:
|
|
||||||
name_of_prune_job: #...
|
|
||||||
```
|
|
||||||
|
|
||||||
When using `zrepl(8)`, a *subcommand* is passed the *job name* as a positional argument:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
autosnap: # subcommand
|
|
||||||
db: # job name
|
|
||||||
prefix: zrepl_
|
|
||||||
interval: 10m
|
|
||||||
dataset_filter: {
|
|
||||||
"tank/db<": ok
|
|
||||||
}
|
|
||||||
```
|
|
||||||
```bash
|
|
||||||
$ zrepl autosnap --config zrepl.yml db
|
|
||||||
```
|
|
||||||
|
|
||||||
Run `zrepl --help` for a list of subcommands and options.
|
|
||||||
|
|
||||||
## Mapping & Filter Syntax
|
|
||||||
|
|
||||||
For various job types, a filesystem `mapping` or `filter` needs to be
|
|
||||||
specified.
|
|
||||||
|
|
||||||
Both have in common that they return a result for a given filesystem path (in
|
|
||||||
the ZFS filesystem hierarchy): mappings return a *target filesystem*, filters
|
|
||||||
return a *filter result* (`omit`, `ok`).
|
|
||||||
|
|
||||||
The pattern syntax is the same for mappings and filters and is documented in
|
|
||||||
the following section.
|
|
||||||
|
|
||||||
#### Pattern Syntax
|
|
||||||
|
|
||||||
A mapping / filter is specified as a **YAML dictionary** with patterns as keys and
|
|
||||||
results as values.<br />
|
|
||||||
The following rules determine which result is chosen for a given filesystem path:
|
|
||||||
|
|
||||||
* More specific path patterns win over less specific ones
|
|
||||||
* Non-wildcard patterns (full path patterns) win over *subtree wildcards* (`<` at end of pattern)
|
|
||||||
|
|
||||||
{{% panel %}}
|
|
||||||
The **subtree wildcard** `<` means "*the dataset left of `<` and all its children*".
|
|
||||||
{{% / panel %}}
|
|
||||||
|
|
||||||
##### Example
|
|
||||||
|
|
||||||
```
|
|
||||||
# Rule number and its pattern
|
|
||||||
1: tank< # tank and all its children
|
|
||||||
2: tank/foo/bar # full path pattern (no wildcard)
|
|
||||||
3: tank/foo< # tank/foo and all its children
|
|
||||||
|
|
||||||
# Which rule applies to given path?
|
|
||||||
tank/foo/bar/loo => 3
|
|
||||||
tank/bar => 1
|
|
||||||
tank/foo/bar => 2
|
|
||||||
zroot => NO MATCH
|
|
||||||
tank/var/log => 1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Mappings
|
|
||||||
|
|
||||||
The example below shows a pull job that would pull remote datasets to the given local paths.<br />
|
|
||||||
If there exists no mapping (`NO MATCH`), the filesystem will not be pulled.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
pull:
|
|
||||||
app01.example.com: # client identity
|
|
||||||
from: app01.example.com
|
|
||||||
mapping: {
|
|
||||||
"tank/var/db<": "zroot/backups/app01/tank/var/db",
|
|
||||||
"tank/var/www<": "zroot/backups/app01/tank/var/www",
|
|
||||||
"tank/var/log": "zroot/logbackup/app01",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
tank/var/db` => zroot/backups/app01/tank/var/db
|
|
||||||
tank/var/db/a/child => zroot/backups/app01/tank/var/db/a/child
|
|
||||||
...
|
|
||||||
tank/var/www => zroot/backups/app01/tank/var/www
|
|
||||||
tank/var/www/a/child => zroot/backups/app01/tank/var/www/a/child
|
|
||||||
...
|
|
||||||
tank/var/log => zroot/logbackup/app01
|
|
||||||
tank/var/log/foo => NOT MAPPED
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Filters
|
|
||||||
|
|
||||||
Valid filter results: `ok` or `omit`.
|
|
||||||
|
|
||||||
The example below shows a pull ACL that allows access to the user homes but not
|
|
||||||
to the rest of the system's datasets.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Example for filter syntax
|
|
||||||
pull_acls:
|
|
||||||
backups.example.com: # client identity
|
|
||||||
filter: {
|
|
||||||
"<": omit,
|
|
||||||
"tank/usr/home<": ok,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next up
|
|
||||||
|
|
||||||
* [Automating snapshot creation & pruning]({{< ref "configuration/snapshots.md" >}})
|
|
||||||
|
|
||||||
* [Replicating filesystems]({{< ref "configuration/replication.md" >}})
|
|
||||||
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
+++
|
|
||||||
title = "Filesystem Replication"
|
|
||||||
description = "Replicating filesystems with existing bookmarks & snapshots"
|
|
||||||
weight = 300
|
|
||||||
+++
|
|
||||||
|
|
||||||
{{% alert theme="warning"%}}Under Construction{{% /alert %}}
|
|
||||||
|
|
||||||
### Remotes
|
|
||||||
|
|
||||||
The `remotes` section specifies remote `zrepl` instances from which to pull from / push backups to:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
remotes:
|
|
||||||
offsite_backups:
|
|
||||||
transport:
|
|
||||||
ssh:
|
|
||||||
host: 192.168.122.6
|
|
||||||
user: root
|
|
||||||
port: 22
|
|
||||||
identity_file: /etc/zrepl/identities/offsite_backups
|
|
||||||
```
|
|
||||||
|
|
||||||
#### SSH Transport
|
|
||||||
|
|
||||||
The SSH transport connects to the remote server using the SSH binary in
|
|
||||||
`$PATH` and the parameters specified in the `zrepl` config file.
|
|
||||||
|
|
||||||
However, instead of a traditional interactive SSH session, `zrepl` expects
|
|
||||||
another instance of `zrepl` on the other side of the connection; You may be
|
|
||||||
familiar with this concept from [git shell](https://git-scm.com/docs/git-shell)
|
|
||||||
or [Borg Backup](https://borgbackup.readthedocs.io/en/stable/deployment.html).
|
|
||||||
|
|
||||||
Check the examples for instructions on how to set this up on your machines!
|
|
||||||
|
|
||||||
{{% panel %}}
|
|
||||||
The environment variables of the underlying SSH process are cleared. `$SSH_AUTH_SOCK` will not be available. We suggest creating a separate, unencrypted SSH key.
|
|
||||||
{{% / panel %}}
|
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user