Compare commits
161 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ebe456197 | |||
| 38ff5c2ce3 | |||
| fad82751b9 | |||
| 2c6f229db0 | |||
| b070b67be5 | |||
| 2bde733e09 | |||
| 91573eb028 | |||
| a646180d7e | |||
| bacc6c98b2 | |||
| bf0a24ea69 | |||
| eb37a3e11a | |||
| 54c63387cb | |||
| 84bdc9f50f | |||
| 3a5f060693 | |||
| 21d29a4cf6 | |||
| a0887afc4f | |||
| f757991635 | |||
| 0da317e3d5 | |||
| 57ceed38f3 | |||
| 0eb7d61829 | |||
| 382981642d | |||
| 28a75d7234 | |||
| e996f34887 | |||
| defaac1b4f | |||
| 9f157677c2 | |||
| ddc42c89fb | |||
| a25ad494cc | |||
| 7d97b2b96e | |||
| a5650ea303 | |||
| 322da7242b | |||
| 6a097e7de3 | |||
| 6ddae5397c | |||
| 9a18259419 | |||
| ade4d07bb5 | |||
| 0a5282d918 | |||
| 12012916b7 | |||
| 9fa4e03748 | |||
| f7be222471 | |||
| 5e9380820e | |||
| 62349a6559 | |||
| ada0c8ab70 | |||
| 7bc7f33f43 | |||
| be203494c5 | |||
| 3c983ac5e0 | |||
| a2c797000f | |||
| 80f9335950 | |||
| d217a3f15d | |||
| 063bf8258b | |||
| 1d66240495 | |||
| d977ca4a24 | |||
| 1cdaccbc3d | |||
| 8e38680ef5 | |||
| 393c4bb2f6 | |||
| 74ad3bf887 | |||
| 7c18abb664 | |||
| 1dffcca430 | |||
| 5a8ee9f640 | |||
| 7b119c0b4f | |||
| 02d5e67698 | |||
| 854280e957 | |||
| 9ea2b1d628 | |||
| 7b7548a4f7 | |||
| a2e410f8b6 | |||
| 690729ccdb | |||
| ff743453b6 | |||
| f34a7db48a | |||
| 3a355b1617 | |||
| 0b5e82a18b | |||
| 2798667345 | |||
| b1660f0cba | |||
| dfb20ba87f | |||
| 7279cb9113 | |||
| 56cb89d153 | |||
| 6fd9bba0cb | |||
| f9605fae78 | |||
| 9969c3e599 | |||
| a69a8d3b21 | |||
| 3a97b13e2f | |||
| 4efaea60bb | |||
| 06e9756729 | |||
| 8341b7f914 | |||
| 96b986a0a0 | |||
| 60244b60dd | |||
| 9848ec7f4e | |||
| b81eae3f65 | |||
| 6131318c80 | |||
| 4566020360 | |||
| de10dc43d5 | |||
| 8abd0fee86 | |||
| af5f68a34d | |||
| 19e5f83164 | |||
| 64087172ff | |||
| 6c0bb1c9b6 | |||
| 124a9eb521 | |||
| 03e6020c6b | |||
| 836657565e | |||
| 76592723de | |||
| d2f033641e | |||
| 2cfd92e0c3 | |||
| f796df8c34 | |||
| 668dfea4e2 | |||
| 7f61bbbebb | |||
| 8105ca888d | |||
| d0166fe938 | |||
| 290a7f5ab6 | |||
| 2576626240 | |||
| 6591044c2e | |||
| 1ae4e5d438 | |||
| d80096de92 | |||
| 674c2c1cb1 | |||
| 3c5018bd6b | |||
| 93cd71eb72 | |||
| 507be60890 | |||
| 780f389973 | |||
| 69cd230568 | |||
| 55370d9b4d | |||
| ce1ea79bf1 | |||
| 2803c73045 | |||
| 9b3e4716bf | |||
| 83f8bdbacd | |||
| b060a7e0f1 | |||
| f6414210fa | |||
| bbb62dfa85 | |||
| f59a8b7f62 | |||
| 08fd6e659f | |||
| 6cde00073f | |||
| 0043ad08fa | |||
| 0c70c87391 | |||
| b0840ab256 | |||
| 7ad0f96222 | |||
| c373222e3a | |||
| c73268fd77 | |||
| c82015f6cf | |||
| 3af9e8d3d2 | |||
| 1d588282bf | |||
| f7ec4b1338 | |||
| 00cbc5c31d | |||
| 95aa3bc795 | |||
| 53ace58e44 | |||
| c22c4ff2e5 | |||
| 6f511fc149 | |||
| 70b8ebc8b6 | |||
| 02db2d5f9d | |||
| cc749a6290 | |||
| 9f969ef43e | |||
| 4989be7853 | |||
| 8ce4415edb | |||
| 75c28faa5b | |||
| 8644511ebc | |||
| e5627bbd6b | |||
| 3aeb6d88af | |||
| 6f44631973 | |||
| 566a0b44c0 | |||
| 934c4c31f1 | |||
| be8e4c9dcf | |||
| 1b20713421 | |||
| 473b66ca5b | |||
| 5c8a31708e | |||
| ca1211d927 | |||
| b012431df0 | |||
| 6e98f5e74e |
@@ -2,13 +2,14 @@ labels:
|
||||
nix: "enabled"
|
||||
|
||||
when:
|
||||
event:
|
||||
- push
|
||||
- tag
|
||||
- pull_request
|
||||
- deployment
|
||||
- cron
|
||||
- manual
|
||||
- event:
|
||||
- tag
|
||||
- pull_request
|
||||
- deployment
|
||||
- cron
|
||||
- manual
|
||||
- event: push
|
||||
branch: main-*
|
||||
|
||||
steps:
|
||||
- name: check formatting
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Contributing to Garage
|
||||
|
||||
## Policy on AI
|
||||
|
||||
To ensure the quality of the codebase and documentation, the use of AI,
|
||||
including LLMs and coding agents, is strictly restricted in the following way:
|
||||
|
||||
- AI **must not** be used to write documentation
|
||||
|
||||
- **Do not** use AI to write bug reports, commit descriptions and pull request
|
||||
messages
|
||||
|
||||
- **Do not** use AI agents to make contributions to Garage, all contributions
|
||||
must be led by a human that know what they are doing at all times
|
||||
|
||||
- AI **may** be used for some tedious code generation tasks, limited to very
|
||||
mechanical translations from API docs or boilerplate writing. The code
|
||||
generated must be so simple as to make it clear that it cannot be covered by
|
||||
copyright.
|
||||
|
||||
You are free to make use of AI privately to explore the codebase and solve
|
||||
conceptual problems, but please restrain from copying the output from an LLM
|
||||
anywhere in your code or on the issue tracker, or from letting an agent edit
|
||||
the codebase directly.
|
||||
|
||||
|
||||
## Asking questions
|
||||
|
||||
Read the documentation before asking questions.
|
||||
Do not use the issue tracker to ask questions about Garage.
|
||||
Questions asked on the issue tracker will be closed.
|
||||
|
||||
Ask questions on the Matrix channel `#garage:deuxfleurs.fr` so that any
|
||||
community member can see your question and help you out.
|
||||
|
||||
If you need in-depth support from the Garage developers specifically, write to
|
||||
`garagehq@deuxfleurs.fr`. Even if you do so, we do not commit to giving you an
|
||||
answer.
|
||||
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
When writing a bug report, use this checklist:
|
||||
|
||||
- For bugs that can be reproduced:
|
||||
- confirm that you are using the latest version of Garage and that the bug still exists in this version
|
||||
- set the log level to debug using the `RUST_LOG=garage=debug` environment variable and reproduce the bug to get more verbose logs
|
||||
|
||||
- Check whether there is already an open issue in the bug tracker. If so, your bug report is still valuable but please add it as a comment to the existing issue instead of opening a new one.
|
||||
|
||||
- Collect as much information as possible:
|
||||
- logs of the Garage daemon at the time the issue happened, including logs that show what was happening before the issue occurred
|
||||
- the output of `garage status`
|
||||
- the output of `garage stats -a`
|
||||
- the output of `garage layout history`
|
||||
|
||||
- Write a detailed bug report, including:
|
||||
- a description of your cluster (number of nodes, hardware, operating system, networking, etc)
|
||||
- a detailed description of what you did that led to the issue, including any code or command line that invoked a Garage API
|
||||
- what you were expecting
|
||||
- what actually happened, and how that's different from what you expected
|
||||
- the information collected previously
|
||||
- if possible, simple steps to help the developers reproduce the issue locally
|
||||
|
||||
Bug reports that are imprecise or otherwise unactionable will be closed.
|
||||
|
||||
|
||||
## Suggesting new features
|
||||
|
||||
Garage can be improved in many ways, but just suggesting a new feature does not mean we will implement it.
|
||||
Feature requests that may lead to an actual implementation are feature requests that:
|
||||
|
||||
- are precise and actionable, i.e. include a precise description of the expected behavior and any necessary architectural details required for the implementation
|
||||
- are motivated by actual need from a variety of users
|
||||
|
||||
Moreover, a certain number of features are defined as out-of-scope for Garage, including but not limited to:
|
||||
|
||||
- extensions to the S3 API that are not present on AWS
|
||||
- features that require the implementation of a consensus algorithm
|
||||
- more generally, features that are incompatible with the architecture of Garage and its goal of staying simple
|
||||
|
||||
Only feature requests in one of the following category may stay open in the issue tracker:
|
||||
|
||||
- features that the Garage team wants to work on
|
||||
- features that are being actively worked on by an external contributor which is clearly identified
|
||||
- features that are easy to implement and could be an easy task for a new contributor that wants to get to know the codebase
|
||||
|
||||
All other feature requests will be closed after a few months of inactivity, so as to keep the number of open issues to a manageable level.
|
||||
Feature requests that are clearly out of scope will be closed directly.
|
||||
|
||||
|
||||
## Improving the documentation
|
||||
|
||||
An easy way to contribute to Garage which also adds a lot of value is to
|
||||
improve the documentation. Make sure to write in clear technical English, and
|
||||
write unambiguously. Documentation contributions are very appreciated if they
|
||||
are well-written.
|
||||
|
||||
|
||||
## For developers
|
||||
|
||||
We welcome code contributions to Garage that adhere to our standards for quality:
|
||||
|
||||
- Changes should be reviewed from a functional perspective to ensure that they work well with the existing codebase and do not introduce bugs or subtle issues.
|
||||
|
||||
- You must have tested your contribution to make sure that it does what it says. The amount of testing required is proportional to the complexity of the change introduced.
|
||||
|
||||
- Any new feature must be properly documented following existing practices (see below).
|
||||
|
||||
- Unit tests should be included when relevant.
|
||||
|
||||
- Contributions should pass basic lints for syntactic quality (`cargo fmt`, `cargo clippy`, `typos`).
|
||||
|
||||
- Contributions should pass our CI test suite.
|
||||
|
||||
- No user-facing breaking changes may be introduced between major releases.
|
||||
|
||||
- No internal data model change may be introduced between major releases, to
|
||||
ensure that Garage daemons with different minor/patch versions numbers can
|
||||
work together in a cluster. For major releases, a proper migration path
|
||||
should be implemented and tested thoroughly.
|
||||
|
||||
Please follow up on your work when changes are requested, to avoid stale PRs.
|
||||
Do not take it personally if a Garage developer pushes directly to your branch
|
||||
to modify your contribution, as this might be necessary to get it merged
|
||||
faster.
|
||||
|
||||
### Properly documenting your contribution
|
||||
|
||||
#### Configuration options
|
||||
|
||||
New configuration options should be documented in
|
||||
`doc/book/reference-manual/configuration.md`. The documentation for a
|
||||
configuration option should be exhaustive. For instance, for choice options all
|
||||
choices should be listed explicitly with a precise description of their
|
||||
meaning.
|
||||
|
||||
In terms of syntax, all configuration options should appear in three places:
|
||||
|
||||
- in the example at the top, with an example value
|
||||
- in the index of all configuration options which is sorted by alphabetical order
|
||||
- in its dedicated subsection with full reference text
|
||||
|
||||
#### CLI commands and command flags
|
||||
|
||||
CLI commands are self-documented using the doc commends in the codebase.
|
||||
Make sure to write clear and precise comments for all options you are adding.
|
||||
|
||||
#### S3 features
|
||||
|
||||
If you implement new S3 features, make sure to update the compatibility matrix in `doc/book/reference-manual/s3-compatibility.md`.
|
||||
|
||||
#### Admin API
|
||||
|
||||
The admin API has an OpenAPI specification that is automatically generated
|
||||
using Utoipa, from a description of each endpoint that is given in
|
||||
`src/api/admin/openapi.rs` and a description of data structure schemas in
|
||||
`src/api/admin/api.rs`. The code in `openapi.rs` is only used to generate the
|
||||
OpenAPI specification document and not for the actual implementation in Garage,
|
||||
whereas structures defined in `api.rs` are also used for the implementation of
|
||||
API calls. Make sure to write good doc comments for all of these items so that
|
||||
the OpenAPI specification will be precise and accurate.
|
||||
|
||||
An up-to-date version of the OpenAPI specification document should be kept in
|
||||
the repository in `doc/api/garage-admin-v2.json`. When you are making changes
|
||||
to the admin API, update this document with the following command:
|
||||
|
||||
```
|
||||
cargo run -- admin-api-schema > doc/api/garage-admin-v2.json
|
||||
```
|
||||
|
||||
|
||||
## Garage team organization
|
||||
|
||||
Alex (handle `lx`) is the lead developer and is responsible of ensuring the
|
||||
correctness of Garage and stability between version upgrades.
|
||||
|
||||
The other maintainers are Trinity (handle `trinity-1686a`), Quentin (handle `quentin`) and Maximilien (handle `halfa`).
|
||||
|
||||
Maximilien is responsible for coordinating effort on the Kubernetes integration / Helm chart.
|
||||
|
||||
## Pull request merging criteria
|
||||
|
||||
The following PRs should only be merged after review and approval from Alex:
|
||||
|
||||
- PRs that introduce architectural changes, such as changes in the data model
|
||||
or change in the coordination protocols between nodes
|
||||
|
||||
- PRs that introduce changes on the format of data structures used for
|
||||
persistent disk storage and internal cluster communication (RPC)
|
||||
|
||||
- PRs that are suspected of introducing some kind of breakage or unexpected
|
||||
behavior due to their complexity
|
||||
|
||||
PRs that introduce breaking change for users but don't fall in one of the
|
||||
previous category should be discussed between maintainers to evaluate the
|
||||
impact on users when upgrading. Alex's approval is not required to merge them
|
||||
as long as they are clearly identified as breaking in the PR title, and are
|
||||
properly merged in the branch for the next major version and not in the current
|
||||
main branch.
|
||||
|
||||
All other PRs can be merged by any maintainer on their own, once they are
|
||||
confident that the quality standards defined in this document are respected
|
||||
before merging.
|
||||
|
||||
## Merging strategy
|
||||
|
||||
When merging PRs, maintainers should ensure that a Git commit is created by
|
||||
Forgejo that records the PR number, its title and its text in the commit
|
||||
message. If a PR is fixing an issue, make sure that the issue number is
|
||||
included in the PR title as well. This is to ensure that when releasing a new
|
||||
version of Garage, the changelog in the release notes can be properly
|
||||
constructed by reading the Git log since the last release.
|
||||
|
||||
We also want to keep the history "almost linear" to facilitate the use of `git
|
||||
bisect` if it ever were necessary. This leaves the following two merging
|
||||
strategies:
|
||||
|
||||
- For PRs that consist of many commits that should stay independent, the
|
||||
"rebase and create merge commit" strategy should be used. The merge commit is
|
||||
created automatically by Forgejo and saves the PR's number, title and text in
|
||||
the commit message.
|
||||
|
||||
- For PRs that consist of only one commit, or a few number of commits that can
|
||||
be merged, the "create squash commit" strategy should be used. This way a
|
||||
single commit will be created by Forgejo which also saves the PR's number,
|
||||
title and text in the commit message.
|
||||
|
||||
When cherry-picking commits from one branch to the other, a simple fast-forward
|
||||
merging strategy can be used if the commit message already references a PR
|
||||
number.
|
||||
@@ -16,6 +16,7 @@ members = [
|
||||
"src/garage",
|
||||
"src/k2v-client",
|
||||
"src/format-table",
|
||||
"fuzz",
|
||||
]
|
||||
|
||||
default-members = ["src/garage"]
|
||||
@@ -24,108 +25,129 @@ default-members = ["src/garage"]
|
||||
|
||||
# Internal Garage crates
|
||||
format_table = { version = "0.1.1", path = "src/format-table" }
|
||||
garage_api_common = { version = "2.2.0", path = "src/api/common" }
|
||||
garage_api_admin = { version = "2.2.0", path = "src/api/admin" }
|
||||
garage_api_s3 = { version = "2.2.0", path = "src/api/s3" }
|
||||
garage_api_k2v = { version = "2.2.0", path = "src/api/k2v" }
|
||||
garage_block = { version = "2.2.0", path = "src/block" }
|
||||
garage_db = { version = "2.2.0", path = "src/db", default-features = false }
|
||||
garage_model = { version = "2.2.0", path = "src/model", default-features = false }
|
||||
garage_net = { version = "2.2.0", path = "src/net" }
|
||||
garage_rpc = { version = "2.2.0", path = "src/rpc" }
|
||||
garage_table = { version = "2.2.0", path = "src/table" }
|
||||
garage_util = { version = "2.2.0", path = "src/util" }
|
||||
garage_web = { version = "2.2.0", path = "src/web" }
|
||||
garage_api_common = { version = "2.3.0", path = "src/api/common" }
|
||||
garage_api_admin = { version = "2.3.0", path = "src/api/admin" }
|
||||
garage_api_s3 = { version = "2.3.0", path = "src/api/s3" }
|
||||
garage_api_k2v = { version = "2.3.0", path = "src/api/k2v" }
|
||||
garage_block = { version = "2.3.0", path = "src/block" }
|
||||
garage_db = { version = "2.3.0", path = "src/db", default-features = false }
|
||||
garage_model = { version = "2.3.0", path = "src/model", default-features = false }
|
||||
garage_net = { version = "2.3.0", path = "src/net" }
|
||||
garage_rpc = { version = "2.3.0", path = "src/rpc" }
|
||||
garage_table = { version = "2.3.0", path = "src/table" }
|
||||
garage_util = { version = "2.3.0", path = "src/util" }
|
||||
garage_web = { version = "2.3.0", path = "src/web" }
|
||||
k2v-client = { version = "0.0.4", path = "src/k2v-client" }
|
||||
|
||||
# External crates from crates.io
|
||||
arc-swap = "1.1"
|
||||
arc-swap = "1.8"
|
||||
arbitrary = { version = "1.4.2"}
|
||||
argon2 = "0.5"
|
||||
async-trait = "0.1.7"
|
||||
async-trait = "0.1"
|
||||
backtrace = "0.3"
|
||||
base64 = "0.21"
|
||||
base64 = "0.22"
|
||||
blake2 = "0.10"
|
||||
bytes = "1.0"
|
||||
bytesize = "1.1"
|
||||
bytes = "1.11"
|
||||
bytesize = "2.3"
|
||||
cfg-if = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
crc-fast = "1.6"
|
||||
crc-fast = "1.9"
|
||||
crypto-common = "0.1"
|
||||
gethostname = "0.4"
|
||||
git-version = "0.3.4"
|
||||
gethostname = "1.1"
|
||||
git-version = "0.3"
|
||||
hex = "0.4"
|
||||
hexdump = "0.1"
|
||||
hmac = "0.12"
|
||||
itertools = "0.12"
|
||||
ipnet = "2.9.0"
|
||||
lazy_static = "1.4"
|
||||
itertools = "0.14"
|
||||
ipnet = "2.11"
|
||||
lazy_static = "1.5"
|
||||
libfuzzer-sys = "0.4"
|
||||
md-5 = "0.10"
|
||||
mktemp = "0.5"
|
||||
nix = { version = "0.29", default-features = false, features = ["fs"] }
|
||||
nom = "7.1"
|
||||
nix = { version = "0.31", default-features = false, features = ["fs"] }
|
||||
nom = "8.0"
|
||||
parking_lot = "0.12"
|
||||
parse_duration = "2.1"
|
||||
paste = "1.0"
|
||||
pin-project = "1.0.12"
|
||||
pnet_datalink = "0.34"
|
||||
rand = "0.8"
|
||||
pin-project = "1.1"
|
||||
pnet_datalink = "0.35"
|
||||
rand = "0.9"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
timeago = { version = "0.4", default-features = false }
|
||||
timeago = { version = "0.5", default-features = false }
|
||||
xxhash-rust = { version = "0.8", default-features = false, features = ["xxh3"] }
|
||||
|
||||
aes-gcm = { version = "0.10", features = ["aes", "stream"] }
|
||||
sodiumoxide = { version = "0.2.5-0", package = "kuska-sodiumoxide" }
|
||||
kuska-handshake = { version = "0.2.0", features = ["default", "async_std"] }
|
||||
|
||||
clap = { version = "4.1", features = ["derive", "env"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
pretty_env_logger = "0.5"
|
||||
structopt = { version = "0.3", default-features = false }
|
||||
syslog-tracing = "0.3"
|
||||
tracing = "0.1"
|
||||
tracing-journald = "0.3.1"
|
||||
tracing-journald = "0.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
heed = { version = "0.11", default-features = false, features = ["lmdb"] }
|
||||
rusqlite = "0.37"
|
||||
heed = { version = "0.22", default-features = false, features = [] }
|
||||
rusqlite = { version = "0.38", features = ["fallible_uint"] }
|
||||
r2d2 = "0.8"
|
||||
r2d2_sqlite = "0.31"
|
||||
fjall = "2.4"
|
||||
r2d2_sqlite = "0.32"
|
||||
fjall = "2.11"
|
||||
|
||||
async-compression = { version = "0.4", features = ["tokio", "zstd"] }
|
||||
zstd = { version = "0.13", default-features = false }
|
||||
|
||||
quick-xml = { version = "0.26", features = ["serialize"] }
|
||||
rmp-serde = "1.1.2"
|
||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
||||
rmp-serde = "1.3"
|
||||
serde = { version = "1.0", default-features = false, features = ["derive", "rc"] }
|
||||
serde_bytes = "0.11"
|
||||
serde_json = "1.0"
|
||||
toml = { version = "0.8", default-features = false, features = ["parse"] }
|
||||
utoipa = { version = "5.3.1", features = ["chrono"] }
|
||||
toml = { version = "0.9", default-features = false, features = ["parse", "serde"] }
|
||||
utoipa = { version = "5.4", features = ["chrono"] }
|
||||
|
||||
# newer version requires rust edition 2021
|
||||
k8s-openapi = { version = "0.21", features = ["v1_24"] }
|
||||
kube = { version = "0.88", default-features = false, features = ["runtime", "derive", "client", "rustls-tls"] }
|
||||
schemars = "0.8"
|
||||
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls-manual-roots", "json"] }
|
||||
k8s-openapi = { version = "0.27", features = ["v1_35"] }
|
||||
kube = { version = "3.0", default-features = false, features = [
|
||||
"runtime",
|
||||
"derive",
|
||||
"client",
|
||||
"rustls-tls",
|
||||
] }
|
||||
schemars = "1.2"
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"rustls-no-provider",
|
||||
"json",
|
||||
] }
|
||||
|
||||
form_urlencoded = "1.0.0"
|
||||
http = "1.0"
|
||||
form_urlencoded = "1.2"
|
||||
http = "1.4"
|
||||
httpdate = "1.0"
|
||||
http-range = "0.1"
|
||||
http-body-util = "0.1"
|
||||
hyper = { version = "1.0", default-features = false }
|
||||
hyper = { version = "1.8", default-features = false }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
multer = "3.0"
|
||||
percent-encoding = "2.2"
|
||||
roxmltree = "0.19"
|
||||
url = "2.3"
|
||||
multer = "3.1"
|
||||
percent-encoding = "2.3"
|
||||
roxmltree = "0.21"
|
||||
url = "2.5"
|
||||
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
tokio = { version = "1.0", default-features = false, features = ["rt", "rt-multi-thread", "io-util", "net", "time", "macros", "sync", "signal", "fs"] }
|
||||
tokio = { version = "1.49", default-features = false, features = [
|
||||
"rt",
|
||||
"rt-multi-thread",
|
||||
"io-util",
|
||||
"net",
|
||||
"time",
|
||||
"macros",
|
||||
"sync",
|
||||
"signal",
|
||||
"fs",
|
||||
] }
|
||||
tokio-util = { version = "0.7", features = ["compat", "io"] }
|
||||
tokio-stream = { version = "0.1", features = ["net"] }
|
||||
socket2 = { version = "0.6", features = ["all"] }
|
||||
|
||||
opentelemetry = { version = "0.17", features = ["rt-tokio", "metrics", "trace"] }
|
||||
opentelemetry-prometheus = "0.10"
|
||||
@@ -134,25 +156,42 @@ opentelemetry-contrib = "0.9"
|
||||
prometheus = "0.13"
|
||||
|
||||
# used by the k2v-client crate only
|
||||
aws-sigv4 = { version = "1.1", default-features = false }
|
||||
hyper-rustls = { version = "0.26", default-features = false, features = ["http1", "http2", "ring", "rustls-native-certs"] }
|
||||
aws-sigv4 = { version = "1.3", default-features = false }
|
||||
hyper-rustls = { version = "0.27", default-features = false, features = [
|
||||
"http1",
|
||||
"http2",
|
||||
"ring",
|
||||
"rustls-native-certs",
|
||||
] }
|
||||
log = "0.4"
|
||||
thiserror = "2.0"
|
||||
|
||||
# ---- used only as build / dev dependencies ----
|
||||
assert-json-diff = "2.0"
|
||||
rustc_version = "0.4.0"
|
||||
rustc_version = "0.4"
|
||||
static_init = "1.0"
|
||||
aws-smithy-runtime = { version = "1.8", default-features = false, features = ["tls-rustls"] }
|
||||
aws-sdk-config = { version = "1.62", default-features = false }
|
||||
aws-sdk-s3 = { version = "1.79", default-features = false, features = ["rt-tokio"] }
|
||||
|
||||
[profile.dev]
|
||||
#lto = "thin" # disabled for now, adds 2-4 min to each CI build
|
||||
lto = "off"
|
||||
aws-smithy-runtime = { version = "1.9", default-features = false, features = [
|
||||
"tls-rustls",
|
||||
] }
|
||||
aws-sdk-config = { version = "1.99", default-features = false }
|
||||
aws-sdk-s3 = { version = "1.121", default-features = false, features = [
|
||||
"rt-tokio",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
opt-level = 3
|
||||
strip = true
|
||||
strip = "debuginfo"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
# pedantic lints configuration
|
||||
doc_markdown = "warn"
|
||||
format_collect = "warn"
|
||||
manual_midpoint = "warn"
|
||||
semicolon_if_nothing_returned = "warn"
|
||||
unnecessary_semicolon = "warn"
|
||||
unnecessary_wraps = "warn"
|
||||
|
||||
# nursery lints configuration
|
||||
# or_fun_call = "warn" # enable it to help detect non trivial code used in `_or` method
|
||||
|
||||
@@ -4,4 +4,6 @@ ENV RUST_BACKTRACE=1
|
||||
ENV RUST_LOG=garage=info
|
||||
|
||||
COPY result/bin/garage /
|
||||
CMD [ "/garage", "server"]
|
||||
|
||||
ENTRYPOINT ["/garage"]
|
||||
CMD ["server"]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Security Reporting
|
||||
|
||||
If you wish to report responsibly a security vulnerability about Garage, we ask that you follow the following process.
|
||||
|
||||
Please report each security vulnerabilities by filling out the following template:
|
||||
|
||||
- PROJECT: A URL to the code repository containing the vulnerable version - be reminded that the source of truth is at https://git.deuxfleurs.fr/deuxfleurs/garage
|
||||
- PUBLIC: Please let us know if this vulnerability has been made or discussed publicly already, and if so, please let us know where.
|
||||
- DESCRIPTION: Please provide precise description of the security vulnerability you have found with as much information as you are able and willing to provide.
|
||||
|
||||
Please send the above info, along with any other information you feel is pertinent by emailing the core team at: garagehq@deuxfleurs.fr
|
||||
|
||||
The Garage Core Team will let you know within a few weeks whether or not your report has been accepted or rejected.
|
||||
We ask that you please keep the report confidential until we have either responded or made a public announcement.
|
||||
@@ -12,7 +12,7 @@
|
||||
"name": "AGPL-3.0",
|
||||
"identifier": "AGPL-3.0"
|
||||
},
|
||||
"version": "v2.2.0"
|
||||
"version": "v2.3.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@@ -1797,6 +1797,17 @@
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Plain-text information about the layout computation\n(do not try to parse this)"
|
||||
},
|
||||
"statistics": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ComputationStat",
|
||||
"description": "Structured statistics about the layout computation"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2119,6 +2130,180 @@
|
||||
"Historical"
|
||||
]
|
||||
},
|
||||
"ComputationStat": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"replicationFactor",
|
||||
"effectiveZoneRedundancy",
|
||||
"partitionSize",
|
||||
"lowPartitionSize",
|
||||
"usableCapacity",
|
||||
"totalCapacity",
|
||||
"effectiveCapacity",
|
||||
"lowUsableCapacity",
|
||||
"zones"
|
||||
],
|
||||
"properties": {
|
||||
"effectiveCapacity": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "The final effective capacity of the cluster, accounting for replication",
|
||||
"minimum": 0
|
||||
},
|
||||
"effectiveZoneRedundancy": {
|
||||
"type": "integer",
|
||||
"description": "The zone redundancy factor achieved by this layout",
|
||||
"minimum": 0
|
||||
},
|
||||
"lowPartitionSize": {
|
||||
"type": "boolean",
|
||||
"description": "Warning flag indicating when partitions are very small"
|
||||
},
|
||||
"lowUsableCapacity": {
|
||||
"type": "boolean",
|
||||
"description": "Warning flag indicating that the raw node capacity could not be used\neffectively"
|
||||
},
|
||||
"partitionSize": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "The size of a partition, in bytes",
|
||||
"minimum": 0
|
||||
},
|
||||
"previousPartitionSize": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "The size of a partition, in bytes, in the previous layout",
|
||||
"minimum": 0
|
||||
},
|
||||
"replicationFactor": {
|
||||
"type": "integer",
|
||||
"description": "The cluster's replication factor",
|
||||
"minimum": 0
|
||||
},
|
||||
"totalCapacity": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "The total raw capacity of nodes",
|
||||
"minimum": 0
|
||||
},
|
||||
"totalMovedPartitions": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"description": "The total number of partitions that will be moved to a new storage node",
|
||||
"minimum": 0
|
||||
},
|
||||
"usableCapacity": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "The portion of total raw node capacity that is used by partitions",
|
||||
"minimum": 0
|
||||
},
|
||||
"zones": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ComputationStatZone"
|
||||
},
|
||||
"description": "Per-zone storage statistics"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ComputationStatNode": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"tags",
|
||||
"storedPartitions",
|
||||
"newPartitions",
|
||||
"totalCapacity",
|
||||
"usableCapacity"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The node's ID"
|
||||
},
|
||||
"newPartitions": {
|
||||
"type": "integer",
|
||||
"description": "The number of partitions that are newly replicated on this node",
|
||||
"minimum": 0
|
||||
},
|
||||
"storedPartitions": {
|
||||
"type": "integer",
|
||||
"description": "The number of partitions that are replicated on this node",
|
||||
"minimum": 0
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "The node's tags as defined in the layout"
|
||||
},
|
||||
"totalCapacity": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "The node's raw capacity",
|
||||
"minimum": 0
|
||||
},
|
||||
"usableCapacity": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "The portion of the node's raw capacity that is used by partitions it stores",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ComputationStatZone": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"nodes",
|
||||
"totalReplicatedPartitions",
|
||||
"uniquePartitions",
|
||||
"totalCapacity",
|
||||
"usableCapacity"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the zone"
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ComputationStatNode"
|
||||
},
|
||||
"description": "Per-node storage statistics for nodes in this zone"
|
||||
},
|
||||
"totalCapacity": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "The total raw capacity of nodes in this zone",
|
||||
"minimum": 0
|
||||
},
|
||||
"totalReplicatedPartitions": {
|
||||
"type": "integer",
|
||||
"description": "The total number of partition replicas in this zone",
|
||||
"minimum": 0
|
||||
},
|
||||
"uniquePartitions": {
|
||||
"type": "integer",
|
||||
"description": "The number of unique partitions that have at least one replica in this zone",
|
||||
"minimum": 0
|
||||
},
|
||||
"usableCapacity": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "The used portion of the raw capacity of nodes in this zones",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConnectClusterNodesRequest": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -2340,6 +2525,16 @@
|
||||
"format": "int64",
|
||||
"description": "Total number of bytes used by objects in this bucket"
|
||||
},
|
||||
"corsRules": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cors.Rule"
|
||||
},
|
||||
"description": "CORS rules for this bucket"
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
@@ -2363,6 +2558,16 @@
|
||||
},
|
||||
"description": "List of access keys that have permissions granted on this bucket"
|
||||
},
|
||||
"lifecycleRules": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/lifecycle.Rule"
|
||||
},
|
||||
"description": "Object lifecycle rules for this bucket"
|
||||
},
|
||||
"objects": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
@@ -2423,6 +2628,15 @@
|
||||
},
|
||||
"indexDocument": {
|
||||
"type": "string"
|
||||
},
|
||||
"routingRules": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/website.RoutingRule"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2581,8 +2795,61 @@
|
||||
"freeform"
|
||||
],
|
||||
"properties": {
|
||||
"bucketCount": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "number of buckets in the cluster",
|
||||
"minimum": 0
|
||||
},
|
||||
"dataAvail": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "available storage space for object data in the entire cluster, in bytes",
|
||||
"minimum": 0
|
||||
},
|
||||
"freeform": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "cluster statistics as a free-form string, kept for compatibility with nodes\nrunning older v2.x versions of garage"
|
||||
},
|
||||
"incompleteAvailInfo": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "true if the available storage space statistics are imprecise due to missing\ninformation of disconnected nodes. When this is the case, the actual\nspace available in the cluster might be lower than the reported values."
|
||||
},
|
||||
"metadataAvail": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "available storage space for object metadata in the entire cluster, in bytes",
|
||||
"minimum": 0
|
||||
},
|
||||
"totalObjectBytes": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "total size of objects stored in all buckets, before compression, deduplication and\nreplication (this is NOT equivalent to actual disk usage in the cluster)",
|
||||
"minimum": 0
|
||||
},
|
||||
"totalObjectCount": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64",
|
||||
"description": "total number of objects stored in all buckets",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3055,7 +3322,8 @@
|
||||
],
|
||||
"properties": {
|
||||
"dbEngine": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "database engine used for metadata"
|
||||
},
|
||||
"garageFeatures": {
|
||||
"type": [
|
||||
@@ -3064,16 +3332,26 @@
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "build-time features enabled for this garage release"
|
||||
},
|
||||
"garageVersion": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "garage version running on this node"
|
||||
},
|
||||
"hostname": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "hostname of this node"
|
||||
},
|
||||
"nodeId": {
|
||||
"type": "string"
|
||||
},
|
||||
"rustVersion": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "rustc version with which this garage release was compiled"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3083,8 +3361,30 @@
|
||||
"freeform"
|
||||
],
|
||||
"properties": {
|
||||
"blockManagerStats": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NodeBlockManagerStats",
|
||||
"description": "block manager statistics"
|
||||
}
|
||||
]
|
||||
},
|
||||
"freeform": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "node statistics as a free-form string, kept for compatibility with nodes\nrunning older v2.x versions of garage"
|
||||
},
|
||||
"tableStats": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeTableStats"
|
||||
},
|
||||
"description": "metadata table statistics"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3385,7 +3685,8 @@
|
||||
],
|
||||
"properties": {
|
||||
"dbEngine": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "database engine used for metadata"
|
||||
},
|
||||
"garageFeatures": {
|
||||
"type": [
|
||||
@@ -3394,16 +3695,26 @@
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "build-time features enabled for this garage release"
|
||||
},
|
||||
"garageVersion": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "garage version running on this node"
|
||||
},
|
||||
"hostname": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "hostname of this node"
|
||||
},
|
||||
"nodeId": {
|
||||
"type": "string"
|
||||
},
|
||||
"rustVersion": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "rustc version with which this garage release was compiled"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3439,8 +3750,30 @@
|
||||
"freeform"
|
||||
],
|
||||
"properties": {
|
||||
"blockManagerStats": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/NodeBlockManagerStats",
|
||||
"description": "block manager statistics"
|
||||
}
|
||||
]
|
||||
},
|
||||
"freeform": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "node statistics as a free-form string, kept for compatibility with nodes\nrunning older v2.x versions of garage"
|
||||
},
|
||||
"tableStats": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NodeTableStats"
|
||||
},
|
||||
"description": "metadata table statistics"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3779,6 +4112,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeBlockManagerStats": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"rcEntries",
|
||||
"resyncQueueLen",
|
||||
"resyncErrors"
|
||||
],
|
||||
"properties": {
|
||||
"rcEntries": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "number of reference counter entries",
|
||||
"minimum": 0
|
||||
},
|
||||
"resyncErrors": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "number of blocks with resync errors",
|
||||
"minimum": 0
|
||||
},
|
||||
"resyncQueueLen": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "number of blocks in the resync queue",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeResp": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -3942,6 +4303,53 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"NodeTableStats": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"tableName",
|
||||
"items",
|
||||
"merkleItems",
|
||||
"merkleQueueLen",
|
||||
"insertQueueLen",
|
||||
"gcQueueLen"
|
||||
],
|
||||
"properties": {
|
||||
"gcQueueLen": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "number of items in the garbage collection queue",
|
||||
"minimum": 0
|
||||
},
|
||||
"insertQueueLen": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "number of items in the remote insert queue",
|
||||
"minimum": 0
|
||||
},
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "number of items stored in metadata table",
|
||||
"minimum": 0
|
||||
},
|
||||
"merkleItems": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "size of the merkle tree representing all items in the table",
|
||||
"minimum": 0
|
||||
},
|
||||
"merkleQueueLen": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"description": "number of items in the merkle tree update queue",
|
||||
"minimum": 0
|
||||
},
|
||||
"tableName": {
|
||||
"type": "string",
|
||||
"description": "name of metadata table"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NodeUpdateTrackers": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -3998,6 +4406,17 @@
|
||||
"newLayout": {
|
||||
"$ref": "#/components/schemas/GetClusterLayoutResponse",
|
||||
"description": "Details about the new cluster layout"
|
||||
},
|
||||
"statistics": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/ComputationStat",
|
||||
"description": "Structured statistics about the layout computation"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4117,7 +4536,7 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Scope of the admin API token, a list of admin endpoint names (such as\n`GetClusterStatus`, etc), or the special value `*` to allow all\nadmin endpoints. **WARNING:** Granting a scope of `CreateAdminToken` or\n`UpdateAdminToken` trivially allows for privilege escalation, and is thus\nfunctionnally equivalent to granting a scope of `*`."
|
||||
"description": "Scope of the admin API token, a list of admin endpoint names (such as\n`GetClusterStatus`, etc), or the special value `*` to allow all\nadmin endpoints. **WARNING:** Granting a scope of `CreateAdminToken` or\n`UpdateAdminToken` trivially allows for privilege escalation, and is thus\nfunctionally equivalent to granting a scope of `*`."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4127,6 +4546,24 @@
|
||||
"UpdateBucketRequestBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"corsRules": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/cors.Rule"
|
||||
}
|
||||
},
|
||||
"lifecycleRules": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/lifecycle.Rule"
|
||||
}
|
||||
},
|
||||
"quotas": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -4172,6 +4609,15 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"routingRules": {
|
||||
"type": [
|
||||
"array",
|
||||
"null"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/website.RoutingRule"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4413,6 +4859,205 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"cors.Rule": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"AllowedOrigin",
|
||||
"AllowedMethod"
|
||||
],
|
||||
"properties": {
|
||||
"AllowedHeader": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"AllowedMethod": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"AllowedOrigin": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"ExposeHeader": {
|
||||
"type": "array",
|
||||
"items": {}
|
||||
},
|
||||
"ID": {},
|
||||
"MaxAgeSeconds": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/xml.IntValue"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"lifecycle.AbortIncompleteMpu": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"DaysAfterInitiation"
|
||||
],
|
||||
"properties": {
|
||||
"DaysAfterInitiation": {
|
||||
"$ref": "#/components/schemas/xml.IntValue"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lifecycle.Expiration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Date": {},
|
||||
"Days": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/xml.IntValue"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"lifecycle.Filter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"And": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/lifecycle.Filter"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ObjectSizeGreaterThan": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/xml.IntValue"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ObjectSizeLessThan": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/xml.IntValue"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Prefix": {}
|
||||
}
|
||||
},
|
||||
"lifecycle.Rule": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"Status"
|
||||
],
|
||||
"properties": {
|
||||
"AbortIncompleteMultipartUpload": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/lifecycle.AbortIncompleteMpu"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Expiration": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/lifecycle.Expiration"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Filter": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/lifecycle.Filter"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ID": {},
|
||||
"Status": {}
|
||||
}
|
||||
},
|
||||
"website.Condition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"HttpErrorCodeReturnedEquals": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/xml.IntValue"
|
||||
}
|
||||
]
|
||||
},
|
||||
"KeyPrefixEquals": {}
|
||||
}
|
||||
},
|
||||
"website.Redirect": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"HostName": {},
|
||||
"HttpRedirectCode": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/xml.IntValue"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Protocol": {},
|
||||
"ReplaceKeyPrefixWith": {},
|
||||
"ReplaceKeyWith": {}
|
||||
}
|
||||
},
|
||||
"website.RoutingRule": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"Redirect"
|
||||
],
|
||||
"properties": {
|
||||
"Condition": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/website.Condition"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Redirect": {
|
||||
"$ref": "#/components/schemas/website.Redirect"
|
||||
}
|
||||
}
|
||||
},
|
||||
"xml.IntValue": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
|
||||
@@ -96,14 +96,14 @@ to store 2 TB of data in total.
|
||||
## Get a Docker image
|
||||
|
||||
Our docker image is currently named `dxflrs/garage` and is stored on the [Docker Hub](https://hub.docker.com/r/dxflrs/garage/tags?page=1&ordering=last_updated).
|
||||
We encourage you to use a fixed tag (eg. `v2.2.0`) and not the `latest` tag.
|
||||
For this example, we will use the latest published version at the time of the writing which is `v2.2.0` but it's up to you
|
||||
We encourage you to use a fixed tag (eg. `v2.3.0`) and not the `latest` tag.
|
||||
For this example, we will use the latest published version at the time of the writing which is `v2.3.0` but it's up to you
|
||||
to check [the most recent versions on the Docker Hub](https://hub.docker.com/r/dxflrs/garage/tags?page=1&ordering=last_updated).
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
sudo docker pull dxflrs/garage:v2.2.0
|
||||
docker pull dxflrs/garage:v2.3.0
|
||||
```
|
||||
|
||||
## Deploying and configuring Garage
|
||||
@@ -171,7 +171,7 @@ docker run \
|
||||
-v /etc/garage.toml:/etc/garage.toml \
|
||||
-v /var/lib/garage/meta:/var/lib/garage/meta \
|
||||
-v /var/lib/garage/data:/var/lib/garage/data \
|
||||
dxflrs/garage:v2.2.0
|
||||
dxflrs/garage:v2.3.0
|
||||
```
|
||||
|
||||
With this command line, Garage should be started automatically at each boot.
|
||||
@@ -185,7 +185,7 @@ If you want to use `docker-compose`, you may use the following `docker-compose.y
|
||||
version: "3"
|
||||
services:
|
||||
garage:
|
||||
image: dxflrs/garage:v2.2.0
|
||||
image: dxflrs/garage:v2.3.0
|
||||
network_mode: "host"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
@@ -213,7 +213,12 @@ If your configuration file is at `/etc/garage.toml`, the `garage` binary should
|
||||
You can also use an alias as follows to use the Garage binary inside your docker container:
|
||||
|
||||
```bash
|
||||
# garage 3.x, we have an entrypoint and you can use
|
||||
alias garage="docker exec -ti <container name>"
|
||||
|
||||
# For garage 2.x, you need to specify the absolute path to binary
|
||||
alias garage="docker exec -ti <container name> /garage"
|
||||
|
||||
```
|
||||
|
||||
You can test your `garage` CLI utility by running a simple command such as:
|
||||
|
||||
@@ -142,7 +142,74 @@ server {
|
||||
|
||||
## Apache httpd
|
||||
|
||||
@TODO
|
||||
The [Apache HTTP Server](https://httpd.apache.org/)
|
||||
is a general purpose web server that includes
|
||||
[reverse proxy](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html)
|
||||
capabilities.
|
||||
|
||||
### Exposing the S3 endpoints
|
||||
|
||||
Create a new [virtual host](https://httpd.apache.org/docs/2.4/vhosts/),
|
||||
obtain a certificate using
|
||||
[certbot](https://eff-certbot.readthedocs.io/en/stable/using.html#apache),
|
||||
and add the
|
||||
[`ProxyPass`](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#proxypass)
|
||||
and
|
||||
[`ProxyPreserveHost`](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#proxypreservehost)
|
||||
options:
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName garage.example.com
|
||||
|
||||
SSLCertificateFile /etc/letsencrypt/live/garage.example.com/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/garage.example.com/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
Header always set Strict-Transport-Security "max-age=31536000"
|
||||
Header always add Content-Security-Policy upgrade-insecure-requests
|
||||
|
||||
ProxyPass "/" "http://localhost:3900/" nocanon
|
||||
ProxyPreserveHost on
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
The `nocanon` keyword is important for
|
||||
[presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html);
|
||||
otherwise,
|
||||
|
||||
> `mod_proxy` will canonicalise ProxyPassed URLs.
|
||||
> But this may be incompatible with some backends,
|
||||
> particularly those that make use of `PATH_INFO`.
|
||||
> The optional `nocanon` keyword suppresses this
|
||||
> and passes the URL path "raw" to the backend.
|
||||
|
||||
### Exposing the web endpoint
|
||||
|
||||
Adding static websites backed by Garage works very similarly,
|
||||
with the only difference being the port selected in the `ProxyPass` directive.
|
||||
|
||||
```apache
|
||||
ProxyPass "/" "http://localhost:3902/" nocanon
|
||||
```
|
||||
|
||||
### Using Unix sockets
|
||||
|
||||
Apache can also proxy via Unix sockets instead of TCP ports,
|
||||
if Garage is so configured.
|
||||
|
||||
`garage.toml`:
|
||||
|
||||
```toml
|
||||
[s3_api]
|
||||
api_bind_addr = "/run/garage/s3_api.socket"
|
||||
```
|
||||
|
||||
Apache config:
|
||||
|
||||
```apache
|
||||
ProxyPass "/" "unix:/run/garage/s3_api.socket|http://localhost/" nocanon
|
||||
```
|
||||
|
||||
## Traefik v2
|
||||
|
||||
|
||||
@@ -82,12 +82,6 @@ nix-build \
|
||||
|
||||
*The result is located in `result/bin`. You can pass arguments to cross compile: check `.woodpecker/release.yml` for examples.*
|
||||
|
||||
If you modify a `Cargo.toml` or regenerate any `Cargo.lock`, you must run `cargo2nix`:
|
||||
|
||||
```
|
||||
cargo2nix -f
|
||||
```
|
||||
|
||||
Many tools like rclone, `mc` (minio-client), or `aws` (awscliv2) will be available in your environment and will be useful to test Garage.
|
||||
|
||||
**This is the recommended method.**
|
||||
@@ -124,23 +118,6 @@ cargo fmt # format the project, run it before any commit!
|
||||
cargo clippy # run the linter, run it before any commit!
|
||||
```
|
||||
|
||||
This is specific to our project, but you will need one last tool, `cargo2nix`.
|
||||
To install it, run:
|
||||
|
||||
```bash
|
||||
cargo install --git https://github.com/superboum/cargo2nix --branch main cargo2nix
|
||||
```
|
||||
|
||||
You must use it every time you modify a `Cargo.toml` or regenerate a `Cargo.lock` file as follow:
|
||||
|
||||
```bash
|
||||
cargo build # Rebuild Cargo.lock if needed
|
||||
cargo2nix -f
|
||||
```
|
||||
|
||||
It will output a `Cargo.nix` file which is a specific `Cargo.lock` file dedicated to Nix that is required by our CI
|
||||
which means you must include it in your commits.
|
||||
|
||||
Later, to use our scripts and integration tests, you might need additional tools.
|
||||
These tools are listed at the end of the `shell.nix` package in the `nativeBuildInputs` part.
|
||||
It is up to you to find a way to install the ones you need on your computer.
|
||||
|
||||
@@ -3,15 +3,6 @@ title = "Miscellaneous notes"
|
||||
weight = 20
|
||||
+++
|
||||
|
||||
## Quirks about cargo2nix/rust in Nix
|
||||
|
||||
If you use submodules in your crate (like `crdt` and `replication` in `garage_table`), you must list them in `default.nix`
|
||||
|
||||
The Windows target does not work. it might be solvable through [overrides](https://github.com/cargo2nix/cargo2nix/blob/master/overlay/overrides.nix). Indeed, we pass `x86_64-pc-windows-gnu` but mingw need `x86_64-w64-mingw32`
|
||||
|
||||
We have a simple [PR on cargo2nix](https://github.com/cargo2nix/cargo2nix/pull/201) that fixes critical bugs but the project does not seem very active currently. We must use [my patched version of cargo2nix](https://github.com/superboum/cargo2nix) to enable i686 and armv6l compilation. We might need to contribute to cargo2nix in the future.
|
||||
|
||||
|
||||
## Nix
|
||||
|
||||
Nix has no armv7 + musl toolchains but armv7l is backward compatible with armv6l.
|
||||
|
||||
@@ -43,12 +43,10 @@ or if you want a build customized for your system,
|
||||
you can [build Garage from source](@/documentation/cookbook/from-source.md).
|
||||
|
||||
If none of these option work for you, you can also run Garage in a Docker
|
||||
container. When using Docker, the commands used in this guide will not work
|
||||
anymore. We recommend reading the tutorial on [configuring a
|
||||
multi-node cluster](@/documentation/cookbook/real-world.md) to learn about
|
||||
using Garage as a Docker container. For simplicity, a minimal command to launch
|
||||
Garage using Docker is provided in this quick start guide as well.
|
||||
|
||||
container. For simplicity, a minimal command to launch Garage using Docker is
|
||||
provided in this quick start guide. We recommend reading the tutorial on
|
||||
[configuring a multi-node cluster](@/documentation/cookbook/real-world.md) to
|
||||
learn about the full Docker workflow for Garage.
|
||||
|
||||
## Configuring and starting Garage
|
||||
|
||||
@@ -82,9 +80,6 @@ bind_addr = "[::]:3902"
|
||||
root_domain = ".web.garage.localhost"
|
||||
index = "index.html"
|
||||
|
||||
[k2v_api]
|
||||
api_bind_addr = "[::]:3904"
|
||||
|
||||
[admin]
|
||||
api_bind_addr = "[::]:3903"
|
||||
admin_token = "$(openssl rand -base64 32)"
|
||||
@@ -95,10 +90,13 @@ EOF
|
||||
See the [Configuration file format](https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/)
|
||||
for complete options and values.
|
||||
|
||||
Now that your configuration file has been created, you may save it to the directory of your choice.
|
||||
By default, Garage looks for **`/etc/garage.toml`.**
|
||||
You can also store it somewhere else, but you will have to specify `-c path/to/garage.toml`
|
||||
at each invocation of the `garage` binary (for example: `garage -c ./garage.toml server`, `garage -c ./garage.toml status`).
|
||||
By default, Garage looks for its configuration file in **`/etc/garage.toml`.**
|
||||
Since we have written our configuration file in the working directory, we will have to set
|
||||
the following environment variable:
|
||||
|
||||
```bash
|
||||
export GARAGE_CONFIG_FILE=$(pwd)/garage.toml
|
||||
```
|
||||
|
||||
As you can see, the `rpc_secret` is a 32 bytes hexadecimal string.
|
||||
You can regenerate it with `openssl rand -hex 32`.
|
||||
@@ -111,15 +109,36 @@ Garage server will not be persistent. Change these to locations on your local di
|
||||
your data to be persisted properly.
|
||||
|
||||
|
||||
### Configuring initial access credentials
|
||||
|
||||
Since `v2.3.0`, Garage can automatically create a default access key and a default storage bucket,
|
||||
based on values provided in environment variables.
|
||||
|
||||
To use this feature, export the following environment variables:
|
||||
|
||||
```bash
|
||||
export GARAGE_DEFAULT_ACCESS_KEY="GK$(openssl rand -hex 16)"
|
||||
export GARAGE_DEFAULT_SECRET_KEY="$(openssl rand -hex 32)"
|
||||
export GARAGE_DEFAULT_BUCKET="default-bucket"
|
||||
```
|
||||
|
||||
The example above creates a random access key ID and associated secret key.
|
||||
You can also provide an access key ID and secret key of your own.
|
||||
|
||||
### Launching the Garage server
|
||||
|
||||
Use the following command to launch the Garage server:
|
||||
|
||||
```
|
||||
garage -c path/to/garage.toml server
|
||||
```bash
|
||||
garage server --single-node --default-bucket
|
||||
```
|
||||
|
||||
If you have placed the `garage.toml` file in `/etc` (its default location), you can simply run `garage server`.
|
||||
The `--single-node` flag instructs Garage to automatically configure a single-node cluster without data replication.
|
||||
The `--default-bucket` flag instructs Garage to create a default access key and a default bucket using the environment variables we defined above.
|
||||
Both flags are optional and can be omitted, in which case you will have to follow manual configuration steps described below.
|
||||
|
||||
**For older versions of Garage (before v2.3.0):** automatic configuration using `--single-node` and `--default-bucket` is not available,
|
||||
you must follow the manual configuration steps.
|
||||
|
||||
Alternatively, if you cannot or do not wish to run the Garage binary directly,
|
||||
you may use Docker to run Garage in a container using the following command:
|
||||
@@ -127,21 +146,61 @@ you may use Docker to run Garage in a container using the following command:
|
||||
```bash
|
||||
docker run \
|
||||
-d \
|
||||
--name garaged \
|
||||
--name garage-container \
|
||||
-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903 \
|
||||
-v /path/to/garage.toml:/etc/garage.toml \
|
||||
-v /path/to/garage/meta:/var/lib/garage/meta \
|
||||
-v /path/to/garage/data:/var/lib/garage/data \
|
||||
dxflrs/garage:v2.2.0
|
||||
-v $(pwd)/garage.toml:/etc/garage.toml \
|
||||
-e GARAGE_DEFAULT_ACCESS_KEY \
|
||||
-e GARAGE_DEFAULT_SECRET_KEY \
|
||||
-e GARAGE_DEFAULT_BUCKET \
|
||||
dxflrs/garage:v2.3.0
|
||||
/garage server --single-node --default-bucket
|
||||
```
|
||||
|
||||
Under Linux, you can substitute `--network host` for `-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903`
|
||||
Note that this command will NOT create persistent volumes for Garage's data, so
|
||||
your cluster will be wiped if the container terminates. To persist Garage's
|
||||
data, you must manually add volumes for the `data` and `metadata` directories
|
||||
and configure their correct paths in your `garage.toml` files (see [configuring
|
||||
a multi-node cluster](@/documentation/cookbook/real-world.md)).
|
||||
|
||||
#### Troubleshooting
|
||||
Under Linux, you can substitute `--network host` for `-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903`.
|
||||
|
||||
### Checking that Garage runs correctly
|
||||
|
||||
The `garage` utility is also used as a CLI tool to administrate your Garage
|
||||
deployment. It needs read access to your configuration file and to the metadata directory
|
||||
to obtain connection parameters to contact the local Garage node.
|
||||
|
||||
Use the following command to show the status of your cluster:
|
||||
|
||||
```
|
||||
garage status
|
||||
```
|
||||
|
||||
If you are running Garage in a Docker container, you can use the following command instead:
|
||||
|
||||
NOTE: Garage 3.x uses docker `ENTRYPOINT` and it's easier to use,
|
||||
while garage 2.x does not and you need to specify path `/garage`
|
||||
|
||||
```bash
|
||||
docker exec garage-container status
|
||||
```
|
||||
|
||||
This should show something like this:
|
||||
|
||||
```
|
||||
==== HEALTHY NODES ====
|
||||
ID Hostname Address Tags Zone Capacity DataAvail Version
|
||||
563e1ac825ee3323 linuxbox 127.0.0.1:3901 [default] dc1 19.9 GiB 19.5 GiB (97.6%) v2.3.0
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Ensure your configuration file, `metadata_dir` and `data_dir` are readable by the user running the `garage` server or Docker.
|
||||
|
||||
You can tune Garage's verbosity by setting the `RUST_LOG=` environment variable. \
|
||||
When running the `garage` CLI, ensure that the path to your configuration file is correctly specified (see below),
|
||||
and that it can read it and read from your metadata directory.
|
||||
|
||||
You can tune Garage's verbosity by setting the `RUST_LOG=` environment variable.
|
||||
Available log levels are (from less verbose to more verbose): `error`, `warn`, `info` *(default)*, `debug` and `trace`.
|
||||
|
||||
```bash
|
||||
@@ -154,36 +213,135 @@ Log level `info` is the default value and is recommended for most use cases.
|
||||
Log level `debug` can help you check why your S3 API calls are not working.
|
||||
|
||||
|
||||
### Checking that Garage runs correctly
|
||||
|
||||
The `garage` utility is also used as a CLI tool to configure your Garage deployment.
|
||||
It uses values from the TOML configuration file to find the Garage daemon running on the
|
||||
local node, therefore if your configuration file is not at `/etc/garage.toml` you will
|
||||
again have to specify `-c path/to/garage.toml` at each invocation.
|
||||
## Uploading and downloading from Garage
|
||||
|
||||
If you are running Garage in a Docker container, you can set `alias garage="docker exec -ti <container name> /garage"`
|
||||
to use the Garage binary inside your container.
|
||||
This section will show how to download and upload files on Garage using a third-party tool named `awscli`.
|
||||
|
||||
If the `garage` CLI is able to correctly detect the parameters of your local Garage node,
|
||||
the following command should be enough to show the status of your cluster:
|
||||
|
||||
```
|
||||
garage status
|
||||
### Install and configure `awscli`
|
||||
|
||||
If you have python on your system, you can install it with:
|
||||
|
||||
```bash
|
||||
python -m pip install --user awscli
|
||||
```
|
||||
|
||||
This should show something like this:
|
||||
Now that `awscli` is installed, you must configure it to talk to your Garage
|
||||
instance using the credentials defined above. Here is a simple way to create
|
||||
a configuration file in `~/.awsrc` using a single command that will save the
|
||||
secrets from your environment:
|
||||
|
||||
```bash
|
||||
cat > ~/.awsrc <<EOF
|
||||
export AWS_ENDPOINT_URL='http://localhost:3900'
|
||||
export AWS_DEFAULT_REGION='garage'
|
||||
export AWS_ACCESS_KEY_ID='$GARAGE_DEFAULT_ACCESS_KEY'
|
||||
export AWS_SECRET_ACCESS_KEY='$GARAGE_DEFAULT_SECRET_KEY'
|
||||
|
||||
aws --version
|
||||
EOF
|
||||
|
||||
```
|
||||
|
||||
Note that you need to have at least `awscli` `>=1.29.0` or `>=2.13.0`, otherwise you
|
||||
need to specify `--endpoint-url` explicitly on each `awscli` invocation.
|
||||
|
||||
Now, each time you want to use `awscli` on this target, run:
|
||||
|
||||
```bash
|
||||
source ~/.awsrc
|
||||
```
|
||||
|
||||
*You can create multiple files with different names if you
|
||||
have multiple Garage clusters or different keys.
|
||||
Switching from one cluster to another is as simple as
|
||||
sourcing the right file.*
|
||||
|
||||
### Example usage of `awscli`
|
||||
|
||||
```bash
|
||||
# list buckets
|
||||
aws s3 ls
|
||||
|
||||
# list objects of a bucket
|
||||
aws s3 ls s3://default-bucket
|
||||
|
||||
# copy from your filesystem to garage
|
||||
aws s3 cp /proc/cpuinfo s3://default-bucket/cpuinfo.txt
|
||||
|
||||
# copy from garage to your filesystem
|
||||
aws s3 cp s3://default-bucket/cpuinfo.txt /tmp/cpuinfo.txt
|
||||
```
|
||||
|
||||
Note that you can use `awscli` for more advanced operations like
|
||||
creating a bucket, pre-signing a request or managing your website.
|
||||
[Read the full documentation to know more](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/s3/index.html).
|
||||
|
||||
Some features are however not implemented like ACL or policy.
|
||||
Check [our S3 compatibility list](@/documentation/reference-manual/s3-compatibility.md).
|
||||
|
||||
### Other tools for interacting with Garage
|
||||
|
||||
The following tools can also be used to send and receive files from/to Garage:
|
||||
|
||||
- [minio-client](@/documentation/connect/cli.md#minio-client)
|
||||
- [s3cmd](@/documentation/connect/cli.md#s3cmd)
|
||||
- [rclone](@/documentation/connect/cli.md#rclone)
|
||||
- [Cyberduck](@/documentation/connect/cli.md#cyberduck)
|
||||
- [WinSCP](@/documentation/connect/cli.md#winscp)
|
||||
|
||||
An exhaustive list is maintained in the ["Integrations" > "Browsing tools" section](@/documentation/connect/_index.md).
|
||||
|
||||
|
||||
|
||||
## Manual configuration
|
||||
|
||||
This section provides instructions that are equivalent to using the
|
||||
`--single-node` and `--default-bucket` flags for automatic configuration. If
|
||||
you are using an older version of Garage (before v2.3.0), you must follow
|
||||
these instructions as automatic configuration is not available.
|
||||
|
||||
We will have to run quite a few `garage` administration commands to get started.
|
||||
If you ever get lost, don't forget that the `help` command and the `--help` flags can help you anywhere,
|
||||
the CLI tool is self-documented! Two examples:
|
||||
|
||||
```
|
||||
garage help
|
||||
garage bucket allow --help
|
||||
```
|
||||
|
||||
### Configuring the `garage` CLI
|
||||
|
||||
Remember that the `garage` CLI needs to know the path of your `garage.toml` configuration file.
|
||||
If it is not in the default location of `/etc/garage.toml`, you can specify it either:
|
||||
|
||||
- by setting the `GARAGE_CONFIG_FILE` environment variable;
|
||||
- by adding the `-c` flag to each `garage` command, for example: `garage -c ./garage.toml status`.
|
||||
|
||||
If you are running Garage in a Docker container, you can set the following alias
|
||||
to provide a fake `garage`command that uses the Garage binary inside your container:
|
||||
|
||||
```bash
|
||||
alias garage="docker exec -ti <container name>"
|
||||
```
|
||||
|
||||
You can test that your `garage` CLI is configured correctly by running a basic command such as `garage status`.
|
||||
|
||||
### Creating a cluster layout
|
||||
|
||||
When you first start a cluster without automatic configuration, the output of `garage status` will look as follows:
|
||||
|
||||
```
|
||||
==== HEALTHY NODES ====
|
||||
ID Hostname Address Tag Zone Capacity
|
||||
563e1ac825ee3323 linuxbox 127.0.0.1:3901 NO ROLE ASSIGNED
|
||||
ID Hostname Address Tags Zone Capacity DataAvail Version
|
||||
563e1ac825ee3323 linuxbox 127.0.0.1:3901 NO ROLE ASSIGNED v2.3.0
|
||||
```
|
||||
|
||||
## Creating a cluster layout
|
||||
|
||||
Creating a cluster layout for a Garage deployment means informing Garage
|
||||
of the disk space available on each node of the cluster, `-c`,
|
||||
as well as the name of the zone (e.g. datacenter), `-z`, each machine is located in.
|
||||
Creating a cluster layout for a Garage deployment means informing Garage of the
|
||||
disk space available on each node of the cluster using the `-c` flag, as well
|
||||
as the name of the zone (e.g. datacenter) each machine is located in using the
|
||||
`-z` flag.
|
||||
|
||||
For our test deployment, we are have only one node with zone named `dc1` and a
|
||||
capacity of `1G`, though the capacity is ignored for a single node deployment
|
||||
@@ -204,38 +362,29 @@ garage layout apply --version 1
|
||||
```
|
||||
|
||||
|
||||
## Creating buckets and keys
|
||||
|
||||
In this section, we will suppose that we want to create a bucket named `nextcloud-bucket`
|
||||
that will be accessed through a key named `nextcloud-app-key`.
|
||||
|
||||
Don't forget that `help` command and `--help` subcommands can help you anywhere,
|
||||
the CLI tool is self-documented! Two examples:
|
||||
|
||||
```
|
||||
garage help
|
||||
garage bucket allow --help
|
||||
```
|
||||
|
||||
### Create a bucket
|
||||
### Creating buckets and keys
|
||||
|
||||
Let's take an example where we want to deploy NextCloud using Garage as the
|
||||
main data storage.
|
||||
main data storage. We will suppose that we want to create a bucket named
|
||||
`nextcloud-bucket` that will be accessed through a key named
|
||||
`nextcloud-app-key`.
|
||||
|
||||
First, create a bucket with the following command:
|
||||
#### Create a bucket
|
||||
|
||||
First, create the bucket with the following command:
|
||||
|
||||
```
|
||||
garage bucket create nextcloud-bucket
|
||||
```
|
||||
|
||||
Check that everything went well:
|
||||
Check that the bucket was created properly:
|
||||
|
||||
```
|
||||
garage bucket list
|
||||
garage bucket info nextcloud-bucket
|
||||
```
|
||||
|
||||
### Create an API key
|
||||
#### Create an API key
|
||||
|
||||
The `nextcloud-bucket` bucket now exists on the Garage server,
|
||||
however it cannot be accessed until we add an API key with the proper access rights.
|
||||
@@ -258,14 +407,14 @@ Secret key: 7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34
|
||||
Authorized buckets:
|
||||
```
|
||||
|
||||
Check that everything works as intended:
|
||||
Check that the key was created properly:
|
||||
|
||||
```
|
||||
garage key list
|
||||
garage key info nextcloud-app-key
|
||||
```
|
||||
|
||||
### Allow a key to access a bucket
|
||||
#### Allow a key to access a bucket
|
||||
|
||||
Now that we have a bucket and a key, we need to give permissions to the key on the bucket:
|
||||
|
||||
@@ -284,78 +433,5 @@ You can check at any time the allowed keys on your bucket with:
|
||||
garage bucket info nextcloud-bucket
|
||||
```
|
||||
|
||||
|
||||
## Uploading and downloading from Garage
|
||||
|
||||
To download and upload files on garage, we can use a third-party tool named `awscli`.
|
||||
|
||||
|
||||
### Install and configure `awscli`
|
||||
|
||||
If you have python on your system, you can install it with:
|
||||
|
||||
```bash
|
||||
python -m pip install --user awscli
|
||||
```
|
||||
|
||||
Now that `awscli` is installed, you must configure it to talk to your Garage instance,
|
||||
with your key. There are multiple ways to do that, the simplest one is to create a file
|
||||
named `~/.awsrc` with this content:
|
||||
|
||||
```bash
|
||||
export AWS_ACCESS_KEY_ID=xxxx # put your Key ID here
|
||||
export AWS_SECRET_ACCESS_KEY=xxxx # put your Secret key here
|
||||
export AWS_DEFAULT_REGION='garage'
|
||||
export AWS_ENDPOINT_URL='http://localhost:3900'
|
||||
|
||||
aws --version
|
||||
```
|
||||
|
||||
Note you need to have at least `awscli` `>=1.29.0` or `>=2.13.0`, otherwise you
|
||||
need to specify `--endpoint-url` explicitly on each `awscli` invocation.
|
||||
|
||||
Now, each time you want to use `awscli` on this target, run:
|
||||
|
||||
```bash
|
||||
source ~/.awsrc
|
||||
```
|
||||
|
||||
*You can create multiple files with different names if you
|
||||
have multiple Garage clusters or different keys.
|
||||
Switching from one cluster to another is as simple as
|
||||
sourcing the right file.*
|
||||
|
||||
### Example usage of `awscli`
|
||||
|
||||
```bash
|
||||
# list buckets
|
||||
aws s3 ls
|
||||
|
||||
# list objects of a bucket
|
||||
aws s3 ls s3://nextcloud-bucket
|
||||
|
||||
# copy from your filesystem to garage
|
||||
aws s3 cp /proc/cpuinfo s3://nextcloud-bucket/cpuinfo.txt
|
||||
|
||||
# copy from garage to your filesystem
|
||||
aws s3 cp s3://nextcloud-bucket/cpuinfo.txt /tmp/cpuinfo.txt
|
||||
```
|
||||
|
||||
Note that you can use `awscli` for more advanced operations like
|
||||
creating a bucket, pre-signing a request or managing your website.
|
||||
[Read the full documentation to know more](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/s3/index.html).
|
||||
|
||||
Some features are however not implemented like ACL or policy.
|
||||
Check [our s3 compatibility list](@/documentation/reference-manual/s3-compatibility.md).
|
||||
|
||||
### Other tools for interacting with Garage
|
||||
|
||||
The following tools can also be used to send and receive files from/to Garage:
|
||||
|
||||
- [minio-client](@/documentation/connect/cli.md#minio-client)
|
||||
- [s3cmd](@/documentation/connect/cli.md#s3cmd)
|
||||
- [rclone](@/documentation/connect/cli.md#rclone)
|
||||
- [Cyberduck](@/documentation/connect/cli.md#cyberduck)
|
||||
- [WinSCP](@/documentation/connect/cli.md#winscp)
|
||||
|
||||
An exhaustive list is maintained in the ["Integrations" > "Browsing tools" section](@/documentation/connect/_index.md).
|
||||
You should now be able to read and write objects to the bucket using the
|
||||
credentials created above.
|
||||
|
||||
@@ -182,15 +182,15 @@ content-type: text/plain; version=0.0.4
|
||||
content-length: 12145
|
||||
date: Tue, 08 Aug 2023 07:25:05 GMT
|
||||
|
||||
# HELP api_admin_error_counter Number of API calls to the various Admin API endpoints that resulted in errors
|
||||
# TYPE api_admin_error_counter counter
|
||||
api_admin_error_counter{api_endpoint="CheckWebsiteEnabled",status_code="400"} 1
|
||||
api_admin_error_counter{api_endpoint="CheckWebsiteEnabled",status_code="404"} 3
|
||||
# HELP api_admin_request_counter Number of API calls to the various Admin API endpoints
|
||||
# TYPE api_admin_request_counter counter
|
||||
api_admin_request_counter{api_endpoint="CheckWebsiteEnabled"} 7
|
||||
api_admin_request_counter{api_endpoint="Health"} 3
|
||||
# HELP api_admin_request_duration Duration of API calls to the various Admin API endpoints
|
||||
# HELP garage_api_admin_error_count Number of API calls to the various Admin API endpoints that resulted in errors
|
||||
# TYPE garage_api_admin_error_count counter
|
||||
garage_api_admin_error_count{api_endpoint="CheckWebsiteEnabled",status_code="400"} 1
|
||||
garage_api_admin_error_count{api_endpoint="CheckWebsiteEnabled",status_code="404"} 3
|
||||
# HELP garage_api_admin_request_count Number of API calls to the various Admin API endpoints
|
||||
# TYPE garage_api_admin_request_count counter
|
||||
garage_api_admin_request_count{api_endpoint="CheckWebsiteEnabled"} 7
|
||||
garage_api_admin_request_count{api_endpoint="Health"} 3
|
||||
# HELP garage_api_admin_request_duration Duration of API calls to the various Admin API endpoints
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@@ -56,10 +56,11 @@ tls_skip_verify = false
|
||||
service_name = "garage-daemon"
|
||||
|
||||
ca_cert = "/etc/consul/consul-ca.crt"
|
||||
# for `agent` API mode, unset client_cert and client_key:
|
||||
client_cert = "/etc/consul/consul-client.crt"
|
||||
client_key = "/etc/consul/consul-key.crt"
|
||||
|
||||
# for `agent` API mode, unset client_cert and client_key, and optionally enable `token`
|
||||
# optionally enable `token` for authentication:
|
||||
# token = "abcdef-01234-56789"
|
||||
|
||||
tags = [ "dns-enabled" ]
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
+++
|
||||
title = "Known issues"
|
||||
weight = 80
|
||||
+++
|
||||
|
||||
Issues in each section are roughly sorted by order of decreasing impact, based on actual reports from users.
|
||||
|
||||
## Architectural limitations
|
||||
|
||||
Issues that are caused by design decisions of Garage internals, and that can't
|
||||
be fixed without major architectural changes in the codebase.
|
||||
|
||||
### Metadata performance issues with many objects
|
||||
|
||||
**Related issues:**
|
||||
|
||||
- [#851 - Performances collapse with 10 millions pictures in a bucket](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/851)
|
||||
- [#1222 - Cluster Setup Write Performance Degraded After Writing 10 Million Object (200-300Kb per object)](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/1222)
|
||||
|
||||
### Very big objects cause performance degradation
|
||||
|
||||
For each object, there is a single metadata entry called a `Version` that
|
||||
contains a list of all of the data blocks in the object. For very big objects,
|
||||
this entry can contain thousands of block references. During the uploading of
|
||||
an object, this metadata entry needs to be read, deserialized, reserialized and
|
||||
written for each individual data block uploaded. This means that the
|
||||
complexity of an upload is `O(n²)` in the number of blocks needed.
|
||||
|
||||
This manifests by excessive metadata I/O and CPU usage, and uploads eventually stalling.
|
||||
|
||||
**Mitigation:** Increase the `block_size` configuration parameter to reduce the
|
||||
number of blocks. Make sure multipart uploads use chunks that are at least
|
||||
`block_size` in size, and that are an exact multiple of `block_size` to avoid
|
||||
the creation of smaller blocks.
|
||||
|
||||
**Long-term solution:** An architectural change in the metadata system would be
|
||||
required to store block lists in many independent metadata entries instead of
|
||||
one single big entry per object.
|
||||
|
||||
**Related issues:**
|
||||
|
||||
- [#662 - Large Files fail to upload](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/662)
|
||||
- [#1366 - High CPU usage and performance degradation during long multipart uploads](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/1366)
|
||||
|
||||
### No conditional writes / locking / WORM support (`if-none-match`, ...)
|
||||
|
||||
This is structurally impossible to implement in Garage due to the lack of a consensus algorithm,
|
||||
which is one of Garage's core design choices which we cannot reconsider.
|
||||
|
||||
A semi-working, *unsafe* implementation of WORM and object locking could be
|
||||
implemented, with the following constraint: only after the completion of the
|
||||
first write (in case of WORM) or the setting of a lock (for object lock) can we
|
||||
guarantee that the object cannot be overwritten. In case where an overwrite
|
||||
requests arrives at the same time as the initial request to write or to lock
|
||||
the object, we cannot implement a safe and consistent way to reject it. This
|
||||
means that many practical use-cases for `if-none-match` cannot be supported
|
||||
(e.g. using it to implement mutual exclusion between concurrent writers).
|
||||
|
||||
**Related issues:**
|
||||
|
||||
- [#1052 - Support conditional writes](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/1052)
|
||||
- [#1127 - Feature Request: WORM (Write Once Read Many) / Object Lock Support](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/1127)
|
||||
|
||||
### `CreateBucket` race condition
|
||||
|
||||
Also due to the lack of a consensus algorithm, there is no mutual exclusion
|
||||
between concurrent `CreateBucket` requests using the same bucket name.
|
||||
|
||||
**Related issues:**
|
||||
|
||||
- [#649 - Race condition in CreateBucket](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/649)
|
||||
|
||||
### Metadata and data have the same replication factor
|
||||
|
||||
There is a single `replication_factor` in the configuration file that applies both to data blocks and metadata entries.
|
||||
This makes clusters with `replication_factor = 1` particularly vulnerable in cases of metadata corruption (see below), as there
|
||||
is a single copy of the metadata for each object even in multi-node clusters.
|
||||
|
||||
**Mitigation:** Do not use `replication_factor = 1`.
|
||||
|
||||
**Long-term solution:** We want to allow scenarios such as replicating the
|
||||
metadata on 2, 3 or more nodes and the data on only 1 or 2 nodes (for example),
|
||||
so that the metadata can benefit from better redundancy without increasing the
|
||||
storage costs for the entire dataset. This will require some important changes
|
||||
in the codebase.
|
||||
|
||||
**Related issues:**
|
||||
|
||||
- [#720 - Separate replication modes for metadata/data](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/720)
|
||||
|
||||
### Node count limitation
|
||||
|
||||
Garage will have issues in clusters with too many nodes, it will not be able to
|
||||
spread data uniformly among nodes and some nodes will fill up faster than
|
||||
other. This starts to manifest when the number of nodes is bigger than `10 ×
|
||||
replication_factor`. This is due to the fact that Garage uses only 256
|
||||
partitions internally.
|
||||
|
||||
**Mitigation:** Build clusters with fewer, bigger nodes.
|
||||
|
||||
**Potential solution:** This can be fixed by increasing the number of
|
||||
partitions in Garage. The code paths exist, there is [a `const`
|
||||
somewhere](https://git.deuxfleurs.fr/Deuxfleurs/garage/src/commit/6fd9bba0cb55062cb1725ab961b7fa8acb9dcc61/src/rpc/layout/mod.rs#L35)
|
||||
that theoretically allows to increase the number of partitions up to `2^16`,
|
||||
but this has not been tested so there might be bugs.
|
||||
|
||||
### Buckets are not sharded
|
||||
|
||||
For each bucket, the first metadata layer that contains an index of all objects
|
||||
is not sharded. This index, which includes the names and all metadata (size,
|
||||
headers, ...) for each object, is stored on `$replication_factor` nodes.
|
||||
|
||||
For instance with `replication_factor = 3`, a given bucket will use only 3
|
||||
specific nodes for this index (chosen at random when the bucket is created) to
|
||||
store this index. In a multi-zone deployments, these nodes will be spread in
|
||||
different zones. Each bucket uses a different set of 3 random nodes for its
|
||||
index.
|
||||
|
||||
As a consequence, very large buckets might cause uneven load distribution
|
||||
within a cluster. If all of the requests on a cluster are for objects in a
|
||||
single bucket, then the `$replication_factor` nodes that store the index will
|
||||
become a hotspot in the cluster, with more intensive metadata access patterns.
|
||||
There is no way of choosing which nodes will have this role.
|
||||
|
||||
Currently, we have no report of this being an issue in practice.
|
||||
|
||||
**Mitigation:** This impacts in particular clusters that are used for a single
|
||||
purpose with a single bucket. This can be solved by dividing your dataset among
|
||||
many buckets, using a client-side sharding strategy that you will have to
|
||||
design. Use at least as many buckets as you have nodes on your cluster.
|
||||
|
||||
|
||||
## Bugs
|
||||
|
||||
Known bugs that are complex to diagnose and fix, and therefore have not been
|
||||
fixed yet.
|
||||
|
||||
### LMDB metadata corruption
|
||||
|
||||
Many users have reported situations where the LMDB metadata db becomes
|
||||
corrupted, sometimes after a forced shutdown of Garage or in case of power
|
||||
loss. A corrupted database file is generally not recoverable.
|
||||
|
||||
**Mitigation:** Use a `replication_factor` of at least 2. Configure automatic
|
||||
snapshotting using `metadata_auto_snapshot_interval` so that in case of
|
||||
corruption you can rollback to a working database.
|
||||
|
||||
Note that taking filesystem-level snapshots of your `metadata_dir`, although it
|
||||
is much faster and less I/O intensive than Garage's built-in snapshotting, does
|
||||
not ensure that the snapshot will be consistent. If the snapshot is taking
|
||||
during a metadata write, the snapshot itself might be corrupted and thus not
|
||||
usable as a rollback point. Therefore, prefer using
|
||||
`metadata_auto_snapshot_interval` in all cases.
|
||||
|
||||
### Layout updates might require manual intervention
|
||||
|
||||
In case of disconnected nodes, when changing the cluster layout to remove these
|
||||
nodes and add other nodes instead, Garage might not be able to properly evict
|
||||
the old nodes from the system. This is a built-in security measure to avoid any
|
||||
inconsistent cluster states.
|
||||
|
||||
This manifests by several cluster layout versions staying active even after a
|
||||
full resync. You can diagnose this situation with `garage layout history`,
|
||||
which will give you instructions to fix it.
|
||||
|
||||
### Tag assignment
|
||||
|
||||
In the `garage layout assign` command, the `-t` argument has to be repeated
|
||||
multiple times to set multiple tags on a node. Writing multiple tags separated
|
||||
by commas will result in a single string.
|
||||
|
||||
## General footguns
|
||||
|
||||
Choices made by the developers that users must be aware of if they don't want
|
||||
to run into potential issues.
|
||||
|
||||
### Resync tranquility is conservative by default
|
||||
|
||||
By default, the worker parameters `resync-tranquility` and `resync-worker-count` are set to very conservative values, to avoid overloading nodes with I/O when data needs to be resynchronized between nodes.
|
||||
This can cause issues where the resync queue grows faster than it can be cleared, which in turn causes performance issues in the rest of Garage.
|
||||
|
||||
This situation is indicated by a big resync queue with few resync errors (the queue is not caused by a disconnected/malfunctionning node).
|
||||
To fix it, increase the number of resync workers and reduce the resync tranquility. For instance, if you want to resync as fast as possible:
|
||||
|
||||
```
|
||||
garage worker set -a resync-worker-count 8
|
||||
garage worker set -a resync-tranquility 0
|
||||
```
|
||||
@@ -40,146 +40,146 @@ garage_local_disk_total{volume="metadata"} 763063566336
|
||||
|
||||
### Cluster health status metrics
|
||||
|
||||
#### `cluster_healthy` (gauge)
|
||||
#### `garage_cluster_healthy` (gauge)
|
||||
|
||||
Whether all storage nodes are connected (0 or 1)
|
||||
|
||||
```
|
||||
cluster_healthy 0
|
||||
garage_cluster_healthy 0
|
||||
```
|
||||
|
||||
#### `cluster_available` (gauge)
|
||||
#### `garage_cluster_available` (gauge)
|
||||
|
||||
Whether all requests can be served, even if some storage nodes are disconnected
|
||||
|
||||
```
|
||||
cluster_available 1
|
||||
garage_cluster_available 1
|
||||
```
|
||||
|
||||
#### `cluster_connected_nodes` (gauge)
|
||||
#### `garage_cluster_connected_nodes` (gauge)
|
||||
|
||||
Number of nodes currently connected
|
||||
|
||||
```
|
||||
cluster_connected_nodes 3
|
||||
garage_cluster_connected_nodes 3
|
||||
```
|
||||
|
||||
#### `cluster_known_nodes` (gauge)
|
||||
#### `garage_cluster_known_nodes` (gauge)
|
||||
|
||||
Number of nodes already seen once in the cluster
|
||||
|
||||
```
|
||||
cluster_known_nodes 3
|
||||
garage_cluster_known_nodes 3
|
||||
```
|
||||
|
||||
#### `cluster_layout_node_connected` (gauge)
|
||||
#### `garage_cluster_layout_node_connected` (gauge)
|
||||
|
||||
Connection status for individual nodes of the cluster layout
|
||||
|
||||
```
|
||||
cluster_layout_node_connected{id="62b218d848e86a64",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 1
|
||||
cluster_layout_node_connected{id="a11c7cf18af29737",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 0
|
||||
cluster_layout_node_connected{id="a235ac7695e0c54d",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 1
|
||||
cluster_layout_node_connected{id="b10c110e4e854e5a",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 1
|
||||
garage_cluster_layout_node_connected{id="62b218d848e86a64",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 1
|
||||
garage_cluster_layout_node_connected{id="a11c7cf18af29737",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 0
|
||||
garage_cluster_layout_node_connected{id="a235ac7695e0c54d",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 1
|
||||
garage_cluster_layout_node_connected{id="b10c110e4e854e5a",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 1
|
||||
```
|
||||
|
||||
#### `cluster_layout_node_disconnected_time` (gauge)
|
||||
#### `garage_cluster_layout_node_disconnected_time` (gauge)
|
||||
|
||||
Time (in seconds) since last connection to individual nodes of the cluster layout
|
||||
|
||||
```
|
||||
cluster_layout_node_disconnected_time{id="62b218d848e86a64",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 0
|
||||
cluster_layout_node_disconnected_time{id="a235ac7695e0c54d",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 0
|
||||
cluster_layout_node_disconnected_time{id="b10c110e4e854e5a",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 0
|
||||
garage_cluster_layout_node_disconnected_time{id="62b218d848e86a64",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 0
|
||||
garage_cluster_layout_node_disconnected_time{id="a235ac7695e0c54d",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 0
|
||||
garage_cluster_layout_node_disconnected_time{id="b10c110e4e854e5a",role_capacity="1000000000",role_gateway="0",role_zone="dc1"} 0
|
||||
```
|
||||
|
||||
#### `cluster_storage_nodes` (gauge)
|
||||
#### `garage_cluster_storage_nodes` (gauge)
|
||||
|
||||
Number of storage nodes declared in the current layout
|
||||
|
||||
```
|
||||
cluster_storage_nodes 4
|
||||
garage_cluster_storage_nodes 4
|
||||
```
|
||||
|
||||
#### `cluster_storage_nodes_ok` (gauge)
|
||||
#### `garage_cluster_storage_nodes_ok` (gauge)
|
||||
|
||||
Number of storage nodes currently connected
|
||||
|
||||
```
|
||||
cluster_storage_nodes_ok 3
|
||||
garage_cluster_storage_nodes_ok 3
|
||||
```
|
||||
|
||||
#### `cluster_partitions` (gauge)
|
||||
#### `garage_cluster_partitions` (gauge)
|
||||
|
||||
Number of partitions in the layout (this is always 256)
|
||||
|
||||
```
|
||||
cluster_partitions 256
|
||||
garage_cluster_partitions 256
|
||||
```
|
||||
|
||||
#### `cluster_partitions_all_ok` (gauge)
|
||||
#### `garage_cluster_partitions_all_ok` (gauge)
|
||||
|
||||
Number of partitions for which all storage nodes are connected
|
||||
|
||||
```
|
||||
cluster_partitions_all_ok 64
|
||||
garage_cluster_partitions_all_ok 64
|
||||
```
|
||||
|
||||
#### `cluster_partitions_quorum` (gauge)
|
||||
#### `garage_cluster_partitions_quorum` (gauge)
|
||||
|
||||
Number of partitions for which we have a quorum of connected nodes and all requests can be served
|
||||
|
||||
```
|
||||
cluster_partitions_quorum 256
|
||||
garage_cluster_partitions_quorum 256
|
||||
```
|
||||
|
||||
### Metrics of the API endpoints
|
||||
|
||||
#### `api_admin_request_counter` (counter)
|
||||
#### `garage_api_admin_request_count` (counter)
|
||||
|
||||
Counts the number of requests to a given endpoint of the administration API. Example:
|
||||
|
||||
```
|
||||
api_admin_request_counter{api_endpoint="Metrics"} 127041
|
||||
garage_api_admin_request_count{api_endpoint="Metrics"} 127041
|
||||
```
|
||||
|
||||
#### `api_admin_request_duration` (histogram)
|
||||
#### `garage_api_admin_request_duration` (histogram)
|
||||
|
||||
Evaluates the duration of API calls to the various administration API endpoint. Example:
|
||||
|
||||
```
|
||||
api_admin_request_duration_bucket{api_endpoint="Metrics",le="0.5"} 127041
|
||||
api_admin_request_duration_sum{api_endpoint="Metrics"} 605.250344830999
|
||||
api_admin_request_duration_count{api_endpoint="Metrics"} 127041
|
||||
garage_api_admin_request_duration_bucket{api_endpoint="Metrics",le="0.5"} 127041
|
||||
garage_api_admin_request_duration_sum{api_endpoint="Metrics"} 605.250344830999
|
||||
garage_api_admin_request_duration_count{api_endpoint="Metrics"} 127041
|
||||
```
|
||||
|
||||
#### `api_s3_request_counter` (counter)
|
||||
#### `garage_api_s3_request_count` (counter)
|
||||
|
||||
Counts the number of requests to a given endpoint of the S3 API. Example:
|
||||
|
||||
```
|
||||
api_s3_request_counter{api_endpoint="CreateMultipartUpload"} 1
|
||||
garage_api_s3_request_count{api_endpoint="CreateMultipartUpload"} 1
|
||||
```
|
||||
|
||||
#### `api_s3_error_counter` (counter)
|
||||
#### `garage_api_s3_error_count` (counter)
|
||||
|
||||
Counts the number of requests to a given endpoint of the S3 API that returned an error. Example:
|
||||
|
||||
```
|
||||
api_s3_error_counter{api_endpoint="GetObject",status_code="404"} 39
|
||||
garage_api_s3_error_count{api_endpoint="GetObject",status_code="404"} 39
|
||||
```
|
||||
|
||||
#### `api_s3_request_duration` (histogram)
|
||||
#### `garage_api_s3_request_duration` (histogram)
|
||||
|
||||
Evaluates the duration of API calls to the various S3 API endpoints. Example:
|
||||
|
||||
```
|
||||
api_s3_request_duration_bucket{api_endpoint="CreateMultipartUpload",le="0.5"} 1
|
||||
api_s3_request_duration_sum{api_endpoint="CreateMultipartUpload"} 0.046340762
|
||||
api_s3_request_duration_count{api_endpoint="CreateMultipartUpload"} 1
|
||||
garage_api_s3_request_duration_bucket{api_endpoint="CreateMultipartUpload",le="0.5"} 1
|
||||
garage_api_s3_request_duration_sum{api_endpoint="CreateMultipartUpload"} 0.046340762
|
||||
garage_api_s3_request_duration_count{api_endpoint="CreateMultipartUpload"} 1
|
||||
```
|
||||
|
||||
#### `api_k2v_request_counter` (counter), `api_k2v_error_counter` (counter), `api_k2v_error_duration` (histogram)
|
||||
#### `garage_api_k2v_request_count` (counter), `garage_api_k2v_error_count` (counter), `garage_api_k2v_error_duration` (histogram)
|
||||
|
||||
Same as for S3, for the K2V API.
|
||||
|
||||
@@ -187,45 +187,45 @@ Same as for S3, for the K2V API.
|
||||
### Metrics of the Web endpoint
|
||||
|
||||
|
||||
#### `web_request_counter` (counter)
|
||||
#### `garage_web_request_count` (counter)
|
||||
|
||||
Number of requests to the web endpoint
|
||||
|
||||
```
|
||||
web_request_counter{method="GET"} 80
|
||||
garage_web_request_count{method="GET"} 80
|
||||
```
|
||||
|
||||
#### `web_request_duration` (histogram)
|
||||
#### `garage_web_request_duration` (histogram)
|
||||
|
||||
Duration of requests to the web endpoint
|
||||
|
||||
```
|
||||
web_request_duration_bucket{method="GET",le="0.5"} 80
|
||||
web_request_duration_sum{method="GET"} 1.0528433229999998
|
||||
web_request_duration_count{method="GET"} 80
|
||||
garage_web_request_duration_bucket{method="GET",le="0.5"} 80
|
||||
garage_web_request_duration_sum{method="GET"} 1.0528433229999998
|
||||
garage_web_request_duration_count{method="GET"} 80
|
||||
```
|
||||
|
||||
#### `web_error_counter` (counter)
|
||||
#### `garage_web_error_count` (counter)
|
||||
|
||||
Number of requests to the web endpoint resulting in errors
|
||||
|
||||
```
|
||||
web_error_counter{method="GET",status_code="404 Not Found"} 64
|
||||
garage_web_error_count{method="GET",status_code="404 Not Found"} 64
|
||||
```
|
||||
|
||||
|
||||
### Metrics of the data block manager
|
||||
|
||||
#### `block_bytes_read`, `block_bytes_written` (counter)
|
||||
#### `garage_block_bytes_read`, `garage_block_bytes_written` (counter)
|
||||
|
||||
Number of bytes read/written to/from disk in the data storage directory.
|
||||
|
||||
```
|
||||
block_bytes_read 120586322022
|
||||
block_bytes_written 3386618077
|
||||
garage_block_bytes_read 120586322022
|
||||
garage_block_bytes_written 3386618077
|
||||
```
|
||||
|
||||
#### `block_ram_buffer_free_kb` (gauge)
|
||||
#### `garage_block_ram_buffer_free_kb` (gauge)
|
||||
|
||||
Kibibytes available for buffering blocks that have to be sent to remote nodes.
|
||||
When clients send too much data to this node and a storage node is not receiving
|
||||
@@ -233,170 +233,168 @@ data fast enough due to slower network conditions, this will decrease down to
|
||||
zero and backpressure will be applied.
|
||||
|
||||
```
|
||||
block_ram_buffer_free_kb 219829
|
||||
garage_block_ram_buffer_free_kb 219829
|
||||
```
|
||||
|
||||
#### `block_compression_level` (counter)
|
||||
#### `garage_block_compression_level` (counter)
|
||||
|
||||
Exposes the block compression level configured for the Garage node.
|
||||
|
||||
```
|
||||
block_compression_level 3
|
||||
garage_block_compression_level 3
|
||||
```
|
||||
|
||||
#### `block_read_duration`, `block_write_duration` (histograms)
|
||||
#### `garage_block_read_duration`, `garage_block_write_duration` (histograms)
|
||||
|
||||
Evaluates the duration of the reading/writing of individual data blocks in the data storage directory.
|
||||
|
||||
```
|
||||
block_read_duration_bucket{le="0.5"} 169229
|
||||
block_read_duration_sum 2761.6902550310056
|
||||
block_read_duration_count 169240
|
||||
block_write_duration_bucket{le="0.5"} 3559
|
||||
block_write_duration_sum 195.59170078500006
|
||||
block_write_duration_count 3571
|
||||
garage_block_read_duration_bucket{le="0.5"} 169229
|
||||
garage_block_read_duration_sum 2761.6902550310056
|
||||
garage_block_read_duration_count 169240
|
||||
garage_block_write_duration_bucket{le="0.5"} 3559
|
||||
garage_block_write_duration_sum 195.59170078500006
|
||||
garage_block_write_duration_count 3571
|
||||
```
|
||||
|
||||
#### `block_delete_counter` (counter)
|
||||
#### `garage_block_delete_count` (counter)
|
||||
|
||||
Counts the number of data blocks that have been deleted from storage.
|
||||
|
||||
```
|
||||
block_delete_counter 122
|
||||
garage_block_delete_count 122
|
||||
```
|
||||
|
||||
#### `block_resync_counter` (counter), `block_resync_duration` (histogram)
|
||||
#### `garage_block_resync_count` (counter), `garage_block_resync_duration` (histogram)
|
||||
|
||||
Counts the number of resync operations the node has executed, and evaluates their duration.
|
||||
|
||||
```
|
||||
block_resync_counter 308897
|
||||
block_resync_duration_bucket{le="0.5"} 308892
|
||||
block_resync_duration_sum 139.64204196100016
|
||||
block_resync_duration_count 308897
|
||||
garage_block_resync_count 308897
|
||||
garage_block_resync_duration_bucket{le="0.5"} 308892
|
||||
garage_block_resync_duration_sum 139.64204196100016
|
||||
garage_block_resync_duration_count 308897
|
||||
```
|
||||
|
||||
#### `block_resync_queue_length` (gauge)
|
||||
#### `garage_block_resync_queue_length` (gauge)
|
||||
|
||||
The number of block hashes currently queued for a resync.
|
||||
This is normal to be nonzero for long periods of time.
|
||||
|
||||
```
|
||||
block_resync_queue_length 0
|
||||
garage_block_resync_queue_length 0
|
||||
```
|
||||
|
||||
#### `block_resync_errored_blocks` (gauge)
|
||||
#### `garage_block_resync_errored_blocks` (gauge)
|
||||
|
||||
The number of block hashes that we were unable to resync last time we tried.
|
||||
**THIS SHOULD BE ZERO, OR FALL BACK TO ZERO RAPIDLY, IN A HEALTHY CLUSTER.**
|
||||
Persistent nonzero values indicate that some data is likely to be lost.
|
||||
|
||||
```
|
||||
block_resync_errored_blocks 0
|
||||
garage_block_resync_errored_blocks 0
|
||||
```
|
||||
|
||||
|
||||
### Metrics related to RPCs (remote procedure calls) between nodes
|
||||
|
||||
#### `rpc_netapp_request_counter` (counter)
|
||||
#### `garage_rpc_netapp_request_count` (counter)
|
||||
|
||||
Number of RPC requests emitted
|
||||
|
||||
```
|
||||
rpc_request_counter{from="<this node>",rpc_endpoint="garage_block/manager.rs/Rpc",to="<remote node>"} 176
|
||||
garage_rpc_request_count{from="<this node>",rpc_endpoint="garage_block/manager.rs/Rpc",to="<remote node>"} 176
|
||||
```
|
||||
|
||||
#### `rpc_netapp_error_counter` (counter)
|
||||
#### `garage_rpc_netapp_error_count` (counter)
|
||||
|
||||
Number of communication errors (errors in the Netapp library, generally due to disconnected nodes)
|
||||
|
||||
```
|
||||
rpc_netapp_error_counter{from="<this node>",rpc_endpoint="garage_block/manager.rs/Rpc",to="<remote node>"} 354
|
||||
garage_rpc_netapp_error_count{from="<this node>",rpc_endpoint="garage_block/manager.rs/Rpc",to="<remote node>"} 354
|
||||
```
|
||||
|
||||
#### `rpc_timeout_counter` (counter)
|
||||
#### `garage_rpc_timeout_count` (counter)
|
||||
|
||||
Number of RPC timeouts, should be close to zero in a healthy cluster.
|
||||
|
||||
```
|
||||
rpc_timeout_counter{from="<this node>",rpc_endpoint="garage_rpc/membership.rs/SystemRpc",to="<remote node>"} 1
|
||||
garage_rpc_timeout_count{from="<this node>",rpc_endpoint="garage_rpc/membership.rs/SystemRpc",to="<remote node>"} 1
|
||||
```
|
||||
|
||||
#### `rpc_duration` (histogram)
|
||||
#### `garage_rpc_duration` (histogram)
|
||||
|
||||
The duration of internal RPC calls between Garage nodes.
|
||||
|
||||
```
|
||||
rpc_duration_bucket{from="<this node>",rpc_endpoint="garage_block/manager.rs/Rpc",to="<remote node>",le="0.5"} 166
|
||||
rpc_duration_sum{from="<this node>",rpc_endpoint="garage_block/manager.rs/Rpc",to="<remote node>"} 35.172253716
|
||||
rpc_duration_count{from="<this node>",rpc_endpoint="garage_block/manager.rs/Rpc",to="<remote node>"} 174
|
||||
garage_rpc_duration_bucket{from="<this node>",rpc_endpoint="garage_block/manager.rs/Rpc",to="<remote node>",le="0.5"} 166
|
||||
garage_rpc_duration_sum{from="<this node>",rpc_endpoint="garage_block/manager.rs/Rpc",to="<remote node>"} 35.172253716
|
||||
garage_rpc_duration_count{from="<this node>",rpc_endpoint="garage_block/manager.rs/Rpc",to="<remote node>"} 174
|
||||
```
|
||||
|
||||
|
||||
### Metrics of the metadata table manager
|
||||
|
||||
#### `table_gc_todo_queue_length` (gauge)
|
||||
#### `garage_table_gc_todo_queue_length` (gauge)
|
||||
|
||||
Table garbage collector TODO queue length
|
||||
|
||||
```
|
||||
table_gc_todo_queue_length{table_name="block_ref"} 0
|
||||
garage_table_gc_todo_queue_length{table_name="block_ref"} 0
|
||||
```
|
||||
|
||||
#### `table_get_request_counter` (counter), `table_get_request_duration` (histogram)
|
||||
#### `garage_table_get_request_count` (counter), `garage_table_get_request_duration` (histogram)
|
||||
|
||||
Number of get/get_range requests internally made on each table, and their duration.
|
||||
|
||||
```
|
||||
table_get_request_counter{table_name="bucket_alias"} 315
|
||||
table_get_request_duration_bucket{table_name="bucket_alias",le="0.5"} 315
|
||||
table_get_request_duration_sum{table_name="bucket_alias"} 0.048509778000000024
|
||||
table_get_request_duration_count{table_name="bucket_alias"} 315
|
||||
garage_table_get_request_count{table_name="bucket_alias"} 315
|
||||
garage_table_get_request_duration_bucket{table_name="bucket_alias",le="0.5"} 315
|
||||
garage_table_get_request_duration_sum{table_name="bucket_alias"} 0.048509778000000024
|
||||
garage_table_get_request_duration_count{table_name="bucket_alias"} 315
|
||||
```
|
||||
|
||||
|
||||
#### `table_put_request_counter` (counter), `table_put_request_duration` (histogram)
|
||||
#### `garage_table_put_request_count` (counter), `garage_table_put_request_duration` (histogram)
|
||||
|
||||
Number of insert/insert_many requests internally made on this table, and their duration
|
||||
|
||||
```
|
||||
table_put_request_counter{table_name="block_ref"} 677
|
||||
table_put_request_duration_bucket{table_name="block_ref",le="0.5"} 677
|
||||
table_put_request_duration_sum{table_name="block_ref"} 61.617528636
|
||||
table_put_request_duration_count{table_name="block_ref"} 677
|
||||
garage_table_put_request_count{table_name="block_ref"} 677
|
||||
garage_table_put_request_duration_bucket{table_name="block_ref",le="0.5"} 677
|
||||
garage_table_put_request_duration_sum{table_name="block_ref"} 61.617528636
|
||||
garage_table_put_request_duration_count{table_name="block_ref"} 677
|
||||
```
|
||||
|
||||
#### `table_internal_delete_counter` (counter)
|
||||
#### `garage_table_internal_delete_count` (counter)
|
||||
|
||||
Number of value deletions in the tree (due to GC or repartitioning)
|
||||
|
||||
```
|
||||
table_internal_delete_counter{table_name="block_ref"} 2296
|
||||
garage_table_internal_delete_count{table_name="block_ref"} 2296
|
||||
```
|
||||
|
||||
#### `table_internal_update_counter` (counter)
|
||||
#### `garage_table_internal_update_count` (counter)
|
||||
|
||||
Number of value updates where the value actually changes (includes creation of new key and update of existing key)
|
||||
|
||||
```
|
||||
table_internal_update_counter{table_name="block_ref"} 5996
|
||||
garage_table_internal_update_count{table_name="block_ref"} 5996
|
||||
```
|
||||
|
||||
#### `table_merkle_updater_todo_queue_length` (gauge)
|
||||
#### `garage_table_merkle_updater_todo_queue_length` (gauge)
|
||||
|
||||
Merkle tree updater TODO queue length (should fall to zero rapidly)
|
||||
|
||||
```
|
||||
table_merkle_updater_todo_queue_length{table_name="block_ref"} 0
|
||||
garage_table_merkle_updater_todo_queue_length{table_name="block_ref"} 0
|
||||
```
|
||||
|
||||
#### `table_sync_items_received`, `table_sync_items_sent` (counters)
|
||||
#### `garage_table_sync_items_received`, `garage_table_sync_items_sent` (counters)
|
||||
|
||||
Number of data items sent to/received from other nodes during resync procedures
|
||||
|
||||
```
|
||||
table_sync_items_received{from="<remote node>",table_name="bucket_v2"} 3
|
||||
table_sync_items_sent{table_name="block_ref",to="<remote node>"} 2
|
||||
garage_table_sync_items_received{from="<remote node>",table_name="bucket_v2"} 3
|
||||
garage_table_sync_items_sent{table_name="block_ref",to="<remote node>"} 2
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
*
|
||||
|
||||
!*.txt
|
||||
!*.md
|
||||
|
||||
!assets
|
||||
|
||||
!.gitignore
|
||||
!*.svg
|
||||
!*.png
|
||||
!*.jpg
|
||||
!*.tex
|
||||
!Makefile
|
||||
!.gitignore
|
||||
!assets/*.drawio.pdf
|
||||
|
||||
talk.{nav,out,snm,toc,aux,log}
|
||||
!talk.pdf
|
||||
@@ -0,0 +1,3 @@
|
||||
talk.pdf: talk.tex
|
||||
pdflatex talk.tex
|
||||
|
||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,330 @@
|
||||
%\nonstopmode
|
||||
\documentclass[aspectratio=169]{beamer}
|
||||
\usepackage[utf8]{inputenc}
|
||||
% \usepackage[frenchb]{babel}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{mathtools}
|
||||
\usepackage{breqn}
|
||||
\usepackage{multirow}
|
||||
\usetheme{boxes}
|
||||
\usepackage{graphicx}
|
||||
%\useoutertheme[footline=authortitle,subsection=false]{miniframes}
|
||||
|
||||
\beamertemplatenavigationsymbolsempty
|
||||
|
||||
\definecolor{TitleOrange}{RGB}{255,137,0}
|
||||
\setbeamercolor{title}{fg=TitleOrange}
|
||||
\setbeamercolor{frametitle}{fg=TitleOrange}
|
||||
|
||||
\definecolor{ListOrange}{RGB}{255,145,5}
|
||||
\setbeamertemplate{itemize item}{\color{ListOrange}$\blacktriangleright$}
|
||||
|
||||
\definecolor{verygrey}{RGB}{70,70,70}
|
||||
\setbeamercolor{normal text}{fg=verygrey}
|
||||
|
||||
|
||||
\usepackage{tabu}
|
||||
\usepackage{multicol}
|
||||
\usepackage{vwcol}
|
||||
\usepackage{stmaryrd}
|
||||
\usepackage{graphicx}
|
||||
|
||||
\usepackage[normalem]{ulem}
|
||||
|
||||
\title{Garage Object Storage: 2.0 update and best practices}
|
||||
\subtitle{a new storage platform for self-hosted geo-distributed clusters}
|
||||
\author{Maximilien Richer, Deuxfleurs}
|
||||
\date{FOSDEM '26}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\begin{frame}
|
||||
\centering
|
||||
\includegraphics[width=.3\linewidth]{../../sticker/Garage.pdf}
|
||||
\vspace{1em}
|
||||
|
||||
{\large\bf Maximilien Richer, Deuxfleurs}
|
||||
\vspace{1em}
|
||||
|
||||
\url{https://garagehq.deuxfleurs.fr/}
|
||||
Matrix channel: \texttt{\#garage:deuxfleurs.fr}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Our objective at Deuxfleurs}
|
||||
|
||||
\begin{center}
|
||||
French association promoting digital sovereignty and privacy\\
|
||||
through self-hosting hosting \textbf{as an alternative to large cloud providers}
|
||||
\end{center}
|
||||
\vspace{2em}
|
||||
|
||||
\vspace{2em}
|
||||
\begin{center}
|
||||
\textbf{This requires \underline{resilience}}\\
|
||||
{\footnotesize (we want good uptime/availability with low supervision)}
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{But what is Garage, exactly?}
|
||||
\textbf{Garage is a self-hosted drop-in replacement for the Amazon S3 object store}\\
|
||||
\vspace{.5em}
|
||||
that implements resilience through geographical redundancy on commodity hardware
|
||||
\begin{center}
|
||||
\includegraphics[width=.8\linewidth]{assets/garageuses.png}
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{What makes Garage different?}
|
||||
\textbf{Coordination-free:}
|
||||
\vspace{2em}
|
||||
\begin{itemize}
|
||||
\item No Raft or Paxos
|
||||
\vspace{1em}
|
||||
\item Internal data types are CRDTs
|
||||
\vspace{1em}
|
||||
\item All nodes are equivalent (no master/leader/index node)
|
||||
\end{itemize}
|
||||
\vspace{2em}
|
||||
$\to$ less sensitive to higher latencies between nodes
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{What makes Garage different?}
|
||||
\begin{center}
|
||||
TODO update with latest garage and minio versions
|
||||
\includegraphics[width=.9\linewidth]{assets/endpoint-latency-dc.png}
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{What makes Garage different?}
|
||||
\textbf{Consistency model:}
|
||||
\vspace{2em}
|
||||
\begin{itemize}
|
||||
\item Not ACID (not required by S3 spec) / not linearizable
|
||||
\vspace{1em}
|
||||
\item \textbf{Read-after-write consistency}\\
|
||||
{\footnotesize (stronger than eventual consistency)}
|
||||
\end{itemize}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{What makes Garage different?}
|
||||
\textbf{Location-aware:}
|
||||
\vspace{2em}
|
||||
\begin{center}
|
||||
\includegraphics[width=\linewidth]{assets/location-aware.png}
|
||||
\end{center}
|
||||
\vspace{2em}
|
||||
Garage replicates data on different zones when possible
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{What makes Garage different?}
|
||||
\begin{center}
|
||||
\includegraphics[width=.8\linewidth]{assets/map.png}
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{An ever-increasing compatibility list}
|
||||
\begin{center}
|
||||
\includegraphics[width=.7\linewidth]{assets/compatibility.png}
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Version history and roadmap}
|
||||
\begin{itemize}
|
||||
\item v0.3: initial beta release (2021)
|
||||
\item v0.7: first released version (2022)
|
||||
\item v1.0: stable release (2024), will be deprecated in summer 2026 1y after v2.0 was released
|
||||
\item v2.0: stable release (2025)
|
||||
\begin{itemize}
|
||||
\item new HTTP admin API
|
||||
\item reworded replication configuration: \texttt{replication\_mode} changed to \texttt{replication\_factor} \& \texttt{consistency\_policy}
|
||||
\end{itemize}
|
||||
\item
|
||||
\end{itemize}
|
||||
\begin{center}
|
||||
v3.0: TBA may include versionning support, tag on buckets and objets, retention policies...
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\centering
|
||||
{\large\bf Best practices for Garage deployments}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Things you should know}
|
||||
\begin{itemize}
|
||||
\item no TLS support, use your own proxy
|
||||
\item no anonymous access (use website endpoint)
|
||||
\item you need to assign roles to nodes manually
|
||||
\item the replication factor cannot be changed easily
|
||||
\item the default region is \texttt{garage} and not \texttt{us-east-1}
|
||||
\item only use the \texttt{degraded} consistency policy for data recovery!
|
||||
\end{itemize}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{What hardware should I use?}
|
||||
\begin{itemize}
|
||||
\item do NOT use network file storage (NFS, SMB, etc.) for \texttt{\/metadata}
|
||||
\item get a \textbf{write-intensive flash disk} for the \texttt{\/metadata} folder
|
||||
\item set \texttt{metadata} on a RAID1 if possible, with a COW filesystem (e.g. Btrfs or ZFS)
|
||||
\item get large HDDs for the \texttt{\/data} folder
|
||||
\item use XFS and garage multi-hdd mode for best performance
|
||||
\item you can use a RAID for data but you'll leave a lot of performance on the table
|
||||
\end{itemize}
|
||||
\center\textit{Garage doesn't require a powerful CPUs nor much RAM, but your performance will depend on your disks!}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Picking a metadata engine}
|
||||
All files-to-block mappings are stored in the metadata engine, including bucket and object metadata. Files below 3KB are stored directly in the metadata engine.
|
||||
\vspace{1em}
|
||||
\begin{itemize}
|
||||
\item Sled: removed in 1.x, move to SQLite or LMDB
|
||||
\item \textbf{SQLite}: safer, \textbf{recommended for small clusters and single-node}
|
||||
\item LMDB: faster, recommended for large clusters with metadata redundancy
|
||||
\begin{itemize}
|
||||
\item Warning: limited to 480 bytes per key with LMDB (not an issue in practice)
|
||||
\end{itemize}
|
||||
\item Fjall: experimental but promising rust-native engine, test it and let us know!
|
||||
\end{itemize}
|
||||
\center{Metadata engine can be set node per node, and changed later with a migration tool}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Single-node deployment}
|
||||
\begin{itemize}
|
||||
\item garage was initially designed for multi-node deployments
|
||||
\item single-node deployments are possible, but you will lose resilience
|
||||
\item \textbf{If you do please ensure you have backups} (especially for metadata)
|
||||
\begin{itemize}
|
||||
\item set up \texttt{metadata\_auto\_snapshot\_interval}
|
||||
\end{itemize}
|
||||
\item use sqlite to minimize data loss risks on powercuts
|
||||
\item or use a UPS!
|
||||
\end{itemize}
|
||||
\vspace{1em}
|
||||
Use \texttt{github.com/bikeshedder/garage-single-node} for an easy single-node setup!
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Multi-node deployment}
|
||||
\begin{itemize}
|
||||
\item try to have geo-distributed zones
|
||||
\item multiple nodes per zone to add more capacity
|
||||
\item at least 3 zones for best resilience
|
||||
\item keep in mind your available network and IO bandwidth
|
||||
\item \textbf{Rebalancing a cluster can take multiple weeks with large HDDs and slow network links}
|
||||
\item monitor your nodes with Prometheus + Grafana
|
||||
\end{itemize}
|
||||
\center{Deuxfleurs has been running a 9TB (3TB usable) 8-nodes cluster (3+3+2) over retail fiber (10ms site-to-site latency) for close to 5 years now. We heard there are petabyte clusters out there!}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Deploying and administering garage at scale}
|
||||
\begin{itemize}
|
||||
\item deploy with your favorite tool (eg. Ansible) and system manager (eg. systemd)
|
||||
\item or use Docker, docker-compose, Kubernetes or Nomad
|
||||
\item Kubernetes and Consul are supported for node-to-node discovery
|
||||
\begin{itemize}
|
||||
\item you'll still have to manage the layout manually!
|
||||
\end{itemize}
|
||||
\item use gateway nodes to optimize network usage
|
||||
\item ajust \texttt{resync-tranquility} and \texttt{scrub-tranquility} to your ressources
|
||||
\end{itemize}
|
||||
\center{Kubernetes storage controller: \texttt{github.com/bmarinov/garage-storage-controller}}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Community UI available!}
|
||||
\begin{center}
|
||||
\includegraphics[width=0.9\linewidth]{assets/community-ui.png}\\
|
||||
\vspace{-1em}
|
||||
\url{https://github.com/khairul169/garage-webui}
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Official Embedded UI comming later this year!}
|
||||
\begin{center}
|
||||
\includegraphics[width=0.9\linewidth]{assets/Garage Web Admin - Dashboard@2x.png}\\
|
||||
\vspace{-1em}
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Official Embedded UI comming this year!}
|
||||
\begin{center}
|
||||
\includegraphics[width=0.9\linewidth]{assets/Garage Web Admin - Bucket details page@2x.png}\\
|
||||
\vspace{-1em}
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{How to make sense of garage metrics?}
|
||||
\begin{center}
|
||||
\includegraphics[width=0.7\linewidth]{assets/garage-stats.png}\\
|
||||
\vspace{-1em}
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{What if things go wrong?}
|
||||
\begin{itemize}
|
||||
\item set logs to debug with \texttt{RUST_LOG=garage_api_common=debug,garage_api_s3=debug,garage=debug}
|
||||
\item auth issues: check your reverse proxy configuration
|
||||
\item slow resync: check your network and disk IO usage, and \texttt{resync-tranquility} worker configuration
|
||||
\item big LMDB database: stop garage and compact with \texttt{mdb\_copy -c}
|
||||
\item ask us on matrix \texttt{\#garage:deuxfleurs.fr} or open an issue on git.deuxfleurs.fr!
|
||||
\begin{itemize}
|
||||
\item provide the output of \texttt{garage status}, \texttt{garage stats} and relevant metrics and logs
|
||||
\end{itemize}
|
||||
\end{itemize}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Moving from Minio}
|
||||
\begin{itemize}
|
||||
\item list your buckets and your keys
|
||||
\item create buckets and keys on the garage cluster
|
||||
\begin{itemize}
|
||||
\item you cannot import non-garage keys yet, patch to come soon!
|
||||
\end{itemize}
|
||||
\item loop over buckets, copy with rclone
|
||||
\begin{itemize}
|
||||
\item see doc \url{https://garagehq.deuxfleurs.fr/documentation/connect/cli/}
|
||||
\end{itemize}
|
||||
\item blog post coming soon!
|
||||
\end{itemize}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Demo time!}
|
||||
\end{frame}
|
||||
|
||||
\begin{frame}
|
||||
\frametitle{Get Garage now!}
|
||||
\begin{center}
|
||||
\includegraphics[width=.3\linewidth]{../../logo/garage_hires.png}\\
|
||||
\vspace{-1em}
|
||||
\url{https://garagehq.deuxfleurs.fr/}\\
|
||||
Matrix channel: \texttt{\#garage:deuxfleurs.fr}
|
||||
|
||||
\vspace{2em}
|
||||
\includegraphics[width=.09\linewidth]{assets/rust_logo.png}
|
||||
\includegraphics[width=.2\linewidth]{assets/AGPLv3_Logo.png}
|
||||
\end{center}
|
||||
\end{frame}
|
||||
|
||||
\end{document}
|
||||
|
||||
%% vim: set ts=4 sw=4 tw=0 noet spelllang=fr :
|
||||
@@ -81,17 +81,17 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1763952169,
|
||||
"narHash": "sha256-+PeDBD8P+NKauH+w7eO/QWCIp8Cx4mCfWnh9sJmy9CM=",
|
||||
"lastModified": 1776914043,
|
||||
"narHash": "sha256-qug5r56yW1qOsjSI99l3Jm15JNT9CvS2otkXNRNtrPI=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "ab726555a9a72e6dc80649809147823a813fa95b",
|
||||
"rev": "2d35c4358d7de3a0e606a6e8b27925d981c01cc3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "ab726555a9a72e6dc80649809147823a813fa95b",
|
||||
"rev": "2d35c4358d7de3a0e606a6e8b27925d981c01cc3",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
inputs.nixpkgs.url =
|
||||
"github:NixOS/nixpkgs/cfe2c7d5b5d3032862254e68c37a6576b633d632";
|
||||
|
||||
# Rust overlay as of 2025-11-24
|
||||
# Rust overlay as of 2026-04-23
|
||||
inputs.rust-overlay.url =
|
||||
"github:oxalica/rust-overlay/ab726555a9a72e6dc80649809147823a813fa95b";
|
||||
"github:oxalica/rust-overlay/2d35c4358d7de3a0e606a6e8b27925d981c01cc3";
|
||||
inputs.rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
# Crane as of 2025-01-24
|
||||
@@ -95,6 +95,14 @@
|
||||
killall
|
||||
];
|
||||
};
|
||||
|
||||
# dev shell for fuzzing
|
||||
fuzz = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
targets.toolchainNightly
|
||||
cargo-fuzz
|
||||
];
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
target
|
||||
corpus
|
||||
artifacts
|
||||
coverage
|
||||
@@ -0,0 +1,73 @@
|
||||
[package]
|
||||
name = "garage-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2018"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
arbitrary = { workspace = true, features = ["derive"]}
|
||||
libfuzzer-sys = { workspace = true }
|
||||
|
||||
garage_db.workspace = true
|
||||
garage_table.workspace = true
|
||||
garage_util.workspace = true
|
||||
garage_model = { workspace = true, default-features = false, features = ["arbitrary", "k2v"] }
|
||||
|
||||
[[bin]]
|
||||
name = "version_crdt"
|
||||
path = "fuzz_targets/version_crdt.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "mpu_crdt"
|
||||
path = "fuzz_targets/mpu_crdt.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "bucket_crdt"
|
||||
path = "fuzz_targets/bucket_crdt.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "block_ref_crdt"
|
||||
path = "fuzz_targets/block_ref_crdt.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "admin_api_token_crdt"
|
||||
path = "fuzz_targets/admin_api_token_crdt.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "key_crdt"
|
||||
path = "fuzz_targets/key_crdt.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "bucket_alias_crdt"
|
||||
path = "fuzz_targets/bucket_alias_crdt.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "k2v_item_crdt"
|
||||
path = "fuzz_targets/k2v_item_crdt.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,11 @@
|
||||
# Fuzzing
|
||||
|
||||
## Setup
|
||||
|
||||
Install cargo fuzz: `cargo install cargo-fuzz`
|
||||
|
||||
## Launch
|
||||
|
||||
Run `cargo fuzz run <fuzz_target>` where `<fuzz_target>` is the name (without extension) of one of the `.rs` files in the `fuzz_targets` directory.
|
||||
|
||||
If you launch the command outside of the fuzz directory, you need to force the nightly toolchain with `cargo +nightly`.
|
||||
@@ -0,0 +1,38 @@
|
||||
#![no_main]
|
||||
|
||||
use garage_fuzz::check_crdt_laws;
|
||||
use garage_model::admin_token_table::{AdminApiToken, AdminApiTokenParams, AdminApiTokenScope};
|
||||
use garage_model::permission::ExpirationTime;
|
||||
use garage_util::crdt;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
type Input = (
|
||||
bool,
|
||||
crdt::Lww<String>,
|
||||
crdt::Lww<crdt::MergingOption<ExpirationTime>>,
|
||||
crdt::Lww<AdminApiTokenScope>,
|
||||
);
|
||||
|
||||
fn make(input: Input) -> AdminApiToken {
|
||||
let (deleted, name, expiration, scope) = input;
|
||||
let state = if deleted {
|
||||
crdt::Deletable::Deleted
|
||||
} else {
|
||||
crdt::Deletable::present(AdminApiTokenParams {
|
||||
created: 0,
|
||||
token_hash: String::new(),
|
||||
name,
|
||||
expiration,
|
||||
scope,
|
||||
})
|
||||
};
|
||||
AdminApiToken {
|
||||
prefix: String::new(),
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
fuzz_target!(|inputs: (Input, Input, Input)| {
|
||||
let (a, b, c) = inputs;
|
||||
check_crdt_laws(make(a), make(b), make(c));
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
#![no_main]
|
||||
|
||||
use garage_fuzz::check_crdt_laws;
|
||||
use garage_model::s3::block_ref_table::BlockRef;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
/// Build a BlockRef with a fixed block hash and version UUID so that CRDT state
|
||||
/// can be compared across merge results. Only the deleted flag varies.
|
||||
fn make_block_ref(deleted: bool) -> BlockRef {
|
||||
BlockRef {
|
||||
block: [0u8; 32].into(),
|
||||
version: [0u8; 32].into(),
|
||||
deleted: deleted.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fuzz_target!(|inputs: (bool, bool, bool)| {
|
||||
let (d1, d2, d3) = inputs;
|
||||
check_crdt_laws(make_block_ref(d1), make_block_ref(d2), make_block_ref(d3));
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
#![no_main]
|
||||
|
||||
use garage_fuzz::check_crdt_laws;
|
||||
use garage_model::bucket_alias_table::BucketAlias;
|
||||
use garage_util::data::Uuid;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
/// Build a BucketAlias with a fixed name so that CRDT state can be compared
|
||||
/// across merge results. The timestamp and optional bucket ID are the CRDT state.
|
||||
fn make_bucket_alias(ts: u64, bucket_id: Option<[u8; 32]>) -> BucketAlias {
|
||||
BucketAlias::new(String::new(), ts, bucket_id.map(Uuid::from))
|
||||
}
|
||||
|
||||
fuzz_target!(|inputs: (
|
||||
(u64, Option<[u8; 32]>),
|
||||
(u64, Option<[u8; 32]>),
|
||||
(u64, Option<[u8; 32]>)
|
||||
)| {
|
||||
let ((ts1, b1), (ts2, b2), (ts3, b3)) = inputs;
|
||||
check_crdt_laws(
|
||||
make_bucket_alias(ts1, b1),
|
||||
make_bucket_alias(ts2, b2),
|
||||
make_bucket_alias(ts3, b3),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
#![no_main]
|
||||
|
||||
use garage_fuzz::check_crdt_laws;
|
||||
use garage_model::bucket_table::{Bucket, BucketParams};
|
||||
use garage_util::crdt::{self, Deletable};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fn make(state: Deletable<BucketParams>) -> Bucket {
|
||||
Bucket {
|
||||
id: [0u8; 32].into(),
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
fuzz_target!(|inputs: (
|
||||
crdt::Deletable<BucketParams>,
|
||||
crdt::Deletable<BucketParams>,
|
||||
crdt::Deletable<BucketParams>
|
||||
)| {
|
||||
let (a, b, c) = inputs;
|
||||
check_crdt_laws(make(a), make(b), make(c));
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
#![no_main]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use garage_fuzz::check_crdt_laws;
|
||||
use garage_model::k2v::item_table::{DvvsEntry, DvvsValue, K2VItem};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
// Timestamps are encoded as `(ts << 32) | shift` so that items built with different
|
||||
// shifts (0, 1, 2) have disjoint timestamp spaces that still interleave in the sorted merge.
|
||||
fn make(raw: BTreeMap<u64, (u32, BTreeMap<u32, DvvsValue>)>, shift: u32) -> K2VItem {
|
||||
let shift = shift as u64;
|
||||
let items = raw
|
||||
.into_iter()
|
||||
.map(|(node, (t_discard, values))| {
|
||||
let entry = DvvsEntry::from_raw(
|
||||
(t_discard as u64) << 32 | shift,
|
||||
values
|
||||
.into_iter()
|
||||
.map(|(ts, v)| ((ts as u64) << 32 | shift, v))
|
||||
.collect(),
|
||||
);
|
||||
(node, entry)
|
||||
})
|
||||
.collect();
|
||||
K2VItem::with_raw_items(items)
|
||||
}
|
||||
|
||||
fuzz_target!(|inputs: (
|
||||
BTreeMap<u64, (u32, BTreeMap<u32, DvvsValue>)>,
|
||||
BTreeMap<u64, (u32, BTreeMap<u32, DvvsValue>)>,
|
||||
BTreeMap<u64, (u32, BTreeMap<u32, DvvsValue>)>,
|
||||
)| {
|
||||
let (a, b, c) = inputs;
|
||||
check_crdt_laws(make(a, 0), make(b, 1), make(c, 2));
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
#![no_main]
|
||||
|
||||
use garage_fuzz::check_crdt_laws;
|
||||
use garage_model::key_table::{Key, KeyParams};
|
||||
use garage_model::permission::{BucketKeyPerm, ExpirationTime};
|
||||
use garage_util::crdt;
|
||||
use garage_util::data::Uuid;
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
type Input = (
|
||||
bool,
|
||||
crdt::Lww<String>,
|
||||
crdt::Lww<crdt::MergingOption<ExpirationTime>>,
|
||||
crdt::Lww<bool>,
|
||||
crdt::Map<Uuid, BucketKeyPerm>,
|
||||
crdt::LwwMap<String, crdt::CancelingOption<Uuid>>,
|
||||
);
|
||||
|
||||
fn make(input: Input) -> Key {
|
||||
let (deleted, name, expiration, allow_create_bucket, authorized_buckets, local_aliases) = input;
|
||||
let state = if deleted {
|
||||
crdt::Deletable::Deleted
|
||||
} else {
|
||||
crdt::Deletable::present(KeyParams {
|
||||
created: None,
|
||||
secret_key: String::new(),
|
||||
name,
|
||||
expiration,
|
||||
allow_create_bucket,
|
||||
authorized_buckets,
|
||||
local_aliases,
|
||||
})
|
||||
};
|
||||
Key {
|
||||
key_id: String::new(),
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
fuzz_target!(|inputs: (Input, Input, Input)| {
|
||||
let (a, b, c) = inputs;
|
||||
check_crdt_laws(make(a), make(b), make(c));
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
#![no_main]
|
||||
|
||||
use garage_fuzz::check_crdt_laws;
|
||||
use garage_model::s3::mpu_table::{MpuPart, MpuPartKey, MultipartUpload};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
/// Build a MultipartUpload from an arbitrary deleted flag and parts list, using a fixed
|
||||
/// upload_id/bucket_id/key so that CRDT state can be compared across merge results.
|
||||
/// `MpuPart.version` is fixed to a constant since it is identity data, not CRDT state:
|
||||
/// two replicas of the same part (same MpuPartKey) always share the same version UUID.
|
||||
/// If deleted, parts are cleared to ensure a valid initial CRDT state.
|
||||
fn make_mpu(deleted: bool, parts: Vec<(MpuPartKey, MpuPart)>) -> MultipartUpload {
|
||||
let mut mpu = MultipartUpload::new(
|
||||
[0u8; 32].into(),
|
||||
0,
|
||||
[0u8; 32].into(),
|
||||
String::new(),
|
||||
deleted,
|
||||
);
|
||||
for (key, mut part) in parts {
|
||||
part.version = [0u8; 32].into();
|
||||
mpu.parts.put(key, part);
|
||||
}
|
||||
if mpu.deleted.get() {
|
||||
mpu.parts.clear();
|
||||
}
|
||||
mpu
|
||||
}
|
||||
|
||||
fuzz_target!(|inputs: (
|
||||
(bool, Vec<(MpuPartKey, MpuPart)>),
|
||||
(bool, Vec<(MpuPartKey, MpuPart)>),
|
||||
(bool, Vec<(MpuPartKey, MpuPart)>)
|
||||
)| {
|
||||
let ((d1, p1), (d2, p2), (d3, p3)) = inputs;
|
||||
check_crdt_laws(make_mpu(d1, p1), make_mpu(d2, p2), make_mpu(d3, p3));
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
#![no_main]
|
||||
|
||||
use garage_fuzz::check_crdt_laws;
|
||||
use garage_model::s3::version_table::{Version, VersionBacklink, VersionBlock, VersionBlockKey};
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
/// Build a Version from an arbitrary deleted flag and block list, using a fixed uuid/backlink
|
||||
/// so that CRDT state can be compared across merge results.
|
||||
/// Duplicate block keys are dropped before construction.
|
||||
/// If deleted, blocks are cleared to ensure a valid initial CRDT state.
|
||||
fn make_version(deleted: bool, mut blocks: Vec<(VersionBlockKey, VersionBlock)>) -> Version {
|
||||
blocks.sort_by_key(|(k, _)| *k);
|
||||
blocks.dedup_by_key(|(k, _)| *k);
|
||||
let mut v = Version::new(
|
||||
[0u8; 32].into(),
|
||||
VersionBacklink::Object {
|
||||
bucket_id: [0u8; 32].into(),
|
||||
key: String::new(),
|
||||
},
|
||||
deleted,
|
||||
);
|
||||
for (key, block) in blocks {
|
||||
v.blocks.put(key, block);
|
||||
}
|
||||
if v.deleted.get() {
|
||||
v.blocks.clear();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fuzz_target!(|inputs: (
|
||||
(bool, Vec<(VersionBlockKey, VersionBlock)>),
|
||||
(bool, Vec<(VersionBlockKey, VersionBlock)>),
|
||||
(bool, Vec<(VersionBlockKey, VersionBlock)>)
|
||||
)| {
|
||||
let ((d1, b1), (d2, b2), (d3, b3)) = inputs;
|
||||
check_crdt_laws(
|
||||
make_version(d1, b1),
|
||||
make_version(d2, b2),
|
||||
make_version(d3, b3),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
@@ -0,0 +1,56 @@
|
||||
use garage_table::crdt::Crdt;
|
||||
use std::fmt::Debug;
|
||||
|
||||
pub fn check_crdt_laws<T>(a: T, b: T, c: T)
|
||||
where
|
||||
T: Crdt + PartialEq + Clone + Debug,
|
||||
{
|
||||
// Idempotency: merge(a, a) == a
|
||||
{
|
||||
let mut a2 = a.clone();
|
||||
a2.merge(&a);
|
||||
assert_eq!(a2, a, "merge is not idempotent: {a2:#?} != {a:#?}");
|
||||
}
|
||||
|
||||
// Commutativity: merge(a, b) == merge(b, a)
|
||||
let ab = {
|
||||
let mut t = a.clone();
|
||||
t.merge(&b);
|
||||
t
|
||||
};
|
||||
let ba = {
|
||||
let mut t = b.clone();
|
||||
t.merge(&a);
|
||||
t
|
||||
};
|
||||
assert_eq!(ab, ba, "merge is not commutative: {ab:#?} != {ba:#?}");
|
||||
|
||||
// LX's corrolary: merge(merge(a,b),b) = merge(a,b)
|
||||
let ab_b = {
|
||||
let mut t = ab.clone();
|
||||
t.merge(&b);
|
||||
t
|
||||
};
|
||||
assert_eq!(ab, ab_b);
|
||||
|
||||
// Associativity: merge(merge(a, b), c) == merge(a, merge(b, c))
|
||||
let ab_c = {
|
||||
let mut t = ab;
|
||||
t.merge(&c);
|
||||
t
|
||||
};
|
||||
let bc = {
|
||||
let mut t = b;
|
||||
t.merge(&c);
|
||||
t
|
||||
};
|
||||
let a_bc = {
|
||||
let mut t = a;
|
||||
t.merge(&bc);
|
||||
t
|
||||
};
|
||||
assert_eq!(
|
||||
ab_c, a_bc,
|
||||
"merge is not associative: {ab_c:#?} != {a_bc:#?}"
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ let
|
||||
|
||||
inherit (pkgs) lib stdenv;
|
||||
|
||||
toolchainFn = (p: p.rust-bin.stable."1.91.0".default.override {
|
||||
toolchainFn = (p: p.rust-bin.stable."1.95.0".default.override {
|
||||
targets = lib.optionals (target != null) [ rustTarget ];
|
||||
extensions = [
|
||||
"rust-src"
|
||||
@@ -148,6 +148,14 @@ let
|
||||
in rec {
|
||||
toolchain = toolchainFn pkgs;
|
||||
|
||||
toolchainNightly = pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
|
||||
targets = lib.optionals (target != null) [ rustTarget ];
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rustfmt"
|
||||
];
|
||||
});
|
||||
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
toolchain
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: garage
|
||||
description: S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
type: application
|
||||
version: 0.9.2
|
||||
appVersion: "v2.2.0"
|
||||
version: 0.9.3
|
||||
appVersion: "v2.3.0"
|
||||
home: https://garagehq.deuxfleurs.fr/
|
||||
icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# garage
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
|
||||
@@ -33,11 +33,14 @@ S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
| garage.replicationFactor | string | `"3"` | Default to 3 replicas, see the replication_factor section at https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#replication_factor |
|
||||
| garage.consistencyMode | string | `"consistent"` | Default to read-after-write consistency, see the consistency_mode section at https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#consistency_mode |
|
||||
| garage.metadataAutoSnapshotInterval | string | `""` | If this value is set, Garage will automatically take a snapshot of the metadata DB file at a regular interval and save it in the metadata directory. https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#metadata_auto_snapshot_interval |
|
||||
| garage.admin.apiBindAddr | string | `"[::]:3903"` | |
|
||||
| garage.rpcBindAddr | string | `"[::]:3901"` | |
|
||||
| garage.rpcSecret | string | `""` | If not given, a random secret will be generated and stored in a Secret object |
|
||||
| garage.s3.api.bindAddr | string | `"[::]:3900"` | |
|
||||
| garage.s3.api.region | string | `"garage"` | |
|
||||
| garage.s3.api.rootDomain | string | `".s3.garage.tld"` | |
|
||||
| garage.s3.web.index | string | `"index.html"` | |
|
||||
| garage.s3.web.bindAddr | string | `"[::]:3902"` | |
|
||||
| garage.s3.web.rootDomain | string | `".web.garage.tld"` | |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| image.repository | string | `"dxflrs/amd64_garage"` | default to amd64 docker image |
|
||||
|
||||
@@ -71,6 +71,13 @@ Create the name of the service account to use
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Extract the trailing port number from a bind address like [::]:3900 or 0.0.0.0:3900.
|
||||
*/}}
|
||||
{{- define "garage.portFromBindAddr" -}}
|
||||
{{- regexFind "[0-9]+$" . -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Returns given number of random Hex characters.
|
||||
In practice, it generates up to 100 randAlphaNum strings
|
||||
|
||||
@@ -5,9 +5,11 @@ metadata:
|
||||
labels:
|
||||
{{- include "garage.labels" . | nindent 4 }}
|
||||
rules:
|
||||
{{- if eq .Values.garage.kubernetesSkipCrd false }}
|
||||
- apiGroups: ["apiextensions.k8s.io"]
|
||||
resources: ["customresourcedefinitions"]
|
||||
verbs: ["get", "list", "watch", "create", "patch"]
|
||||
{{ end }}
|
||||
- apiGroups: ["deuxfleurs.fr"]
|
||||
resources: ["garagenodes"]
|
||||
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
@@ -25,4 +27,4 @@ subjects:
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: manage-crds-{{ .Release.Namespace }}-{{ .Release.Name }}
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
|
||||
@@ -45,16 +45,16 @@ data:
|
||||
|
||||
[s3_api]
|
||||
s3_region = "{{ .Values.garage.s3.api.region }}"
|
||||
api_bind_addr = "[::]:3900"
|
||||
api_bind_addr = "{{ .Values.garage.s3.api.bindAddr }}"
|
||||
root_domain = "{{ .Values.garage.s3.api.rootDomain }}"
|
||||
|
||||
[s3_web]
|
||||
bind_addr = "[::]:3902"
|
||||
bind_addr = "{{ .Values.garage.s3.web.bindAddr }}"
|
||||
root_domain = "{{ .Values.garage.s3.web.rootDomain }}"
|
||||
index = "{{ .Values.garage.s3.web.index }}"
|
||||
|
||||
[admin]
|
||||
api_bind_addr = "[::]:3903"
|
||||
api_bind_addr = "{{ .Values.garage.admin.apiBindAddr }}"
|
||||
{{- if .Values.monitoring.tracing.sink }}
|
||||
trace_sink = "{{ .Values.monitoring.tracing.sink }}"
|
||||
{{- end }}
|
||||
|
||||
@@ -10,11 +10,11 @@ spec:
|
||||
clusterIP: None
|
||||
ports:
|
||||
- port: {{ .Values.service.s3.api.port }}
|
||||
targetPort: 3900
|
||||
targetPort: {{ include "garage.portFromBindAddr" .Values.garage.s3.api.bindAddr | int }}
|
||||
protocol: TCP
|
||||
name: s3-api
|
||||
- port: {{ .Values.service.s3.web.port }}
|
||||
targetPort: 3902
|
||||
targetPort: {{ include "garage.portFromBindAddr" .Values.garage.s3.web.bindAddr | int }}
|
||||
protocol: TCP
|
||||
name: s3-web
|
||||
selector:
|
||||
|
||||
@@ -12,11 +12,11 @@ spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.s3.api.port }}
|
||||
targetPort: 3900
|
||||
targetPort: {{ include "garage.portFromBindAddr" .Values.garage.s3.api.bindAddr | int }}
|
||||
protocol: TCP
|
||||
name: s3-api
|
||||
- port: {{ .Values.service.s3.web.port }}
|
||||
targetPort: 3902
|
||||
targetPort: {{ include "garage.portFromBindAddr" .Values.garage.s3.web.bindAddr | int }}
|
||||
protocol: TCP
|
||||
name: s3-web
|
||||
selector:
|
||||
@@ -35,8 +35,8 @@ spec:
|
||||
type: ClusterIP
|
||||
clusterIP: None
|
||||
ports:
|
||||
- port: 3903
|
||||
targetPort: 3903
|
||||
- port: {{ include "garage.portFromBindAddr" .Values.garage.admin.apiBindAddr | int }}
|
||||
targetPort: {{ include "garage.portFromBindAddr" .Values.garage.admin.apiBindAddr | int }}
|
||||
protocol: TCP
|
||||
name: metrics
|
||||
selector:
|
||||
|
||||
@@ -28,6 +28,9 @@ spec:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "garage.serviceAccountName" . }}
|
||||
{{- with .Values.priorityClassName }}
|
||||
priorityClassName: {{ . }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
initContainers:
|
||||
@@ -57,11 +60,11 @@ spec:
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- containerPort: 3900
|
||||
- containerPort: {{ include "garage.portFromBindAddr" .Values.garage.s3.api.bindAddr | int }}
|
||||
name: s3-api
|
||||
- containerPort: 3902
|
||||
- containerPort: {{ include "garage.portFromBindAddr" .Values.garage.s3.web.bindAddr | int }}
|
||||
name: web-api
|
||||
- containerPort: 3903
|
||||
- containerPort: {{ include "garage.portFromBindAddr" .Values.garage.admin.apiBindAddr | int }}
|
||||
name: admin
|
||||
{{- with .Values.environment }}
|
||||
env:
|
||||
@@ -91,7 +94,7 @@ spec:
|
||||
volumes:
|
||||
- name: configmap
|
||||
configMap:
|
||||
name: {{ include "garage.fullname" . }}-config
|
||||
name: {{ if .Values.garage.existingConfigMap }}{{ .Values.garage.existingConfigMap }}{{ else }}{{ include "garage.fullname" . }}-config{{ end }}
|
||||
- name: etc
|
||||
emptyDir: {}
|
||||
{{- if .Values.persistence.enabled }}
|
||||
|
||||
@@ -48,11 +48,15 @@ garage:
|
||||
kubernetesSkipCrd: false
|
||||
s3:
|
||||
api:
|
||||
bindAddr: "[::]:3900"
|
||||
region: "garage"
|
||||
rootDomain: ".s3.garage.tld"
|
||||
web:
|
||||
bindAddr: "[::]:3902"
|
||||
rootDomain: ".web.garage.tld"
|
||||
index: "index.html"
|
||||
admin:
|
||||
apiBindAddr: "[::]:3903"
|
||||
|
||||
# -- Additional configuration to append to garage.toml. Use a multi-line string for custom config.
|
||||
# Example:
|
||||
@@ -221,14 +225,14 @@ resources: {}
|
||||
livenessProbe: {}
|
||||
#httpGet:
|
||||
# path: /health
|
||||
# port: 3903
|
||||
# port: 3903 # or the port from garage.admin.apiBindAddr
|
||||
#initialDelaySeconds: 5
|
||||
#periodSeconds: 30
|
||||
# -- Specifies a readinessProbe
|
||||
readinessProbe: {}
|
||||
#httpGet:
|
||||
# path: /health
|
||||
# port: 3903
|
||||
# port: 3903 # or the port from garage.admin.apiBindAddr
|
||||
#initialDelaySeconds: 5
|
||||
#periodSeconds: 30
|
||||
|
||||
@@ -238,6 +242,10 @@ tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
# -- Optional priority class name to assign to the pods.
|
||||
# See https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/
|
||||
priorityClassName: ""
|
||||
|
||||
environment: {}
|
||||
|
||||
extraVolumes: {}
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "api_request_counter",
|
||||
"field": "garage_api_request_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -284,7 +284,7 @@
|
||||
"hide": false,
|
||||
"metrics": [
|
||||
{
|
||||
"field": "api_request_duration",
|
||||
"field": "garage_api_request_duration",
|
||||
"id": "1",
|
||||
"type": "avg"
|
||||
}
|
||||
@@ -412,7 +412,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "api_error_counter",
|
||||
"field": "garage_api_error_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -540,7 +540,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "web_request_counter",
|
||||
"field": "garage_web_request_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -666,7 +666,7 @@
|
||||
"hide": false,
|
||||
"metrics": [
|
||||
{
|
||||
"field": "web_request_duration",
|
||||
"field": "garage_web_request_duration",
|
||||
"id": "1",
|
||||
"type": "avg"
|
||||
}
|
||||
@@ -794,7 +794,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "web_error_counter",
|
||||
"field": "garage_web_error_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -918,7 +918,7 @@
|
||||
"hide": false,
|
||||
"metrics": [
|
||||
{
|
||||
"field": "table_get_request_counter",
|
||||
"field": "garage_table_get_request_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -1042,7 +1042,7 @@
|
||||
"hide": false,
|
||||
"metrics": [
|
||||
{
|
||||
"field": "table_put_request_counter",
|
||||
"field": "garage_table_put_request_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -1154,7 +1154,7 @@
|
||||
"hide": false,
|
||||
"metrics": [
|
||||
{
|
||||
"field": "block_bytes_read",
|
||||
"field": "garage_block_bytes_read",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -1270,7 +1270,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "block_bytes_written",
|
||||
"field": "garage_block_bytes_written",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -1386,7 +1386,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "block_resync_counter",
|
||||
"field": "garage_block_resync_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -1500,7 +1500,7 @@
|
||||
"hide": false,
|
||||
"metrics": [
|
||||
{
|
||||
"field": "block_resync_queue_length",
|
||||
"field": "garage_block_resync_queue_length",
|
||||
"id": "1",
|
||||
"type": "avg"
|
||||
}
|
||||
@@ -1610,7 +1610,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "table_merkle_updater_todo_queue_length",
|
||||
"field": "garage_table_merkle_updater_todo_queue_length",
|
||||
"id": "1",
|
||||
"type": "avg"
|
||||
}
|
||||
@@ -1724,7 +1724,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "table_gc_todo_queue_length",
|
||||
"field": "garage_table_gc_todo_queue_length",
|
||||
"id": "1",
|
||||
"type": "avg"
|
||||
}
|
||||
@@ -1824,7 +1824,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "block_resync_error_counter",
|
||||
"field": "garage_block_resync_error_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"settings": {},
|
||||
@@ -1938,7 +1938,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "block_resync_errored_blocks",
|
||||
"field": "garage_block_resync_errored_blocks",
|
||||
"hide": false,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -2041,7 +2041,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "block_corruption_counter",
|
||||
"field": "garage_block_corruption_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -2165,7 +2165,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "rpc_netapp_error_counter",
|
||||
"field": "garage_rpc_netapp_error_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -2292,7 +2292,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "rpc_request_counter",
|
||||
"field": "garage_rpc_request_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -2418,7 +2418,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "rpc_duration",
|
||||
"field": "garage_rpc_duration",
|
||||
"id": "1",
|
||||
"type": "avg"
|
||||
}
|
||||
@@ -2521,7 +2521,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "admin_http_requests_total",
|
||||
"field": "garage_admin_http_requests_total",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -2654,7 +2654,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "rpc_garage_error_counter",
|
||||
"field": "garage_rpc_garage_error_count",
|
||||
"hide": true,
|
||||
"id": "1",
|
||||
"type": "sum"
|
||||
@@ -2765,7 +2765,7 @@
|
||||
},
|
||||
"metrics": [
|
||||
{
|
||||
"field": "rpc_duration",
|
||||
"field": "garage_rpc_duration",
|
||||
"id": "1",
|
||||
"type": "avg"
|
||||
}
|
||||
@@ -2995,4 +2995,4 @@
|
||||
"uid": "ODT8K4B7e",
|
||||
"version": 7,
|
||||
"weekStart": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
"uid": "${DS_DS_PROMETHEUS}"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "sum(rate(block_bytes_read{job=\"garage\"}[$__rate_interval]) )",
|
||||
"expr": "sum(rate(garage_block_bytes_read{job=\"garage\"}[$__rate_interval]) )",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "Disk bytes read",
|
||||
@@ -155,7 +155,7 @@
|
||||
"uid": "${DS_DS_PROMETHEUS}"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "-sum(rate(block_bytes_written{job=\"garage\"}[$__rate_interval]) )",
|
||||
"expr": "-sum(rate(garage_block_bytes_written{job=\"garage\"}[$__rate_interval]) )",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "Disk bytes written",
|
||||
@@ -250,7 +250,7 @@
|
||||
},
|
||||
"editorMode": "code",
|
||||
"exemplar": true,
|
||||
"expr": "sum by (api_endpoint) (rate(api_s3_request_counter {job=\"garage\"}[$__rate_interval]))",
|
||||
"expr": "sum by (api_endpoint) (rate(garage_api_s3_request_count {job=\"garage\"}[$__rate_interval]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "{{api_endpoint}}",
|
||||
@@ -345,7 +345,7 @@
|
||||
"uid": "${DS_DS_PROMETHEUS}"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "sum(rate(web_request_counter {job=\"garage\"}[$__rate_interval]))",
|
||||
"expr": "sum(rate(garage_web_request_count {job=\"garage\"}[$__rate_interval]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "Web request rate",
|
||||
@@ -439,7 +439,7 @@
|
||||
"uid": "${DS_DS_PROMETHEUS}"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "sum by (rpc_endpoint) (rate(rpc_request_counter {job=\"garage\"}[$__rate_interval]))",
|
||||
"expr": "sum by (rpc_endpoint) (rate(garage_rpc_request_count {job=\"garage\"}[$__rate_interval]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "{{rpc_endpoint}}",
|
||||
@@ -534,7 +534,7 @@
|
||||
},
|
||||
"editorMode": "code",
|
||||
"exemplar": true,
|
||||
"expr": "sum by (api_endpoint, status_code) (rate(api_s3_error_counter {job=\"garage\"}[$__rate_interval]))",
|
||||
"expr": "sum by (api_endpoint, status_code) (rate(garage_api_s3_error_count {job=\"garage\"}[$__rate_interval]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "{{api_endpoint}} {{status_code}}",
|
||||
@@ -629,7 +629,7 @@
|
||||
"uid": "${DS_DS_PROMETHEUS}"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "sum by(status_code) (rate(web_error_counter {job=\"garage\"}[$__rate_interval]))",
|
||||
"expr": "sum by(status_code) (rate(garage_web_error_count {job=\"garage\"}[$__rate_interval]))",
|
||||
"hide": false,
|
||||
"interval": "",
|
||||
"legendFormat": "{{status_code}}",
|
||||
@@ -722,7 +722,7 @@
|
||||
"uid": "${DS_DS_PROMETHEUS}"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "block_resync_queue_length{job=\"garage\"}",
|
||||
"expr": "garage_block_resync_queue_length{job=\"garage\"}",
|
||||
"interval": "",
|
||||
"legendFormat": "{{instance}}",
|
||||
"refId": "A"
|
||||
@@ -814,7 +814,7 @@
|
||||
"uid": "${DS_DS_PROMETHEUS}"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "sum by(table_name) (table_gc_todo_queue_length{job=\"garage\"})",
|
||||
"expr": "sum by(table_name) (garage_table_gc_todo_queue_length{job=\"garage\"})",
|
||||
"interval": "",
|
||||
"legendFormat": "{{ table_name}}",
|
||||
"refId": "A"
|
||||
@@ -906,7 +906,7 @@
|
||||
"uid": "${DS_DS_PROMETHEUS}"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "sum by(table_name) (table_merkle_updater_todo_queue_length{job=\"garage\"})",
|
||||
"expr": "sum by(table_name) (garage_table_merkle_updater_todo_queue_length{job=\"garage\"})",
|
||||
"interval": "",
|
||||
"legendFormat": "{{ table_name}}",
|
||||
"refId": "A"
|
||||
@@ -998,7 +998,7 @@
|
||||
"uid": "${DS_DS_PROMETHEUS}"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "block_resync_errored_blocks{job=\"garage\"}",
|
||||
"expr": "garage_block_resync_errored_blocks{job=\"garage\"}",
|
||||
"interval": "",
|
||||
"legendFormat": "{{instance}}",
|
||||
"refId": "A"
|
||||
@@ -1025,4 +1025,4 @@
|
||||
"uid": "ys3pnpZ4k",
|
||||
"version": 26,
|
||||
"weekStart": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_api_admin"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
@@ -48,3 +48,6 @@ prometheus = { workspace = true, optional = true }
|
||||
[features]
|
||||
metrics = ["opentelemetry-prometheus", "prometheus"]
|
||||
k2v = ["garage_model/k2v"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -7,6 +7,7 @@ use garage_util::time::now_msec;
|
||||
|
||||
use garage_model::admin_token_table::*;
|
||||
use garage_model::garage::Garage;
|
||||
use garage_model::permission::ExpirationTime;
|
||||
|
||||
use crate::api::*;
|
||||
use crate::error::*;
|
||||
@@ -244,8 +245,8 @@ fn admin_token_info_results(token: &AdminApiToken, now: u64) -> GetAdminTokenInf
|
||||
.expect("invalid timestamp stored in db"),
|
||||
),
|
||||
name: params.name.get().to_string(),
|
||||
expiration: params.expiration.get().map(|x| {
|
||||
DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db")
|
||||
expiration: params.expiration.get().inner().map(|x| {
|
||||
DateTime::from_timestamp_millis(x.0 as i64).expect("invalid timestamp stored in db")
|
||||
}),
|
||||
expired: params.is_expired(now),
|
||||
scope: params.scope.get().0.clone(),
|
||||
@@ -279,10 +280,10 @@ fn apply_token_updates(
|
||||
if let Some(expiration) = updates.expiration {
|
||||
params
|
||||
.expiration
|
||||
.update(Some(expiration.timestamp_millis() as u64));
|
||||
.update(Some(ExpirationTime(expiration.timestamp_millis() as u64)).into());
|
||||
}
|
||||
if updates.never_expires {
|
||||
params.expiration.update(None);
|
||||
params.expiration.update(None.into());
|
||||
}
|
||||
if let Some(scope) = updates.scope {
|
||||
params.scope.update(AdminApiTokenScope(scope));
|
||||
|
||||
@@ -12,7 +12,7 @@ use garage_rpc::*;
|
||||
|
||||
use garage_model::garage::Garage;
|
||||
|
||||
use garage_api_common::{common_error::CommonError, helpers::is_default};
|
||||
use garage_api_common::{common_error::CommonError, helpers::is_default, xml};
|
||||
|
||||
use crate::api_server::{find_matching_nodes, AdminRpc, AdminRpcResponse};
|
||||
use crate::error::Error;
|
||||
@@ -282,8 +282,34 @@ pub struct GetClusterHealthResponse {
|
||||
pub struct GetClusterStatisticsRequest;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetClusterStatisticsResponse {
|
||||
// FIXME for v3: remove freeform field and move display logic to garage crate
|
||||
/// cluster statistics as a free-form string, kept for compatibility with nodes
|
||||
/// running older v2.x versions of garage
|
||||
pub freeform: String,
|
||||
// FIXME for v3: remove Option<> and serde(default) for all fields below
|
||||
/// available storage space for object data in the entire cluster, in bytes
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_avail: Option<u64>,
|
||||
/// available storage space for object metadata in the entire cluster, in bytes
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub metadata_avail: Option<u64>,
|
||||
/// true if the available storage space statistics are imprecise due to missing
|
||||
/// information of disconnected nodes. When this is the case, the actual
|
||||
/// space available in the cluster might be lower than the reported values.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub incomplete_avail_info: Option<bool>,
|
||||
/// number of buckets in the cluster
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bucket_count: Option<u64>,
|
||||
/// total number of objects stored in all buckets
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub total_object_count: Option<u64>,
|
||||
/// total size of objects stored in all buckets, before compression, deduplication and
|
||||
/// replication (this is NOT equivalent to actual disk usage in the cluster)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub total_object_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
// ---- ConnectClusterNodes ----
|
||||
@@ -592,6 +618,10 @@ pub enum PreviewClusterLayoutChangesResponse {
|
||||
/// Plain-text information about the layout computation
|
||||
/// (do not try to parse this)
|
||||
message: Vec<String>,
|
||||
/// Structured statistics about the layout computation
|
||||
// FIXME for v3: remove default and skip_serializing_if
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
statistics: Option<Box<garage_rpc::layout::ComputationStat>>,
|
||||
/// Details about the new cluster layout
|
||||
new_layout: GetClusterLayoutResponse,
|
||||
},
|
||||
@@ -613,6 +643,10 @@ pub struct ApplyClusterLayoutResponse {
|
||||
/// Plain-text information about the layout computation
|
||||
/// (do not try to parse this)
|
||||
pub message: Vec<String>,
|
||||
/// Structured statistics about the layout computation
|
||||
// FIXME for v3: remove default and skip_serializing_if
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub statistics: Option<garage_rpc::layout::ComputationStat>,
|
||||
/// Details about the new cluster layout
|
||||
pub layout: GetClusterLayoutResponse,
|
||||
}
|
||||
@@ -843,9 +877,16 @@ pub struct GetBucketInfoResponse {
|
||||
pub global_aliases: Vec<String>,
|
||||
/// Whether website access is enabled for this bucket
|
||||
pub website_access: bool,
|
||||
#[serde(default)]
|
||||
/// Website configuration for this bucket
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub website_config: Option<GetBucketInfoWebsiteResponse>,
|
||||
// FIXME for v3: remove serde(default) for the two fields below
|
||||
/// CORS rules for this bucket
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cors_rules: Option<Vec<xml::cors::CorsRule>>,
|
||||
/// Object lifecycle rules for this bucket
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lifecycle_rules: Option<Vec<xml::lifecycle::LifecycleRule>>,
|
||||
/// List of access keys that have permissions granted on this bucket
|
||||
pub keys: Vec<GetBucketInfoKey>,
|
||||
/// Number of objects in this bucket
|
||||
@@ -869,6 +910,9 @@ pub struct GetBucketInfoResponse {
|
||||
pub struct GetBucketInfoWebsiteResponse {
|
||||
pub index_document: String,
|
||||
pub error_document: Option<String>,
|
||||
// FIXME for v3: remove serde(default) for field below
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub routing_rules: Option<Vec<xml::website::RoutingRule>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -927,6 +971,11 @@ pub struct UpdateBucketResponse(pub GetBucketInfoResponse);
|
||||
pub struct UpdateBucketRequestBody {
|
||||
pub website_access: Option<UpdateBucketWebsiteAccess>,
|
||||
pub quotas: Option<ApiBucketQuotas>,
|
||||
// FIXME for v3: remove serde(default) for the two fields below
|
||||
#[serde(default)]
|
||||
pub cors_rules: Option<Vec<xml::cors::CorsRule>>,
|
||||
#[serde(default)]
|
||||
pub lifecycle_rules: Option<Vec<xml::lifecycle::LifecycleRule>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -935,6 +984,9 @@ pub struct UpdateBucketWebsiteAccess {
|
||||
pub enabled: bool,
|
||||
pub index_document: Option<String>,
|
||||
pub error_document: Option<String>,
|
||||
// FIXME for v3: remove serde(default) for field below
|
||||
#[serde(default)]
|
||||
pub routing_rules: Option<Vec<xml::website::RoutingRule>>,
|
||||
}
|
||||
|
||||
// ---- DeleteBucket ----
|
||||
@@ -1109,10 +1161,41 @@ pub struct LocalGetNodeInfoRequest;
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalGetNodeInfoResponse {
|
||||
pub node_id: String,
|
||||
// FIXME for v3: remove Option<> and serde(default) for field below
|
||||
/// hostname of this node
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hostname: Option<String>,
|
||||
/// garage version running on this node
|
||||
pub garage_version: String,
|
||||
/// build-time features enabled for this garage release
|
||||
pub garage_features: Option<Vec<String>>,
|
||||
/// rustc version with which this garage release was compiled
|
||||
pub rust_version: String,
|
||||
/// database engine used for metadata
|
||||
pub db_engine: String,
|
||||
// FIXME for v3: remove Option<> and serde(default) for field below
|
||||
// FIXME for v3: merge LocalGetNodeInfoResponse and NodeResp
|
||||
/// Socket address used by other nodes to connect to this node for RPC
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schema(value_type = Option<String>)]
|
||||
pub addr: Option<SocketAddr>,
|
||||
/// Whether this node is connected in the cluster
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_up: Option<bool>,
|
||||
/// Role assigned to this node in the current cluster layout
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<NodeAssignedRole>,
|
||||
/// Whether this node is part of an older layout version and is draining data.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub draining: Option<bool>,
|
||||
/// Total and available space on the disk partition(s) containing the data
|
||||
/// directory(ies)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_partition: Option<FreeSpaceResp>,
|
||||
/// Total and available space on the disk partition containing the
|
||||
/// metadata directory
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub metadata_partition: Option<FreeSpaceResp>,
|
||||
}
|
||||
|
||||
// ---- GetNodeStatistics ----
|
||||
@@ -1121,8 +1204,47 @@ pub struct LocalGetNodeInfoResponse {
|
||||
pub struct LocalGetNodeStatisticsRequest;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalGetNodeStatisticsResponse {
|
||||
// FIXME for v3: remove freeform field and move display logic to garage crate
|
||||
/// node statistics as a free-form string, kept for compatibility with nodes
|
||||
/// running older v2.x versions of garage
|
||||
pub freeform: String,
|
||||
// FIXME for v3: remove serde(default) for fields below
|
||||
/// metadata table statistics
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table_stats: Option<Vec<NodeTableStats>>,
|
||||
/// block manager statistics
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub block_manager_stats: Option<NodeBlockManagerStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeTableStats {
|
||||
/// name of metadata table
|
||||
pub table_name: String,
|
||||
/// number of items stored in metadata table
|
||||
pub items: u64,
|
||||
/// size of the merkle tree representing all items in the table
|
||||
pub merkle_items: u64,
|
||||
/// number of items in the merkle tree update queue
|
||||
pub merkle_queue_len: u64,
|
||||
/// number of items in the remote insert queue
|
||||
pub insert_queue_len: u64,
|
||||
/// number of items in the garbage collection queue
|
||||
pub gc_queue_len: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeBlockManagerStats {
|
||||
/// number of reference counter entries
|
||||
pub rc_entries: u64,
|
||||
/// number of blocks in the resync queue
|
||||
pub resync_queue_len: u64,
|
||||
/// number of blocks with resync errors
|
||||
pub resync_errors: u64,
|
||||
}
|
||||
|
||||
// ---- CreateMetadataSnapshot ----
|
||||
|
||||
@@ -18,6 +18,7 @@ use garage_model::s3::mpu_table;
|
||||
use garage_model::s3::object_table::*;
|
||||
|
||||
use garage_api_common::common_error::CommonError;
|
||||
use garage_api_common::xml;
|
||||
|
||||
use crate::api::*;
|
||||
use crate::error::*;
|
||||
@@ -37,7 +38,7 @@ impl RequestHandler for ListBucketsRequest {
|
||||
&EmptyKey,
|
||||
None,
|
||||
Some(DeletedFilter::NotDeleted),
|
||||
10000,
|
||||
1_000_000,
|
||||
EnumerationOrder::Forward,
|
||||
)
|
||||
.await?;
|
||||
@@ -89,7 +90,7 @@ impl RequestHandler for GetBucketInfoRequest {
|
||||
.bucket_alias_table
|
||||
.get(&EmptyKey, &ga)
|
||||
.await?
|
||||
.and_then(|x| *x.state.get())
|
||||
.and_then(|x| x.state.get().into_inner())
|
||||
.ok_or_else(|| HelperError::NoSuchBucket(ga.to_string()))?,
|
||||
(None, None, Some(search)) => {
|
||||
let helper = garage.bucket_helper();
|
||||
@@ -167,7 +168,7 @@ impl RequestHandler for CreateBucketRequest {
|
||||
}
|
||||
|
||||
if let Some(alias) = garage.bucket_alias_table.get(&EmptyKey, ga).await? {
|
||||
if alias.state.get().is_some() {
|
||||
if alias.state.get().inner().is_some() {
|
||||
return Err(CommonError::BucketAlreadyExists.into());
|
||||
}
|
||||
}
|
||||
@@ -293,25 +294,46 @@ impl RequestHandler for UpdateBucketRequest {
|
||||
|
||||
if let Some(wa) = self.body.website_access {
|
||||
if wa.enabled {
|
||||
let (redirect_all, routing_rules) = match state.website_config.get() {
|
||||
Some(wc) => (wc.redirect_all.clone(), wc.routing_rules.clone()),
|
||||
None => (None, Vec::new()),
|
||||
let redirect_all = state
|
||||
.website_config
|
||||
.get()
|
||||
.inner()
|
||||
.and_then(|wc| wc.redirect_all.clone());
|
||||
|
||||
let routing_rules = if let Some(rr) = wa.routing_rules {
|
||||
for r in rr.iter() {
|
||||
r.validate()?;
|
||||
}
|
||||
rr.into_iter()
|
||||
.map(xml::website::RoutingRule::into_garage_routing_rule)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
state
|
||||
.website_config
|
||||
.get()
|
||||
.inner()
|
||||
.map(|wc| wc.routing_rules.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
state.website_config.update(Some(WebsiteConfig {
|
||||
index_document: wa.index_document.ok_or_bad_request(
|
||||
"Please specify indexDocument when enabling website access.",
|
||||
)?,
|
||||
error_document: wa.error_document,
|
||||
redirect_all,
|
||||
routing_rules,
|
||||
}));
|
||||
|
||||
state.website_config.update(
|
||||
Some(WebsiteConfig {
|
||||
index_document: wa.index_document.ok_or_bad_request(
|
||||
"Please specify indexDocument when enabling website access.",
|
||||
)?,
|
||||
error_document: wa.error_document,
|
||||
redirect_all,
|
||||
routing_rules,
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
if wa.index_document.is_some() || wa.error_document.is_some() {
|
||||
return Err(Error::bad_request(
|
||||
"Cannot specify indexDocument or errorDocument when disabling website access.",
|
||||
));
|
||||
}
|
||||
state.website_config.update(None);
|
||||
state.website_config.update(None.into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +344,38 @@ impl RequestHandler for UpdateBucketRequest {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(cr) = self.body.cors_rules {
|
||||
let cors_config = if cr.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let cc = xml::cors::CorsConfiguration {
|
||||
xmlns: (),
|
||||
cors_rules: cr,
|
||||
};
|
||||
cc.validate()?;
|
||||
Some(cc.into_garage_cors_config()?)
|
||||
};
|
||||
|
||||
state.cors_config.update(cors_config.into());
|
||||
}
|
||||
|
||||
if let Some(lr) = self.body.lifecycle_rules {
|
||||
let lifecycle_config = if lr.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let lc = xml::lifecycle::LifecycleConfiguration {
|
||||
xmlns: (),
|
||||
lifecycle_rules: lr,
|
||||
};
|
||||
Some(
|
||||
lc.validate_into_garage_lifecycle_config()
|
||||
.ok_or_bad_request("Invalid lifecycle configuration")?,
|
||||
)
|
||||
};
|
||||
|
||||
state.lifecycle_config.update(lifecycle_config.into());
|
||||
}
|
||||
|
||||
garage.bucket_table.insert(&bucket).await?;
|
||||
|
||||
Ok(UpdateBucketResponse(
|
||||
@@ -557,7 +611,7 @@ impl RequestHandler for AddBucketAliasRequest {
|
||||
BucketAliasEnum::Global { global_alias } => {
|
||||
helper
|
||||
.set_global_bucket_alias(bucket_id, &global_alias)
|
||||
.await?
|
||||
.await?;
|
||||
}
|
||||
BucketAliasEnum::Local {
|
||||
local_alias,
|
||||
@@ -565,7 +619,7 @@ impl RequestHandler for AddBucketAliasRequest {
|
||||
} => {
|
||||
helper
|
||||
.set_local_bucket_alias(bucket_id, &access_key_id, &local_alias)
|
||||
.await?
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,7 +645,7 @@ impl RequestHandler for RemoveBucketAliasRequest {
|
||||
BucketAliasEnum::Global { global_alias } => {
|
||||
helper
|
||||
.unset_global_bucket_alias(bucket_id, &global_alias)
|
||||
.await?
|
||||
.await?;
|
||||
}
|
||||
BucketAliasEnum::Local {
|
||||
local_alias,
|
||||
@@ -599,7 +653,7 @@ impl RequestHandler for RemoveBucketAliasRequest {
|
||||
} => {
|
||||
helper
|
||||
.unset_local_bucket_alias(bucket_id, &access_key_id, &local_alias)
|
||||
.await?
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,13 +742,30 @@ async fn bucket_info_results(
|
||||
.filter(|(_, _, a)| *a)
|
||||
.map(|(n, _, _)| n.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
website_access: state.website_config.get().is_some(),
|
||||
website_config: state.website_config.get().clone().map(|wsc| {
|
||||
website_access: state.website_config.get().inner().is_some(),
|
||||
website_config: state.website_config.get().inner().cloned().map(|wsc| {
|
||||
GetBucketInfoWebsiteResponse {
|
||||
index_document: wsc.index_document,
|
||||
error_document: wsc.error_document,
|
||||
routing_rules: Some(
|
||||
wsc.routing_rules
|
||||
.into_iter()
|
||||
.map(xml::website::RoutingRule::from_garage_routing_rule)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
}
|
||||
}),
|
||||
cors_rules: state.cors_config.get().inner().map(|rules| {
|
||||
rules
|
||||
.iter()
|
||||
.map(xml::cors::CorsRule::from_garage_cors_rule)
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
lifecycle_rules: state.lifecycle_config.get().inner().map(|lc| {
|
||||
lc.iter()
|
||||
.map(xml::lifecycle::LifecycleRule::from_garage_lifecycle_rule)
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
keys: relevant_keys
|
||||
.into_values()
|
||||
.filter_map(|key| {
|
||||
@@ -716,7 +787,7 @@ async fn bucket_info_results(
|
||||
.local_aliases
|
||||
.items()
|
||||
.iter()
|
||||
.filter(|(_, _, b)| *b == Some(bucket.id))
|
||||
.filter(|(_, _, b)| b.into_inner() == Some(bucket.id))
|
||||
.map(|(n, _, _)| n.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
|
||||
@@ -8,8 +8,10 @@ use garage_util::data::*;
|
||||
|
||||
use garage_rpc::layout;
|
||||
use garage_rpc::layout::PARTITION_BITS;
|
||||
use garage_table::*;
|
||||
|
||||
use garage_model::garage::Garage;
|
||||
use garage_model::s3::object_table;
|
||||
|
||||
use crate::api::*;
|
||||
use crate::error::*;
|
||||
@@ -152,7 +154,6 @@ impl RequestHandler for GetClusterHealthRequest {
|
||||
impl RequestHandler for GetClusterStatisticsRequest {
|
||||
type Response = GetClusterStatisticsResponse;
|
||||
|
||||
// FIXME: return this as a JSON struct instead of text
|
||||
async fn handle(
|
||||
self,
|
||||
garage: &Arc<Garage>,
|
||||
@@ -160,8 +161,60 @@ impl RequestHandler for GetClusterStatisticsRequest {
|
||||
) -> Result<GetClusterStatisticsResponse, Error> {
|
||||
let mut ret = String::new();
|
||||
|
||||
// Gather storage node and free space statistics for current nodes
|
||||
// Gather info on number of buckets, objects and object size
|
||||
let buckets = garage
|
||||
.bucket_table
|
||||
.get_range(
|
||||
&EmptyKey,
|
||||
None,
|
||||
Some(DeletedFilter::NotDeleted),
|
||||
1_000_000,
|
||||
EnumerationOrder::Forward,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let bucket_stats_opt = if buckets.len() < 1000 {
|
||||
futures::future::try_join_all(
|
||||
buckets
|
||||
.iter()
|
||||
.map(|b| garage.object_counter_table.table.get(&b.id, &EmptyKey)),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let layout = &garage.system.cluster_layout();
|
||||
|
||||
let bucket_count = buckets.len() as u64;
|
||||
let (total_object_count, total_object_bytes);
|
||||
if let Some(bucket_stats) = bucket_stats_opt {
|
||||
let bucket_stats = bucket_stats
|
||||
.into_iter()
|
||||
.filter_map(|cnt| cnt.map(|x| x.filtered_values(layout)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
total_object_count = Some(
|
||||
bucket_stats
|
||||
.iter()
|
||||
.clone()
|
||||
.map(|cnt| *cnt.get(object_table::OBJECTS).unwrap_or(&0) as u64)
|
||||
.sum(),
|
||||
);
|
||||
total_object_bytes = Some(
|
||||
bucket_stats
|
||||
.iter()
|
||||
.clone()
|
||||
.map(|cnt| *cnt.get(object_table::BYTES).unwrap_or(&0) as u64)
|
||||
.sum(),
|
||||
);
|
||||
} else {
|
||||
total_object_count = None;
|
||||
total_object_bytes = None;
|
||||
}
|
||||
|
||||
// Gather storage node and free space statistics for current nodes
|
||||
let mut node_partition_count = HashMap::<Uuid, u64>::new();
|
||||
if let Ok(current_layout) = layout.current() {
|
||||
for short_id in current_layout.ring_assignment_data.iter() {
|
||||
@@ -231,33 +284,57 @@ impl RequestHandler for GetClusterStatisticsRequest {
|
||||
.map(|c| c.0 / *parts)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !meta_part_avail.is_empty() && !data_part_avail.is_empty() {
|
||||
let meta_avail =
|
||||
bytesize::ByteSize(meta_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS));
|
||||
let data_avail =
|
||||
bytesize::ByteSize(data_part_avail.iter().min().unwrap() * (1 << PARTITION_BITS));
|
||||
writeln!(
|
||||
&mut ret,
|
||||
"\nEstimated available storage space cluster-wide (might be lower in practice):"
|
||||
)
|
||||
.unwrap();
|
||||
if meta_part_avail.len() < node_partition_count.len()
|
||||
|| data_part_avail.len() < node_partition_count.len()
|
||||
{
|
||||
ret += &format_table_to_string(vec![
|
||||
format!(" data: < {}", data_avail),
|
||||
format!(" metadata: < {}", meta_avail),
|
||||
]);
|
||||
writeln!(&mut ret, "A precise estimate could not be given as information is missing for some storage nodes.").unwrap();
|
||||
} else {
|
||||
ret += &format_table_to_string(vec![
|
||||
format!(" data: {}", data_avail),
|
||||
format!(" metadata: {}", meta_avail),
|
||||
]);
|
||||
}
|
||||
|
||||
let metadata_avail: u64 =
|
||||
meta_part_avail.iter().min().unwrap_or(&0) * (1 << PARTITION_BITS);
|
||||
let data_avail: u64 = data_part_avail.iter().min().unwrap_or(&0) * (1 << PARTITION_BITS);
|
||||
|
||||
let metadata_avail_str = bytesize::ByteSize(metadata_avail);
|
||||
let data_avail_str = bytesize::ByteSize(data_avail);
|
||||
|
||||
let incomplete_info = meta_part_avail.len() < node_partition_count.len()
|
||||
|| data_part_avail.len() < node_partition_count.len();
|
||||
|
||||
// Display bucket statistics
|
||||
let mut bucket_stats = vec![format!("Number of buckets:\t{}", bucket_count)];
|
||||
if let Some(toc) = total_object_count {
|
||||
bucket_stats.push(format!("Total number of objects:\t{}", toc));
|
||||
}
|
||||
if let Some(tob) = total_object_bytes {
|
||||
bucket_stats.push(format!(
|
||||
"Total size of objects:\t{}",
|
||||
bytesize::ByteSize(tob)
|
||||
));
|
||||
}
|
||||
writeln!(&mut ret, "\n{}", format_table_to_string(bucket_stats)).unwrap();
|
||||
|
||||
writeln!(
|
||||
&mut ret,
|
||||
"Estimated available storage space cluster-wide (might be lower in practice):"
|
||||
)
|
||||
.unwrap();
|
||||
if incomplete_info {
|
||||
ret += &format_table_to_string(vec![
|
||||
format!(" data: < {}", data_avail_str),
|
||||
format!(" metadata: < {}", metadata_avail_str),
|
||||
]);
|
||||
writeln!(&mut ret, "A precise estimate could not be given as information is missing for some storage nodes.").unwrap();
|
||||
} else {
|
||||
ret += &format_table_to_string(vec![
|
||||
format!(" data: {}", data_avail_str),
|
||||
format!(" metadata: {}", metadata_avail_str),
|
||||
]);
|
||||
}
|
||||
|
||||
Ok(GetClusterStatisticsResponse { freeform: ret })
|
||||
Ok(GetClusterStatisticsResponse {
|
||||
freeform: ret,
|
||||
metadata_avail: Some(metadata_avail),
|
||||
data_avail: Some(data_avail),
|
||||
incomplete_avail_info: Some(incomplete_info),
|
||||
bucket_count: Some(bucket_count),
|
||||
total_object_count,
|
||||
total_object_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use garage_util::time::now_msec;
|
||||
|
||||
use garage_model::garage::Garage;
|
||||
use garage_model::key_table::*;
|
||||
use garage_model::permission::ExpirationTime;
|
||||
|
||||
use crate::api::*;
|
||||
use crate::error::*;
|
||||
@@ -40,8 +41,8 @@ impl RequestHandler for ListKeysRequest {
|
||||
DateTime::from_timestamp_millis(x as i64)
|
||||
.expect("invalid timestamp stored in db")
|
||||
}),
|
||||
expiration: p.expiration.get().map(|x| {
|
||||
DateTime::from_timestamp_millis(x as i64)
|
||||
expiration: p.expiration.get().inner().map(|x| {
|
||||
DateTime::from_timestamp_millis(x.0 as i64)
|
||||
.expect("invalid timestamp stored in db")
|
||||
}),
|
||||
expired: p.is_expired(now),
|
||||
@@ -76,7 +77,9 @@ impl RequestHandler for GetKeyInfoRequest {
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.len() != 1 {
|
||||
if candidates.is_empty() {
|
||||
return Err(Error::NoSuchAccessKey(search.clone()));
|
||||
} else if candidates.len() != 1 {
|
||||
return Err(Error::bad_request(format!(
|
||||
"{} matching keys",
|
||||
candidates.len()
|
||||
@@ -199,7 +202,7 @@ async fn key_info_results(
|
||||
.local_aliases
|
||||
.items()
|
||||
.iter()
|
||||
.filter_map(|(_, _, v)| v.as_ref()),
|
||||
.filter_map(|(_, _, v)| v.inner()),
|
||||
) {
|
||||
if !relevant_buckets.contains_key(id) {
|
||||
if let Some(b) = garage.bucket_table.get(&EmptyKey, id).await? {
|
||||
@@ -215,8 +218,8 @@ async fn key_info_results(
|
||||
created: key_state.created.map(|x| {
|
||||
DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db")
|
||||
}),
|
||||
expiration: key_state.expiration.get().map(|x| {
|
||||
DateTime::from_timestamp_millis(x as i64).expect("invalid timestamp stored in db")
|
||||
expiration: key_state.expiration.get().inner().map(|x| {
|
||||
DateTime::from_timestamp_millis(x.0 as i64).expect("invalid timestamp stored in db")
|
||||
}),
|
||||
expired: key_state.is_expired(now_msec()),
|
||||
access_key_id: key.key_id.clone(),
|
||||
@@ -281,10 +284,10 @@ fn apply_key_updates(key: &mut Key, updates: UpdateKeyRequestBody) -> Result<(),
|
||||
if let Some(expiration) = updates.expiration {
|
||||
key_state
|
||||
.expiration
|
||||
.update(Some(expiration.timestamp_millis() as u64));
|
||||
.update(Some(ExpirationTime(expiration.timestamp_millis() as u64)).into());
|
||||
}
|
||||
if updates.never_expires {
|
||||
key_state.expiration.update(None);
|
||||
key_state.expiration.update(None.into());
|
||||
}
|
||||
if let Some(allow) = updates.allow {
|
||||
if allow.create_bucket {
|
||||
|
||||
@@ -53,7 +53,7 @@ fn format_cluster_layout(layout: &layout::LayoutHistory) -> GetClusterLayoutResp
|
||||
.roles
|
||||
.items()
|
||||
.iter()
|
||||
.filter(|(k, _, v)| current.roles.get(k) != Some(v))
|
||||
.filter(|(k, _, v)| current.roles.get(k).and_then(|vv| vv.0.as_ref()) != v.0.as_ref())
|
||||
.map(|(k, _, v)| match &v.0 {
|
||||
None => NodeRoleChange {
|
||||
id: hex::encode(k),
|
||||
@@ -255,10 +255,14 @@ impl RequestHandler for PreviewClusterLayoutChangesRequest {
|
||||
Ok(PreviewClusterLayoutChangesResponse::Error { error })
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
Ok((new_layout, msg)) => Ok(PreviewClusterLayoutChangesResponse::Success {
|
||||
message: msg,
|
||||
new_layout: format_cluster_layout(&new_layout),
|
||||
}),
|
||||
Ok((new_layout, stat)) => {
|
||||
let message = stat.to_message();
|
||||
Ok(PreviewClusterLayoutChangesResponse::Success {
|
||||
message,
|
||||
statistics: Some(Box::new(stat)),
|
||||
new_layout: format_cluster_layout(&new_layout),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,7 +276,8 @@ impl RequestHandler for ApplyClusterLayoutRequest {
|
||||
_admin: &Admin,
|
||||
) -> Result<ApplyClusterLayoutResponse, Error> {
|
||||
let layout = garage.system.cluster_layout().inner().clone();
|
||||
let (layout, msg) = layout.apply_staged_changes(self.version)?;
|
||||
let (layout, stat) = layout.apply_staged_changes(self.version)?;
|
||||
let message = stat.to_message();
|
||||
|
||||
garage
|
||||
.system
|
||||
@@ -281,7 +286,8 @@ impl RequestHandler for ApplyClusterLayoutRequest {
|
||||
.await?;
|
||||
|
||||
Ok(ApplyClusterLayoutResponse {
|
||||
message: msg,
|
||||
message,
|
||||
statistics: Some(stat),
|
||||
layout: format_cluster_layout(&layout),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,13 +22,55 @@ impl RequestHandler for LocalGetNodeInfoRequest {
|
||||
garage: &Arc<Garage>,
|
||||
_admin: &Admin,
|
||||
) -> Result<LocalGetNodeInfoResponse, Error> {
|
||||
let sys_status = garage.system.local_status();
|
||||
let hostname = sys_status.hostname.unwrap_or_default().to_string();
|
||||
|
||||
let layout = garage.system.cluster_layout();
|
||||
let current_layout = layout.inner().current();
|
||||
|
||||
Ok(LocalGetNodeInfoResponse {
|
||||
node_id: hex::encode(garage.system.id),
|
||||
hostname: Some(hostname),
|
||||
garage_version: garage_util::version::garage_version().to_string(),
|
||||
garage_features: garage_util::version::garage_features()
|
||||
.map(|features| features.iter().map(ToString::to_string).collect()),
|
||||
rust_version: garage_util::version::rust_version().to_string(),
|
||||
db_engine: garage.db.engine(),
|
||||
is_up: Some(true),
|
||||
addr: garage
|
||||
.system
|
||||
.get_known_nodes()
|
||||
.iter()
|
||||
.find(|x| x.id == garage.system.id)
|
||||
.and_then(|x| x.addr),
|
||||
draining: Some(
|
||||
current_layout.node_role(&garage.system.id).is_none()
|
||||
&& layout
|
||||
.inner()
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|x| x.version != current_layout.version)
|
||||
.any(|x| x.node_role(&garage.system.id).is_some()),
|
||||
),
|
||||
role: current_layout
|
||||
.node_role(&garage.system.id)
|
||||
.map(|v| NodeAssignedRole {
|
||||
zone: v.zone.clone(),
|
||||
capacity: v.capacity,
|
||||
tags: v.tags.clone(),
|
||||
}),
|
||||
data_partition: sys_status
|
||||
.data_disk_avail
|
||||
.map(|(avail, total)| FreeSpaceResp {
|
||||
available: avail,
|
||||
total,
|
||||
}),
|
||||
metadata_partition: sys_status
|
||||
.meta_disk_avail
|
||||
.map(|(avail, total)| FreeSpaceResp {
|
||||
available: avail,
|
||||
total,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -57,46 +99,58 @@ impl RequestHandler for LocalGetNodeStatisticsRequest {
|
||||
) -> Result<LocalGetNodeStatisticsResponse, Error> {
|
||||
let sys_status = garage.system.local_status();
|
||||
|
||||
let hostname = sys_status.hostname.unwrap_or_default().to_string();
|
||||
let garage_version = garage_util::version::garage_version().to_string();
|
||||
let garage_features = garage_util::version::garage_features()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<String>>();
|
||||
let rustc_version = garage_util::version::rust_version().to_string();
|
||||
let db_engine_descr = garage.db.engine();
|
||||
|
||||
let mut ret = format_table_to_string(vec![
|
||||
format!("Node ID:\t{:?}", garage.system.id),
|
||||
format!("Hostname:\t{}", sys_status.hostname.unwrap_or_default(),),
|
||||
format!(
|
||||
"Garage version:\t{}",
|
||||
garage_util::version::garage_version(),
|
||||
),
|
||||
format!(
|
||||
"Garage features:\t{}",
|
||||
garage_util::version::garage_features()
|
||||
.map(|list| list.join(", "))
|
||||
.unwrap_or_else(|| "(unknown)".into()),
|
||||
),
|
||||
format!(
|
||||
"Rust compiler version:\t{}",
|
||||
garage_util::version::rust_version(),
|
||||
),
|
||||
format!("Database engine:\t{}", garage.db.engine()),
|
||||
format!("Hostname:\t{}", hostname),
|
||||
format!("Garage version:\t{}", garage_version),
|
||||
format!("Garage features:\t{}", garage_features.join(", ")),
|
||||
format!("Rust compiler version:\t{}", rustc_version),
|
||||
format!("Database engine:\t{}", db_engine_descr),
|
||||
]);
|
||||
|
||||
// Gather table statistics
|
||||
let mut table = vec![" Table\tItems\tMklItems\tMklTodo\tInsQueue\tGcTodo".into()];
|
||||
table.push(gather_table_stats(&garage.admin_token_table)?);
|
||||
table.push(gather_table_stats(&garage.bucket_table)?);
|
||||
table.push(gather_table_stats(&garage.bucket_alias_table)?);
|
||||
table.push(gather_table_stats(&garage.key_table)?);
|
||||
|
||||
table.push(gather_table_stats(&garage.object_table)?);
|
||||
table.push(gather_table_stats(&garage.object_counter_table.table)?);
|
||||
table.push(gather_table_stats(&garage.mpu_table)?);
|
||||
table.push(gather_table_stats(&garage.mpu_counter_table.table)?);
|
||||
table.push(gather_table_stats(&garage.version_table)?);
|
||||
table.push(gather_table_stats(&garage.block_ref_table)?);
|
||||
let mut table_stats = vec![
|
||||
gather_table_stats(&garage.admin_token_table)?,
|
||||
gather_table_stats(&garage.bucket_table)?,
|
||||
gather_table_stats(&garage.bucket_alias_table)?,
|
||||
gather_table_stats(&garage.key_table)?,
|
||||
gather_table_stats(&garage.object_table)?,
|
||||
gather_table_stats(&garage.object_counter_table.table)?,
|
||||
gather_table_stats(&garage.mpu_table)?,
|
||||
gather_table_stats(&garage.mpu_counter_table.table)?,
|
||||
gather_table_stats(&garage.version_table)?,
|
||||
gather_table_stats(&garage.block_ref_table)?,
|
||||
];
|
||||
|
||||
#[cfg(feature = "k2v")]
|
||||
{
|
||||
table.push(gather_table_stats(&garage.k2v.item_table)?);
|
||||
table.push(gather_table_stats(&garage.k2v.counter_table.table)?);
|
||||
table_stats.push(gather_table_stats(&garage.k2v.item_table)?);
|
||||
table_stats.push(gather_table_stats(&garage.k2v.counter_table.table)?);
|
||||
}
|
||||
|
||||
// Gather table statistics
|
||||
let mut table = vec![" Table\tItems\tMklItems\tMklTodo\tInsQueue\tGcTodo".into()];
|
||||
table.extend(table_stats.iter().map(|ts| {
|
||||
format!(
|
||||
" {}\t{}\t{}\t{}\t{}\t{}",
|
||||
ts.table_name,
|
||||
ts.items,
|
||||
ts.merkle_items,
|
||||
ts.merkle_queue_len,
|
||||
ts.insert_queue_len,
|
||||
ts.gc_queue_len,
|
||||
)
|
||||
}));
|
||||
|
||||
write!(
|
||||
&mut ret,
|
||||
"\nTable stats:\n{}",
|
||||
@@ -104,46 +158,52 @@ impl RequestHandler for LocalGetNodeStatisticsRequest {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let block_manager_stats = NodeBlockManagerStats {
|
||||
rc_entries: garage.block_manager.rc_approximate_len()? as u64,
|
||||
resync_queue_len: garage.block_manager.resync.queue_approximate_len()? as u64,
|
||||
resync_errors: garage.block_manager.resync.errors_approximate_len()? as u64,
|
||||
};
|
||||
|
||||
// Gather block manager statistics
|
||||
writeln!(&mut ret, "\nBlock manager stats:").unwrap();
|
||||
let rc_len = garage.block_manager.rc_approximate_len()?.to_string();
|
||||
|
||||
ret += &format_table_to_string(vec![
|
||||
format!(" number of RC entries:\t{} (~= number of blocks)", rc_len),
|
||||
format!(
|
||||
" number of RC entries:\t{} (~= number of blocks)",
|
||||
block_manager_stats.rc_entries
|
||||
),
|
||||
format!(
|
||||
" resync queue length:\t{}",
|
||||
garage.block_manager.resync.queue_approximate_len()?
|
||||
block_manager_stats.resync_queue_len,
|
||||
),
|
||||
format!(
|
||||
" blocks with resync errors:\t{}",
|
||||
garage.block_manager.resync.errors_approximate_len()?
|
||||
block_manager_stats.resync_errors
|
||||
),
|
||||
]);
|
||||
|
||||
Ok(LocalGetNodeStatisticsResponse { freeform: ret })
|
||||
Ok(LocalGetNodeStatisticsResponse {
|
||||
freeform: ret,
|
||||
table_stats: Some(table_stats),
|
||||
block_manager_stats: Some(block_manager_stats),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn gather_table_stats<F, R>(t: &Arc<Table<F, R>>) -> Result<String, Error>
|
||||
fn gather_table_stats<F, R>(t: &Arc<Table<F, R>>) -> Result<NodeTableStats, Error>
|
||||
where
|
||||
F: TableSchema + 'static,
|
||||
R: TableReplication + 'static,
|
||||
{
|
||||
let data_len = t
|
||||
.data
|
||||
.store
|
||||
.approximate_len()
|
||||
.map_err(GarageError::from)?
|
||||
.to_string();
|
||||
let mkl_len = t.merkle_updater.merkle_tree_approximate_len()?.to_string();
|
||||
let data_len = t.data.store.approximate_len().map_err(GarageError::from)?;
|
||||
let mkl_len = t.merkle_updater.merkle_tree_approximate_len()?;
|
||||
|
||||
Ok(format!(
|
||||
" {}\t{}\t{}\t{}\t{}\t{}",
|
||||
F::TABLE_NAME,
|
||||
data_len,
|
||||
mkl_len,
|
||||
t.merkle_updater.todo_approximate_len()?,
|
||||
t.data.insert_queue_approximate_len()?,
|
||||
t.data.gc_todo_approximate_len()?
|
||||
))
|
||||
Ok(NodeTableStats {
|
||||
table_name: F::TABLE_NAME.to_string(),
|
||||
items: data_len as u64,
|
||||
merkle_items: mkl_len as u64,
|
||||
merkle_queue_len: t.merkle_updater.todo_approximate_len()? as u64,
|
||||
insert_queue_len: t.data.insert_queue_approximate_len()? as u64,
|
||||
gc_queue_len: t.data.gc_todo_approximate_len()? as u64,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -869,14 +869,14 @@ impl Modify for SecurityAddon {
|
||||
components.add_security_scheme(
|
||||
"bearerAuth",
|
||||
SecurityScheme::Http(Http::builder().scheme(HttpAuthScheme::Bearer).build()),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
version = "v2.2.0",
|
||||
version = "v2.3.0",
|
||||
title = "Garage administration API",
|
||||
description = "Administrate your Garage cluster programmatically, including status, layout, keys, buckets, and maintenance tasks.
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ pub enum Endpoint {
|
||||
impl Endpoint {
|
||||
/// Determine which S3 endpoint a request is for using the request, and a bucket which was
|
||||
/// possibly extracted from the Host header.
|
||||
/// Returns Self plus bucket name, if endpoint is not Endpoint::ListBuckets
|
||||
/// Returns Self plus bucket name, if endpoint is not `Endpoint::ListBuckets`
|
||||
pub fn from_request<T>(req: &Request<T>) -> Result<Self, Error> {
|
||||
let uri = req.uri();
|
||||
let path = uri.path();
|
||||
@@ -124,7 +124,7 @@ impl Endpoint {
|
||||
]);
|
||||
|
||||
if let Some(message) = query.nonempty_message() {
|
||||
debug!("Unused query parameter: {}", message)
|
||||
debug!("Unused query parameter: {}", message);
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
|
||||
@@ -79,7 +79,7 @@ pub enum Endpoint {
|
||||
impl Endpoint {
|
||||
/// Determine which S3 endpoint a request is for using the request, and a bucket which was
|
||||
/// possibly extracted from the Host header.
|
||||
/// Returns Self plus bucket name, if endpoint is not Endpoint::ListBuckets
|
||||
/// Returns Self plus bucket name, if endpoint is not `Endpoint::ListBuckets`
|
||||
pub fn from_request<T>(req: &Request<T>) -> Result<Self, Error> {
|
||||
let uri = req.uri();
|
||||
let path = uri.path();
|
||||
@@ -126,7 +126,7 @@ impl Endpoint {
|
||||
]);
|
||||
|
||||
if let Some(message) = query.nonempty_message() {
|
||||
debug!("Unused query parameter: {}", message)
|
||||
debug!("Unused query parameter: {}", message);
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::Authorization;
|
||||
impl AdminApiRequest {
|
||||
/// Determine which S3 endpoint a request is for using the request, and a bucket which was
|
||||
/// possibly extracted from the Host header.
|
||||
/// Returns Self plus bucket name, if endpoint is not Endpoint::ListBuckets
|
||||
/// Returns Self plus bucket name, if endpoint is not `Endpoint::ListBuckets`
|
||||
pub async fn from_request(req: Request<IncomingBody>) -> Result<Self, Error> {
|
||||
let uri = req.uri().clone();
|
||||
let path = uri.path();
|
||||
@@ -89,7 +89,7 @@ impl AdminApiRequest {
|
||||
]);
|
||||
|
||||
if let Some(message) = query.nonempty_message() {
|
||||
debug!("Unused query parameter: {}", message)
|
||||
debug!("Unused query parameter: {}", message);
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
|
||||
@@ -164,7 +164,7 @@ async fn check_domain(garage: &Arc<Garage>, domain: &str) -> Result<bool, Error>
|
||||
}
|
||||
|
||||
let bucket_state = bucket.state.as_option().unwrap();
|
||||
let bucket_website_config = bucket_state.website_config.get();
|
||||
let bucket_website_config = bucket_state.website_config.get().inner();
|
||||
|
||||
match bucket_website_config {
|
||||
Some(_v) => Ok(true),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_api_common"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
@@ -27,6 +27,7 @@ thiserror.workspace = true
|
||||
hex.workspace = true
|
||||
hmac.workspace = true
|
||||
md-5.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
tracing.workspace = true
|
||||
nom.workspace = true
|
||||
pin-project.workspace = true
|
||||
@@ -41,7 +42,12 @@ hyper = { workspace = true, default-features = false, features = ["server", "htt
|
||||
hyper-util.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
quick-xml.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
utoipa.workspace = true
|
||||
|
||||
opentelemetry.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -36,6 +36,10 @@ pub enum CommonError {
|
||||
#[error("Invalid header value: {0}")]
|
||||
InvalidHeader(#[from] hyper::header::ToStrError),
|
||||
|
||||
/// The client sent a request for an action not supported by garage
|
||||
#[error("Unimplemented action: {0}")]
|
||||
NotImplemented(String),
|
||||
|
||||
// ---- SPECIFIC ERROR CONDITIONS ----
|
||||
// These have to be error codes referenced in the S3 spec here:
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/ErrorResponses.html#ErrorCodeList
|
||||
@@ -55,6 +59,10 @@ pub enum CommonError {
|
||||
/// Bucket name is not valid according to AWS S3 specs
|
||||
#[error("Invalid bucket name: {0}")]
|
||||
InvalidBucketName(String),
|
||||
|
||||
/// Tried to create bucket that is already owned by you
|
||||
#[error("Bucket already owned by you")]
|
||||
BucketAlreadyOwnedByYou,
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
@@ -97,8 +105,11 @@ impl CommonError {
|
||||
}
|
||||
CommonError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
CommonError::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
CommonError::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED,
|
||||
CommonError::NoSuchBucket(_) => StatusCode::NOT_FOUND,
|
||||
CommonError::BucketNotEmpty | CommonError::BucketAlreadyExists => StatusCode::CONFLICT,
|
||||
CommonError::BucketNotEmpty
|
||||
| CommonError::BucketAlreadyExists
|
||||
| CommonError::BucketAlreadyOwnedByYou => StatusCode::CONFLICT,
|
||||
CommonError::InvalidBucketName(_) | CommonError::InvalidHeader(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
@@ -120,6 +131,8 @@ impl CommonError {
|
||||
CommonError::BucketNotEmpty => "BucketNotEmpty",
|
||||
CommonError::InvalidBucketName(_) => "InvalidBucketName",
|
||||
CommonError::InvalidHeader(_) => "InvalidHeaderValue",
|
||||
CommonError::BucketAlreadyOwnedByYou => "BucketAlreadyOwnedByYou",
|
||||
CommonError::NotImplemented(_) => "NotImplemented",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,10 +155,10 @@ impl TryFrom<HelperError> for CommonError {
|
||||
}
|
||||
}
|
||||
|
||||
/// This function converts HelperErrors into CommonErrors,
|
||||
/// for variants that exist in CommonError.
|
||||
/// This is used for helper functions that might return InvalidBucketName
|
||||
/// or NoSuchBucket for instance, and we want to pass that error
|
||||
/// This function converts `HelperErrors` into `CommonErrors`,
|
||||
/// for variants that exist in `CommonError`.
|
||||
/// This is used for helper functions that might return `InvalidBucketName`
|
||||
/// or `NoSuchBucket` for instance, and we want to pass that error
|
||||
/// up to our caller.
|
||||
pub fn pass_helper_error(err: HelperError) -> CommonError {
|
||||
match CommonError::try_from(err) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use http::header::{
|
||||
ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD,
|
||||
HeaderValue, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS,
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_REQUEST_HEADERS,
|
||||
ACCESS_CONTROL_REQUEST_METHOD, VARY,
|
||||
};
|
||||
use hyper::{body::Body, body::Incoming as IncomingBody, Request, Response, StatusCode};
|
||||
|
||||
@@ -12,20 +13,25 @@ use garage_model::garage::Garage;
|
||||
use crate::common_error::{CommonError, OkOrBadRequest, OkOrInternalError};
|
||||
use crate::helpers::*;
|
||||
|
||||
// Return both the matching rule and the parsed Origin header so callers that
|
||||
// apply CORS headers don't have to repeat Origin lookup and validation.
|
||||
pub fn find_matching_cors_rule<'a, B>(
|
||||
bucket_params: &'a BucketParams,
|
||||
req: &Request<B>,
|
||||
) -> Result<Option<&'a GarageCorsRule>, CommonError> {
|
||||
if let Some(cors_config) = bucket_params.cors_config.get() {
|
||||
req: &'a Request<B>,
|
||||
) -> Result<Option<(&'a GarageCorsRule, &'a str)>, CommonError> {
|
||||
if let Some(cors_config) = bucket_params.cors_config.get().inner() {
|
||||
if let Some(origin) = req.headers().get("Origin") {
|
||||
let origin = origin.to_str()?;
|
||||
let request_headers = match req.headers().get(ACCESS_CONTROL_REQUEST_HEADERS) {
|
||||
Some(h) => h.to_str()?.split(',').map(|h| h.trim()).collect::<Vec<_>>(),
|
||||
None => vec![],
|
||||
};
|
||||
return Ok(cors_config.iter().find(|rule| {
|
||||
cors_rule_matches(rule, origin, req.method().as_ref(), request_headers.iter())
|
||||
}));
|
||||
return Ok(cors_config
|
||||
.iter()
|
||||
.find(|rule| {
|
||||
cors_rule_matches(rule, origin, req.method().as_ref(), request_headers.iter())
|
||||
})
|
||||
.map(|rule| (rule, origin)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
@@ -53,12 +59,16 @@ where
|
||||
pub fn add_cors_headers(
|
||||
resp: &mut Response<impl Body>,
|
||||
rule: &GarageCorsRule,
|
||||
request_origin: &str,
|
||||
) -> Result<(), http::header::InvalidHeaderValue> {
|
||||
let h = resp.headers_mut();
|
||||
h.insert(
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
rule.allow_origins.join(", ").parse()?,
|
||||
);
|
||||
let is_wildcard_origin = rule.allow_origins.iter().any(|origin| origin == "*");
|
||||
let allow_origin = if is_wildcard_origin {
|
||||
"*"
|
||||
} else {
|
||||
request_origin
|
||||
};
|
||||
h.insert(ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin.parse()?);
|
||||
h.insert(
|
||||
ACCESS_CONTROL_ALLOW_METHODS,
|
||||
rule.allow_methods.join(", ").parse()?,
|
||||
@@ -71,6 +81,12 @@ pub fn add_cors_headers(
|
||||
ACCESS_CONTROL_EXPOSE_HEADERS,
|
||||
rule.expose_headers.join(", ").parse()?,
|
||||
);
|
||||
// When ACAO reflects the request origin instead of returning "*",
|
||||
// caches must vary on the Origin request header to avoid reusing
|
||||
// a response generated for one origin when serving another origin.
|
||||
if !is_wildcard_origin {
|
||||
h.insert(VARY, HeaderValue::from_static("Origin"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -107,6 +123,7 @@ pub fn handle_options_api(
|
||||
Ok(Response::builder()
|
||||
.header(ACCESS_CONTROL_ALLOW_ORIGIN, "*")
|
||||
.header(ACCESS_CONTROL_ALLOW_METHODS, "*")
|
||||
.header(ACCESS_CONTROL_ALLOW_HEADERS, "*")
|
||||
.status(StatusCode::OK)
|
||||
.body(EmptyBody::new())?)
|
||||
}
|
||||
@@ -141,7 +158,7 @@ pub fn handle_options_for_bucket<B>(
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
if let Some(cors_config) = bucket_params.cors_config.get() {
|
||||
if let Some(cors_config) = bucket_params.cors_config.get().inner() {
|
||||
let matching_rule = cors_config
|
||||
.iter()
|
||||
.find(|rule| cors_rule_matches(rule, origin, request_method, request_headers.iter()));
|
||||
@@ -149,7 +166,17 @@ pub fn handle_options_for_bucket<B>(
|
||||
let mut resp = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(EmptyBody::new())?;
|
||||
add_cors_headers(&mut resp, rule).ok_or_internal_error("Invalid CORS configuration")?;
|
||||
add_cors_headers(&mut resp, rule, origin)
|
||||
.ok_or_internal_error("Invalid CORS configuration")?;
|
||||
// Preflight responses vary not only on Origin but also on the
|
||||
// requested method and requested headers, so caches must not
|
||||
// reuse one preflight decision for a different preflight input.
|
||||
resp.headers_mut().insert(
|
||||
VARY,
|
||||
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers"
|
||||
.parse()
|
||||
.expect("static vary header"),
|
||||
);
|
||||
return Ok(resp);
|
||||
}
|
||||
}
|
||||
@@ -158,3 +185,101 @@ pub fn handle_options_for_bucket<B>(
|
||||
"This CORS request is not allowed.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bucket_params_with_rule(allow_origins: Vec<&str>) -> BucketParams {
|
||||
let mut bucket_params = BucketParams::default();
|
||||
bucket_params.cors_config.update(
|
||||
Some(vec![GarageCorsRule {
|
||||
id: Some("cors-test".into()),
|
||||
max_age_seconds: None,
|
||||
allow_origins: allow_origins.into_iter().map(str::to_string).collect(),
|
||||
allow_methods: vec!["GET".into(), "PUT".into()],
|
||||
allow_headers: vec!["*".into()],
|
||||
expose_headers: vec![],
|
||||
}])
|
||||
.into(),
|
||||
);
|
||||
bucket_params
|
||||
}
|
||||
|
||||
fn preflight_request(origin: &str) -> Request<()> {
|
||||
Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri("http://example.test/bucket")
|
||||
.header("Origin", origin)
|
||||
.header(ACCESS_CONTROL_REQUEST_METHOD, "PUT")
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_with_single_allowed_origin_returns_request_origin() {
|
||||
let bucket_params = bucket_params_with_rule(vec!["https://app.example.test"]);
|
||||
let req = preflight_request("https://app.example.test");
|
||||
|
||||
let resp = handle_options_for_bucket(&req, &bucket_params).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
|
||||
"https://app.example.test"
|
||||
);
|
||||
let vary_values: Vec<_> = resp
|
||||
.headers()
|
||||
.get_all(VARY)
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
vary_values,
|
||||
vec!["Origin, Access-Control-Request-Method, Access-Control-Request-Headers",]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_with_multiple_allowed_origins_reflects_request_origin() {
|
||||
let bucket_params = bucket_params_with_rule(vec![
|
||||
"https://app.example.test",
|
||||
"https://admin.example.test",
|
||||
]);
|
||||
let req = preflight_request("https://app.example.test");
|
||||
|
||||
let resp = handle_options_for_bucket(&req, &bucket_params).unwrap();
|
||||
|
||||
// This assertion documents the behavior browsers expect:
|
||||
// even if multiple origins are allowed by configuration, the
|
||||
// response should reflect the request origin rather than emit
|
||||
// a comma-separated list. It currently fails and is meant to
|
||||
// turn green once header generation is corrected.
|
||||
assert_eq!(
|
||||
resp.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
|
||||
"https://app.example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_with_wildcard_allowed_origin_returns_wildcard() {
|
||||
let bucket_params = bucket_params_with_rule(vec!["*"]);
|
||||
let req = preflight_request("https://app.example.test");
|
||||
|
||||
let resp = handle_options_for_bucket(&req, &bucket_params).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resp.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(),
|
||||
"*"
|
||||
);
|
||||
let vary_values: Vec<_> = resp
|
||||
.headers()
|
||||
.get_all(VARY)
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
vary_values,
|
||||
vec!["Origin, Access-Control-Request-Method, Access-Control-Request-Headers",]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Module containing various helpers for encoding
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Encode &str for use in a URI
|
||||
pub fn uri_encode(string: &str, encode_slash: bool) -> String {
|
||||
let mut result = String::with_capacity(string.len() * 2);
|
||||
@@ -9,14 +11,98 @@ pub fn uri_encode(string: &str, encode_slash: bool) -> String {
|
||||
'/' if encode_slash => result.push_str("%2F"),
|
||||
'/' if !encode_slash => result.push('/'),
|
||||
_ => {
|
||||
result.push_str(
|
||||
&format!("{}", c)
|
||||
.bytes()
|
||||
.map(|b| format!("%{:02X}", b))
|
||||
.collect::<String>(),
|
||||
);
|
||||
let mut buf = [0_u8; 4];
|
||||
let str = c.encode_utf8(&mut buf);
|
||||
for b in str.bytes() {
|
||||
write!(&mut result, "%{:02X}", b).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::encoding::uri_encode;
|
||||
|
||||
#[test]
|
||||
fn test_uri_encode() {
|
||||
let url1_encoded = uri_encode(
|
||||
"https://garagehq.deuxfleurs.fr/documentation/reference-manual/features/",
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
&url1_encoded,
|
||||
"https%3A%2F%2Fgaragehq.deuxfleurs.fr%2Fdocumentation%2Freference-manual%2Ffeatures%2F"
|
||||
);
|
||||
|
||||
let url2_encoded = uri_encode(
|
||||
"https://garagehq.deuxfleurs.fr/blog/2025-06-garage-v2/",
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
&url2_encoded,
|
||||
"https%3A%2F%2Fgaragehq.deuxfleurs.fr%2Fblog%2F2025-06-garage-v2%2F"
|
||||
);
|
||||
|
||||
let url3_encoded = uri_encode(
|
||||
"https://garagehq.deuxfleurs.fr/blog/2025-06-hé_les_gens/",
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
&url3_encoded,
|
||||
"https%3A%2F%2Fgaragehq.deuxfleurs.fr%2Fblog%2F2025-06-h%C3%A9_les_gens%2F"
|
||||
);
|
||||
|
||||
let url4_encoded = uri_encode("/home/local user/Documents/personnel/à_blog.md", true);
|
||||
assert_eq!(
|
||||
&url4_encoded,
|
||||
"%2Fhome%2Flocal%20user%2FDocuments%2Fpersonnel%2F%C3%A0_blog.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uri_encode_without_slash() {
|
||||
let url1_encoded = uri_encode(
|
||||
"https://garagehq.deuxfleurs.fr/documentation/reference-manual/features/",
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
&url1_encoded,
|
||||
"https%3A//garagehq.deuxfleurs.fr/documentation/reference-manual/features/"
|
||||
);
|
||||
|
||||
let url2_encoded = uri_encode(
|
||||
"https://garagehq.deuxfleurs.fr/blog/2025-06-garage-v2/",
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
&url2_encoded,
|
||||
"https%3A//garagehq.deuxfleurs.fr/blog/2025-06-garage-v2/"
|
||||
);
|
||||
|
||||
let url3_encoded = uri_encode(
|
||||
"https://garagehq.deuxfleurs.fr/blog/2025-06-hé_les_gens/",
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
&url3_encoded,
|
||||
"https%3A//garagehq.deuxfleurs.fr/blog/2025-06-h%C3%A9_les_gens/"
|
||||
);
|
||||
let url4_encoded = uri_encode("/home/local user/Documents/personnel/à_blog.md", false);
|
||||
assert_eq!(
|
||||
&url4_encoded,
|
||||
"/home/local%20user/Documents/personnel/%C3%A0_blog.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uri_encode_most_than_double_size() {
|
||||
let url_encoded = uri_encode("/home/ùàé ç/çaèù/à_êô.md", true);
|
||||
assert_eq!(
|
||||
&url_encoded,
|
||||
"%2Fhome%2F%C3%B9%C3%A0%C3%A9%20%C3%A7%2F%C3%A7a%C3%A8%C3%B9%2F%C3%A0_%C3%AA%C3%B4.md"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,21 +84,21 @@ impl<A: ApiHandler> ApiServer<A> {
|
||||
region,
|
||||
api_handler,
|
||||
request_counter: meter
|
||||
.u64_counter(format!("api.{}.request_counter", A::API_NAME))
|
||||
.u64_counter(format!("garage_api.{}.request_count", A::API_NAME))
|
||||
.with_description(format!(
|
||||
"Number of API calls to the various {} API endpoints",
|
||||
A::API_NAME_DISPLAY
|
||||
))
|
||||
.init(),
|
||||
error_counter: meter
|
||||
.u64_counter(format!("api.{}.error_counter", A::API_NAME))
|
||||
.u64_counter(format!("garage_api.{}.error_count", A::API_NAME))
|
||||
.with_description(format!(
|
||||
"Number of API calls to the various {} API endpoints that resulted in errors",
|
||||
A::API_NAME_DISPLAY
|
||||
))
|
||||
.init(),
|
||||
request_duration: meter
|
||||
.f64_value_recorder(format!("api.{}.request_duration", A::API_NAME))
|
||||
.f64_value_recorder(format!("garage_api.{}.request_duration", A::API_NAME))
|
||||
.with_description(format!(
|
||||
"Duration of API calls to the various {} API endpoints",
|
||||
A::API_NAME_DISPLAY
|
||||
@@ -125,7 +125,7 @@ impl<A: ApiHandler> ApiServer<A> {
|
||||
}
|
||||
UnixOrTCPSocketAddress::UnixSocket(ref path) => {
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(path)?;
|
||||
@@ -162,7 +162,14 @@ impl<A: ApiHandler> ApiServer<A> {
|
||||
.key_id_from_request(&req)
|
||||
.map(|k| format!("(key {k}) "))
|
||||
.unwrap_or_default();
|
||||
info!("{source} {key}{} {uri}", req.method());
|
||||
|
||||
let method = req.method().clone();
|
||||
if A::API_NAME == "admin" && (uri.path() == "/health" || uri.path() == "/metrics") {
|
||||
debug!("{source} {key}{method} {uri}");
|
||||
} else {
|
||||
info!("{source} {key}{method} {uri}");
|
||||
}
|
||||
|
||||
debug!("{:?}", req);
|
||||
|
||||
let tracer = opentelemetry::global::tracer("garage");
|
||||
@@ -190,15 +197,23 @@ impl<A: ApiHandler> ApiServer<A> {
|
||||
let mut http_error_builder = Response::builder().status(e.http_status_code());
|
||||
|
||||
if let Some(header_map) = http_error_builder.headers_mut() {
|
||||
e.add_http_headers(header_map)
|
||||
e.add_http_headers(header_map);
|
||||
}
|
||||
|
||||
let http_error = http_error_builder.body(body)?;
|
||||
|
||||
if e.http_status_code().is_server_error() {
|
||||
warn!("Response: error {}, {}", e.http_status_code(), e);
|
||||
warn!(
|
||||
"error {}, {} in response to {source} {key}{method} {uri}",
|
||||
e.http_status_code(),
|
||||
e
|
||||
);
|
||||
} else {
|
||||
info!("Response: error {}, {}", e.http_status_code(), e);
|
||||
info!(
|
||||
"error {}, {} in response to {source} {key}{method} {uri}",
|
||||
e.http_status_code(),
|
||||
e
|
||||
);
|
||||
}
|
||||
Ok(http_error
|
||||
.map(|body| BoxBody::new(body.map_err(|_: Infallible| unreachable!()))))
|
||||
|
||||
@@ -10,3 +10,4 @@ pub mod generic_server;
|
||||
pub mod helpers;
|
||||
pub mod router_macros;
|
||||
pub mod signature;
|
||||
pub mod xml;
|
||||
|
||||
@@ -164,7 +164,7 @@ macro_rules! router_match {
|
||||
$query.$param.take().map(|param| param.into_owned())
|
||||
}};
|
||||
(@@parse_param $query:expr, query, $param:ident) => {{
|
||||
// extract mendatory query parameter
|
||||
// extract mandatory query parameter
|
||||
$query.$param.take()
|
||||
.ok_or_bad_request(
|
||||
format!("Missing argument `{}` for endpoint", stringify!($param))
|
||||
|
||||
@@ -89,7 +89,7 @@ impl ReqBody {
|
||||
checksummer
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
Err(frame) => {
|
||||
let trailers = frame.into_trailers().unwrap();
|
||||
|
||||
@@ -11,6 +11,7 @@ use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use garage_util::data::*;
|
||||
|
||||
use super::*;
|
||||
use crate::common_error::CommonError;
|
||||
|
||||
pub use garage_model::s3::object_table::{ChecksumAlgorithm, ChecksumValue};
|
||||
|
||||
@@ -201,7 +202,7 @@ impl Checksums {
|
||||
}
|
||||
if let Some(extra) = expected.extra {
|
||||
let algo = extra.algorithm();
|
||||
let calculated = self.extract(Some(algo));
|
||||
let calculated = self.extract(Some(algo))?;
|
||||
if calculated != Some(extra) {
|
||||
return Err(Error::InvalidDigest(format!(
|
||||
"Failed to validate checksum for algorithm {:?}: calculated {:?}, expected {:?}",
|
||||
@@ -212,17 +213,45 @@ impl Checksums {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn extract(&self, algo: Option<ChecksumAlgorithm>) -> Option<ChecksumValue> {
|
||||
match algo {
|
||||
pub fn extract(&self, algo: Option<ChecksumAlgorithm>) -> Result<Option<ChecksumValue>, Error> {
|
||||
Ok(match algo {
|
||||
None => None,
|
||||
Some(ChecksumAlgorithm::Crc32) => Some(ChecksumValue::Crc32(self.crc32.unwrap())),
|
||||
Some(ChecksumAlgorithm::Crc32c) => Some(ChecksumValue::Crc32c(self.crc32c.unwrap())),
|
||||
Some(ChecksumAlgorithm::Crc64Nvme) => {
|
||||
Some(ChecksumValue::Crc64Nvme(self.crc64nvme.unwrap()))
|
||||
Some(ChecksumAlgorithm::Crc32) => {
|
||||
Some(ChecksumValue::Crc32(self.crc32.ok_or_else(|| {
|
||||
CommonError::BadRequest(
|
||||
"Requested checksum verification without providing checksum".to_string(),
|
||||
)
|
||||
})?))
|
||||
}
|
||||
Some(ChecksumAlgorithm::Sha1) => Some(ChecksumValue::Sha1(self.sha1.unwrap())),
|
||||
Some(ChecksumAlgorithm::Sha256) => Some(ChecksumValue::Sha256(self.sha256.unwrap())),
|
||||
}
|
||||
Some(ChecksumAlgorithm::Crc32c) => {
|
||||
Some(ChecksumValue::Crc32c(self.crc32c.ok_or_else(|| {
|
||||
CommonError::BadRequest(
|
||||
"Requested checksum verification without providing checksum".to_string(),
|
||||
)
|
||||
})?))
|
||||
}
|
||||
Some(ChecksumAlgorithm::Crc64Nvme) => Some(ChecksumValue::Crc64Nvme(
|
||||
self.crc64nvme.ok_or_else(|| {
|
||||
CommonError::BadRequest(
|
||||
"Requested checksum verification without providing checksum".to_string(),
|
||||
)
|
||||
})?,
|
||||
)),
|
||||
Some(ChecksumAlgorithm::Sha1) => {
|
||||
Some(ChecksumValue::Sha1(self.sha1.ok_or_else(|| {
|
||||
CommonError::BadRequest(
|
||||
"Requested checksum verification without providing checksum".to_string(),
|
||||
)
|
||||
})?))
|
||||
}
|
||||
Some(ChecksumAlgorithm::Sha256) => {
|
||||
Some(ChecksumValue::Sha256(self.sha256.ok_or_else(|| {
|
||||
CommonError::BadRequest(
|
||||
"Requested checksum verification without providing checksum".to_string(),
|
||||
)
|
||||
})?))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@ pub enum Error {
|
||||
Common(CommonError),
|
||||
|
||||
/// Authorization Header Malformed
|
||||
#[error("Authorization header malformed, unexpected scope: {0}")]
|
||||
AuthorizationHeaderMalformed(String),
|
||||
#[error(
|
||||
"Authorization header malformed, unexpected scope: '{unexpected}', expected: '{expected}'"
|
||||
)]
|
||||
AuthorizationHeaderMalformed {
|
||||
unexpected: String,
|
||||
expected: String,
|
||||
},
|
||||
|
||||
// Category: bad request
|
||||
/// The request contained an invalid UTF-8 sequence in its path or in other parameters
|
||||
|
||||
@@ -81,7 +81,7 @@ fn parse_x_amz_content_sha256(header: Option<&str>) -> Result<ContentSha256Heade
|
||||
_ => {
|
||||
return Err(Error::bad_request(
|
||||
"invalid or unsupported x-amz-content-sha256",
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(ContentSha256Header::StreamingPayload { trailer, signed })
|
||||
@@ -340,7 +340,11 @@ pub fn canonical_request(
|
||||
let canonical_uri: std::borrow::Cow<str> = if service != "s3" {
|
||||
uri_encode(canonical_uri, false).into()
|
||||
} else {
|
||||
canonical_uri.into()
|
||||
//TODO: decode is already do for construct Api::EndPoint, should be better to be able to keep it instead of compute it again.
|
||||
let key = percent_encoding::percent_decode_str(canonical_uri)
|
||||
.decode_utf8()
|
||||
.unwrap();
|
||||
uri_encode(&key, false).into()
|
||||
};
|
||||
|
||||
// Canonical query string from passed HeaderMap
|
||||
@@ -353,15 +357,32 @@ pub fn canonical_request(
|
||||
items.join("&")
|
||||
};
|
||||
|
||||
// Canonical header string calculated from signed headers
|
||||
// Canonical header string calculated from signed headers.
|
||||
//
|
||||
// Per the SigV4 spec, signed header values must have sequential
|
||||
// internal whitespace collapsed to a single space, in addition to
|
||||
// being trimmed. AWS SDKs do this before computing the signature
|
||||
// but transmit the raw value on the wire, so we must match.
|
||||
// -> https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
|
||||
let canonical_header_string = signed_headers
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let value = headers
|
||||
.get(name)
|
||||
let all_values = headers.get_all(name);
|
||||
let mut iter_values = all_values.iter();
|
||||
let base_value = iter_values
|
||||
.next()
|
||||
.ok_or_bad_request(format!("signed header `{}` is not present", name))?;
|
||||
let value = std::str::from_utf8(value.as_bytes())?;
|
||||
Ok(format!("{}:{}", name.as_str(), value.trim()))
|
||||
let mut built_string = std::str::from_utf8(base_value.as_bytes())?.to_string();
|
||||
for extend_value in iter_values {
|
||||
let extend_string = std::str::from_utf8(extend_value.as_bytes())?;
|
||||
built_string.push(',');
|
||||
built_string.push_str(extend_string);
|
||||
}
|
||||
let normalized = built_string
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
Ok(format!("{}:{}", name.as_str(), normalized))
|
||||
})
|
||||
.collect::<Result<Vec<String>, Error>>()?
|
||||
.join("\n");
|
||||
@@ -393,7 +414,10 @@ pub fn verify_v4(
|
||||
) -> Result<Key, Error> {
|
||||
let scope_expected = compute_scope(&auth.date, &garage.config.s3_api.s3_region, service);
|
||||
if auth.scope != scope_expected {
|
||||
return Err(Error::AuthorizationHeaderMalformed(auth.scope.to_string()));
|
||||
return Err(Error::AuthorizationHeaderMalformed {
|
||||
unexpected: auth.scope.to_string(),
|
||||
expected: scope_expected,
|
||||
});
|
||||
}
|
||||
|
||||
let key = garage
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::iter::FromIterator;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -5,7 +6,7 @@ use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
|
||||
use futures::prelude::*;
|
||||
use futures::task;
|
||||
use hmac::Mac;
|
||||
use http::header::{HeaderMap, HeaderValue, CONTENT_ENCODING};
|
||||
use http::header::{Entry, HeaderMap, HeaderValue, CONTENT_ENCODING};
|
||||
use hyper::body::{Bytes, Frame, Incoming as IncomingBody};
|
||||
use hyper::Request;
|
||||
|
||||
@@ -42,15 +43,52 @@ pub fn parse_streaming_body(
|
||||
// Remove the aws-chunked component in the content-encoding: header
|
||||
// Note: this header is not properly sent by minio client, so don't fail
|
||||
// if it is absent from the request.
|
||||
if let Some(content_encoding) = req.headers_mut().remove(CONTENT_ENCODING) {
|
||||
if let Some(rest) = content_encoding.as_bytes().strip_prefix(b"aws-chunked,") {
|
||||
req.headers_mut()
|
||||
.insert(CONTENT_ENCODING, HeaderValue::from_bytes(rest).unwrap());
|
||||
} else if content_encoding != "aws-chunked" {
|
||||
return Err(Error::bad_request(
|
||||
"content-encoding does not contain aws-chunked for STREAMING-*-PAYLOAD",
|
||||
));
|
||||
let mut original_content_encoding = vec![];
|
||||
if let Entry::Occupied(content_encoding) = req.headers_mut().entry(CONTENT_ENCODING) {
|
||||
// 1. collect headers
|
||||
let (_, vals) = content_encoding.remove_entry_mult();
|
||||
original_content_encoding = Vec::from_iter(vals);
|
||||
}
|
||||
let mut header_initialized = false;
|
||||
let mut chunked_found = false;
|
||||
for enc_val in original_content_encoding.iter() {
|
||||
// 2. clean each header value and reinject it.
|
||||
let mut rebuilt_val = vec![];
|
||||
for part in enc_val.as_bytes().split(|c| *c == b',') {
|
||||
let trimmed_part = part.trim_ascii();
|
||||
if trimmed_part == b"aws-chunked" {
|
||||
chunked_found = true;
|
||||
continue;
|
||||
}
|
||||
if !rebuilt_val.is_empty() {
|
||||
rebuilt_val.push(b',');
|
||||
}
|
||||
rebuilt_val.extend_from_slice(trimmed_part);
|
||||
}
|
||||
|
||||
if rebuilt_val.is_empty() {
|
||||
// skip empty headers
|
||||
continue;
|
||||
}
|
||||
|
||||
if !header_initialized {
|
||||
req.headers_mut().insert(
|
||||
CONTENT_ENCODING,
|
||||
HeaderValue::from_bytes(&rebuilt_val).unwrap(),
|
||||
);
|
||||
header_initialized = true;
|
||||
} else {
|
||||
req.headers_mut().append(
|
||||
CONTENT_ENCODING,
|
||||
HeaderValue::from_bytes(&rebuilt_val).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !original_content_encoding.is_empty() && !chunked_found {
|
||||
return Err(Error::bad_request(
|
||||
"content-encoding does not contain aws-chunked for STREAMING-*-PAYLOAD",
|
||||
));
|
||||
}
|
||||
|
||||
// If trailer header is announced, add the calculation of the requested checksum
|
||||
@@ -201,6 +239,7 @@ mod payload {
|
||||
use nom::character::streaming::hex_digit1;
|
||||
use nom::combinator::{map_res, opt};
|
||||
use nom::number::streaming::hex_u32;
|
||||
use nom::Parser as _;
|
||||
|
||||
macro_rules! try_parse {
|
||||
($expr:expr) => {
|
||||
@@ -234,7 +273,7 @@ mod payload {
|
||||
let (input, _) = try_parse!(tag(";")(input));
|
||||
|
||||
let (input, _) = try_parse!(tag("chunk-signature=")(input));
|
||||
let (input, data) = try_parse!(map_res(hex_digit1, hex::decode)(input));
|
||||
let (input, data) = try_parse!(map_res(hex_digit1, hex::decode).parse(input));
|
||||
let signature = Hash::try_from(&data).ok_or(nom::Err::Failure(Error::BadSignature))?;
|
||||
|
||||
let (input, _) = try_parse!(tag("\r\n")(input));
|
||||
@@ -272,18 +311,20 @@ mod payload {
|
||||
let (input, header_name) = try_parse!(map_res(
|
||||
take_while(|c: u8| c.is_ascii_alphanumeric() || c == b'-'),
|
||||
HeaderName::from_bytes
|
||||
)(input));
|
||||
let (input, _) = try_parse!(tag(b":")(input));
|
||||
)
|
||||
.parse(input));
|
||||
let (input, _) = try_parse!(tag(&b":"[..])(input));
|
||||
let (input, header_value) = try_parse!(map_res(
|
||||
take_while(|c: u8| c.is_ascii_alphanumeric() || b"+/=".contains(&c)),
|
||||
HeaderValue::from_bytes
|
||||
)(input));
|
||||
)
|
||||
.parse(input));
|
||||
|
||||
// Possible '\n' after the header value, depends on clients
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
|
||||
let (input, _) = try_parse!(opt(tag(b"\n"))(input));
|
||||
let (input, _) = try_parse!(opt(tag(&b"\n"[..])).parse(input));
|
||||
|
||||
let (input, _) = try_parse!(tag(b"\r\n")(input));
|
||||
let (input, _) = try_parse!(tag(&b"\r\n"[..]).parse(input));
|
||||
|
||||
Ok((
|
||||
input,
|
||||
@@ -297,10 +338,10 @@ mod payload {
|
||||
pub fn parse_signed(input: &[u8]) -> nom::IResult<&[u8], Self, Error<&[u8]>> {
|
||||
let (input, trailer) = Self::parse_content(input)?;
|
||||
|
||||
let (input, _) = try_parse!(tag(b"x-amz-trailer-signature:")(input));
|
||||
let (input, data) = try_parse!(map_res(hex_digit1, hex::decode)(input));
|
||||
let (input, _) = try_parse!(tag(&b"x-amz-trailer-signature:"[..]).parse(input));
|
||||
let (input, data) = try_parse!(map_res(hex_digit1, hex::decode).parse(input));
|
||||
let signature = Hash::try_from(&data).ok_or(nom::Err::Failure(Error::BadSignature))?;
|
||||
let (input, _) = try_parse!(tag(b"\r\n")(input));
|
||||
let (input, _) = try_parse!(tag(&b"\r\n"[..]).parse(input));
|
||||
|
||||
Ok((
|
||||
input,
|
||||
@@ -312,7 +353,7 @@ mod payload {
|
||||
}
|
||||
pub fn parse_unsigned(input: &[u8]) -> nom::IResult<&[u8], Self, Error<&[u8]>> {
|
||||
let (input, trailer) = Self::parse_content(input)?;
|
||||
let (input, _) = try_parse!(tag(b"\r\n")(input));
|
||||
let (input, _) = try_parse!(tag(&b"\r\n"[..]).parse(input));
|
||||
|
||||
Ok((input, trailer))
|
||||
}
|
||||
@@ -477,7 +518,7 @@ where
|
||||
continue;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
return Poll::Ready(Some(Err(StreamingPayloadError::Stream(e))))
|
||||
return Poll::Ready(Some(Err(StreamingPayloadError::Stream(e))));
|
||||
}
|
||||
None => {
|
||||
return Poll::Ready(Some(Err(StreamingPayloadError::message(
|
||||
@@ -487,7 +528,7 @@ where
|
||||
}
|
||||
}
|
||||
Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => {
|
||||
return Poll::Ready(Some(Err(e)))
|
||||
return Poll::Ready(Some(Err(e)));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use hyper::{header::HeaderName, Method};
|
||||
|
||||
use garage_model::bucket_table::CorsRule as GarageCorsRule;
|
||||
|
||||
use super::{xmlns_tag, IntValue, Value};
|
||||
use crate::common_error::{CommonError as Error, OkOrBadRequest};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename = "CORSConfiguration")]
|
||||
pub struct CorsConfiguration {
|
||||
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)]
|
||||
pub xmlns: (),
|
||||
// "default" is required to be able to parse an empty list of rules,
|
||||
// cf https://docs.rs/quick-xml/latest/quick_xml/de/#sequences-xsall-and-xssequence-xml-schema-types
|
||||
#[serde(rename = "CORSRule", default)]
|
||||
pub cors_rules: Vec<CorsRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = cors::Rule)]
|
||||
pub struct CorsRule {
|
||||
#[serde(rename = "ID", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(rename = "MaxAgeSeconds", skip_serializing_if = "Option::is_none")]
|
||||
pub max_age_seconds: Option<IntValue>,
|
||||
#[serde(rename = "AllowedOrigin")]
|
||||
pub allowed_origins: Vec<Value>,
|
||||
#[serde(rename = "AllowedMethod")]
|
||||
pub allowed_methods: Vec<Value>,
|
||||
#[serde(rename = "AllowedHeader", default)]
|
||||
pub allowed_headers: Vec<Value>,
|
||||
#[serde(rename = "ExposeHeader", default)]
|
||||
pub expose_headers: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = cors::AllowedMethod)]
|
||||
pub struct AllowedMethod {
|
||||
#[serde(rename = "AllowedMethod")]
|
||||
pub allowed_method: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = cors::AllowedHeader)]
|
||||
pub struct AllowedHeader {
|
||||
#[serde(rename = "AllowedHeader")]
|
||||
pub allowed_header: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = cors::ExposedHeader)]
|
||||
pub struct ExposeHeader {
|
||||
#[serde(rename = "ExposeHeader")]
|
||||
pub expose_header: Value,
|
||||
}
|
||||
|
||||
impl CorsConfiguration {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
for r in self.cors_rules.iter() {
|
||||
r.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn into_garage_cors_config(self) -> Result<Vec<GarageCorsRule>, Error> {
|
||||
Ok(self
|
||||
.cors_rules
|
||||
.iter()
|
||||
.map(CorsRule::to_garage_cors_rule)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl CorsRule {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
for method in self.allowed_methods.iter() {
|
||||
method
|
||||
.0
|
||||
.parse::<Method>()
|
||||
.ok_or_bad_request("Invalid CORSRule method")?;
|
||||
}
|
||||
for header in self
|
||||
.allowed_headers
|
||||
.iter()
|
||||
.chain(self.expose_headers.iter())
|
||||
{
|
||||
header
|
||||
.0
|
||||
.parse::<HeaderName>()
|
||||
.ok_or_bad_request("Invalid HTTP header name")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn to_garage_cors_rule(&self) -> GarageCorsRule {
|
||||
let convert_vec =
|
||||
|vval: &[Value]| vval.iter().map(|x| x.0.to_owned()).collect::<Vec<String>>();
|
||||
GarageCorsRule {
|
||||
id: self.id.as_ref().map(|x| x.0.to_owned()),
|
||||
max_age_seconds: self.max_age_seconds.as_ref().map(|x| x.0 as u64),
|
||||
allow_origins: convert_vec(&self.allowed_origins),
|
||||
allow_methods: convert_vec(&self.allowed_methods),
|
||||
allow_headers: convert_vec(&self.allowed_headers),
|
||||
expose_headers: convert_vec(&self.expose_headers),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_garage_cors_rule(rule: &GarageCorsRule) -> Self {
|
||||
let convert_vec = |vval: &[String]| {
|
||||
vval.iter()
|
||||
.map(|x| Value(x.clone()))
|
||||
.collect::<Vec<Value>>()
|
||||
};
|
||||
Self {
|
||||
id: rule.id.as_ref().map(|x| Value(x.clone())),
|
||||
max_age_seconds: rule.max_age_seconds.map(|x| IntValue(x as i64)),
|
||||
allowed_origins: convert_vec(&rule.allow_origins),
|
||||
allowed_methods: convert_vec(&rule.allow_methods),
|
||||
allowed_headers: convert_vec(&rule.allow_headers),
|
||||
expose_headers: convert_vec(&rule.expose_headers),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::xml::{to_xml_with_header, unprettify_xml};
|
||||
|
||||
use super::*;
|
||||
|
||||
use quick_xml::de::from_str;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize() {
|
||||
let message = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<CORSRule>
|
||||
<AllowedOrigin>http://www.example.com</AllowedOrigin>
|
||||
|
||||
<AllowedMethod>PUT</AllowedMethod>
|
||||
<AllowedMethod>POST</AllowedMethod>
|
||||
<AllowedMethod>DELETE</AllowedMethod>
|
||||
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
</CORSRule>
|
||||
<CORSRule>
|
||||
<AllowedOrigin>*</AllowedOrigin>
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
</CORSRule>
|
||||
<CORSRule>
|
||||
<ID>qsdfjklm</ID>
|
||||
<MaxAgeSeconds>12345</MaxAgeSeconds>
|
||||
<AllowedOrigin>https://perdu.com</AllowedOrigin>
|
||||
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
<AllowedMethod>DELETE</AllowedMethod>
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
<ExposeHeader>*</ExposeHeader>
|
||||
</CORSRule>
|
||||
</CORSConfiguration>"#;
|
||||
let conf: CorsConfiguration =
|
||||
from_str(message).expect("failed to deserialize xml into `CorsConfiguration` struct");
|
||||
let ref_value = CorsConfiguration {
|
||||
xmlns: (),
|
||||
cors_rules: vec![
|
||||
CorsRule {
|
||||
id: None,
|
||||
max_age_seconds: None,
|
||||
allowed_origins: vec!["http://www.example.com".into()],
|
||||
allowed_methods: vec!["PUT".into(), "POST".into(), "DELETE".into()],
|
||||
allowed_headers: vec!["*".into()],
|
||||
expose_headers: vec![],
|
||||
},
|
||||
CorsRule {
|
||||
id: None,
|
||||
max_age_seconds: None,
|
||||
allowed_origins: vec!["*".into()],
|
||||
allowed_methods: vec!["GET".into()],
|
||||
allowed_headers: vec![],
|
||||
expose_headers: vec![],
|
||||
},
|
||||
CorsRule {
|
||||
id: Some("qsdfjklm".into()),
|
||||
max_age_seconds: Some(IntValue(12345)),
|
||||
allowed_origins: vec!["https://perdu.com".into()],
|
||||
allowed_methods: vec!["GET".into(), "DELETE".into()],
|
||||
allowed_headers: vec!["*".into()],
|
||||
expose_headers: vec!["*".into()],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq! {
|
||||
ref_value,
|
||||
conf
|
||||
};
|
||||
|
||||
let message2 = to_xml_with_header(&ref_value).expect("xml serialization");
|
||||
|
||||
assert_eq!(unprettify_xml(message), unprettify_xml(&message2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_norules() {
|
||||
let message = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"></CORSConfiguration>"#;
|
||||
let conf: CorsConfiguration = from_str(message).unwrap();
|
||||
let ref_value = CorsConfiguration {
|
||||
xmlns: (),
|
||||
cors_rules: vec![],
|
||||
};
|
||||
assert_eq! {
|
||||
ref_value,
|
||||
conf
|
||||
};
|
||||
|
||||
let message2 = to_xml_with_header(&ref_value).expect("xml serialization");
|
||||
assert_eq!(unprettify_xml(&message), unprettify_xml(&message2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use garage_model::bucket_table::{
|
||||
parse_lifecycle_date, LifecycleExpiration as GarageLifecycleExpiration,
|
||||
LifecycleFilter as GarageLifecycleFilter, LifecycleRule as GarageLifecycleRule,
|
||||
};
|
||||
|
||||
use super::{xmlns_tag, IntValue, Value};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct LifecycleConfiguration {
|
||||
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)]
|
||||
pub xmlns: (),
|
||||
#[serde(rename = "Rule")]
|
||||
pub lifecycle_rules: Vec<LifecycleRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[schema(as = lifecycle::Rule)]
|
||||
pub struct LifecycleRule {
|
||||
#[serde(rename = "ID", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(rename = "Status")]
|
||||
pub status: Value,
|
||||
#[serde(rename = "Filter", default, skip_serializing_if = "Option::is_none")]
|
||||
pub filter: Option<Filter>,
|
||||
#[serde(
|
||||
rename = "Expiration",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub expiration: Option<Expiration>,
|
||||
#[serde(
|
||||
rename = "AbortIncompleteMultipartUpload",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub abort_incomplete_mpu: Option<AbortIncompleteMpu>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, ToSchema, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default,
|
||||
)]
|
||||
#[schema(as = lifecycle::Filter)]
|
||||
pub struct Filter {
|
||||
#[serde(rename = "And", skip_serializing_if = "Option::is_none")]
|
||||
#[schema(no_recursion)]
|
||||
pub and: Option<Box<Filter>>,
|
||||
#[serde(rename = "Prefix", skip_serializing_if = "Option::is_none")]
|
||||
pub prefix: Option<Value>,
|
||||
#[serde(
|
||||
rename = "ObjectSizeGreaterThan",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub size_gt: Option<IntValue>,
|
||||
#[serde(rename = "ObjectSizeLessThan", skip_serializing_if = "Option::is_none")]
|
||||
pub size_lt: Option<IntValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[schema(as = lifecycle::Expiration)]
|
||||
pub struct Expiration {
|
||||
#[serde(rename = "Days", skip_serializing_if = "Option::is_none")]
|
||||
pub days: Option<IntValue>,
|
||||
#[serde(rename = "Date", skip_serializing_if = "Option::is_none")]
|
||||
pub at_date: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[schema(as = lifecycle::AbortIncompleteMpu)]
|
||||
pub struct AbortIncompleteMpu {
|
||||
#[serde(rename = "DaysAfterInitiation")]
|
||||
pub days: IntValue,
|
||||
}
|
||||
|
||||
impl LifecycleConfiguration {
|
||||
pub fn validate_into_garage_lifecycle_config(
|
||||
self,
|
||||
) -> Result<Vec<GarageLifecycleRule>, &'static str> {
|
||||
let mut ret = vec![];
|
||||
for rule in self.lifecycle_rules {
|
||||
ret.push(rule.validate_into_garage_lifecycle_rule()?);
|
||||
}
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
pub fn from_garage_lifecycle_config(config: &[GarageLifecycleRule]) -> Self {
|
||||
Self {
|
||||
xmlns: (),
|
||||
lifecycle_rules: config
|
||||
.iter()
|
||||
.map(LifecycleRule::from_garage_lifecycle_rule)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LifecycleRule {
|
||||
pub fn validate_into_garage_lifecycle_rule(self) -> Result<GarageLifecycleRule, &'static str> {
|
||||
let enabled = match self.status.0.as_str() {
|
||||
"Enabled" => true,
|
||||
"Disabled" => false,
|
||||
_ => return Err("invalid value for <Status>"),
|
||||
};
|
||||
|
||||
let filter = self
|
||||
.filter
|
||||
.map(Filter::validate_into_garage_lifecycle_filter)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let abort_incomplete_mpu_days = self.abort_incomplete_mpu.map(|x| x.days.0 as usize);
|
||||
|
||||
let expiration = self
|
||||
.expiration
|
||||
.map(Expiration::validate_into_garage_lifecycle_expiration)
|
||||
.transpose()?;
|
||||
|
||||
Ok(GarageLifecycleRule {
|
||||
id: self.id.map(|x| x.0),
|
||||
enabled,
|
||||
filter,
|
||||
abort_incomplete_mpu_days,
|
||||
expiration,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_garage_lifecycle_rule(rule: &GarageLifecycleRule) -> Self {
|
||||
Self {
|
||||
id: rule.id.as_deref().map(Value::from),
|
||||
status: if rule.enabled {
|
||||
Value::from("Enabled")
|
||||
} else {
|
||||
Value::from("Disabled")
|
||||
},
|
||||
filter: Filter::from_garage_lifecycle_filter(&rule.filter),
|
||||
abort_incomplete_mpu: rule
|
||||
.abort_incomplete_mpu_days
|
||||
.map(|days| AbortIncompleteMpu {
|
||||
days: IntValue(days as i64),
|
||||
}),
|
||||
expiration: rule
|
||||
.expiration
|
||||
.as_ref()
|
||||
.map(Expiration::from_garage_lifecycle_expiration),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub fn count(&self) -> i32 {
|
||||
fn count<T>(x: &Option<T>) -> i32 {
|
||||
x.as_ref().map(|_| 1).unwrap_or(0)
|
||||
}
|
||||
count(&self.prefix) + count(&self.size_gt) + count(&self.size_lt)
|
||||
}
|
||||
|
||||
pub fn validate_into_garage_lifecycle_filter(
|
||||
self,
|
||||
) -> Result<GarageLifecycleFilter, &'static str> {
|
||||
if self.count() > 0 && self.and.is_some() {
|
||||
Err("Filter tag cannot contain both <And> and another condition")
|
||||
} else if let Some(and) = self.and {
|
||||
if and.and.is_some() {
|
||||
return Err("Nested <And> tags");
|
||||
}
|
||||
Ok(and.internal_into_garage_lifecycle_filter())
|
||||
} else if self.count() > 1 {
|
||||
Err("Multiple Filter conditions must be wrapped in an <And> tag")
|
||||
} else {
|
||||
Ok(self.internal_into_garage_lifecycle_filter())
|
||||
}
|
||||
}
|
||||
|
||||
fn internal_into_garage_lifecycle_filter(self) -> GarageLifecycleFilter {
|
||||
GarageLifecycleFilter {
|
||||
prefix: self.prefix.map(|x| x.0),
|
||||
size_gt: self.size_gt.map(|x| x.0 as u64),
|
||||
size_lt: self.size_lt.map(|x| x.0 as u64),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_garage_lifecycle_filter(rule: &GarageLifecycleFilter) -> Option<Self> {
|
||||
let filter = Filter {
|
||||
and: None,
|
||||
prefix: rule.prefix.as_deref().map(Value::from),
|
||||
size_gt: rule.size_gt.map(|x| IntValue(x as i64)),
|
||||
size_lt: rule.size_lt.map(|x| IntValue(x as i64)),
|
||||
};
|
||||
match filter.count() {
|
||||
0 => None,
|
||||
1 => Some(filter),
|
||||
_ => Some(Filter {
|
||||
and: Some(Box::new(filter)),
|
||||
..Default::default()
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Expiration {
|
||||
pub fn validate_into_garage_lifecycle_expiration(
|
||||
self,
|
||||
) -> Result<GarageLifecycleExpiration, &'static str> {
|
||||
match (self.days, self.at_date) {
|
||||
(Some(_), Some(_)) => Err("cannot have both <Days> and <Date> in <Expiration>"),
|
||||
(None, None) => Err("<Expiration> must contain either <Days> or <Date>"),
|
||||
(Some(days), None) => Ok(GarageLifecycleExpiration::AfterDays(days.0 as usize)),
|
||||
(None, Some(date)) => {
|
||||
parse_lifecycle_date(&date.0)?;
|
||||
Ok(GarageLifecycleExpiration::AtDate(date.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_garage_lifecycle_expiration(exp: &GarageLifecycleExpiration) -> Self {
|
||||
match exp {
|
||||
GarageLifecycleExpiration::AfterDays(days) => Expiration {
|
||||
days: Some(IntValue(*days as i64)),
|
||||
at_date: None,
|
||||
},
|
||||
GarageLifecycleExpiration::AtDate(date) => Expiration {
|
||||
days: None,
|
||||
at_date: Some(Value(date.to_string())),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::xml::{to_xml_with_header, unprettify_xml};
|
||||
|
||||
use super::*;
|
||||
|
||||
use quick_xml::de::from_str;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_lifecycle_config() {
|
||||
let message = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Rule>
|
||||
<ID>id1</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<Prefix>documents/</Prefix>
|
||||
</Filter>
|
||||
<AbortIncompleteMultipartUpload>
|
||||
<DaysAfterInitiation>7</DaysAfterInitiation>
|
||||
</AbortIncompleteMultipartUpload>
|
||||
</Rule>
|
||||
<Rule>
|
||||
<ID>id2</ID>
|
||||
<Status>Enabled</Status>
|
||||
<Filter>
|
||||
<And>
|
||||
<Prefix>logs/</Prefix>
|
||||
<ObjectSizeGreaterThan>1000000</ObjectSizeGreaterThan>
|
||||
</And>
|
||||
</Filter>
|
||||
<Expiration>
|
||||
<Days>365</Days>
|
||||
</Expiration>
|
||||
</Rule>
|
||||
</LifecycleConfiguration>"#;
|
||||
let conf: LifecycleConfiguration = from_str(message).unwrap();
|
||||
let ref_value = LifecycleConfiguration {
|
||||
xmlns: (),
|
||||
lifecycle_rules: vec![
|
||||
LifecycleRule {
|
||||
id: Some("id1".into()),
|
||||
status: "Enabled".into(),
|
||||
filter: Some(Filter {
|
||||
prefix: Some("documents/".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
expiration: None,
|
||||
abort_incomplete_mpu: Some(AbortIncompleteMpu { days: IntValue(7) }),
|
||||
},
|
||||
LifecycleRule {
|
||||
id: Some("id2".into()),
|
||||
status: "Enabled".into(),
|
||||
filter: Some(Filter {
|
||||
and: Some(Box::new(Filter {
|
||||
prefix: Some("logs/".into()),
|
||||
size_gt: Some(IntValue(1000000)),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
}),
|
||||
expiration: Some(Expiration {
|
||||
days: Some(IntValue(365)),
|
||||
at_date: None,
|
||||
}),
|
||||
abort_incomplete_mpu: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq! {
|
||||
ref_value,
|
||||
conf
|
||||
};
|
||||
|
||||
let message2 = to_xml_with_header(&ref_value).expect("serialize xml");
|
||||
|
||||
assert_eq!(unprettify_xml(message), unprettify_xml(&message2));
|
||||
|
||||
// Check validation
|
||||
let validated = ref_value
|
||||
.validate_into_garage_lifecycle_config()
|
||||
.expect("invalid xml config");
|
||||
|
||||
let ref_config = vec![
|
||||
GarageLifecycleRule {
|
||||
id: Some("id1".into()),
|
||||
enabled: true,
|
||||
filter: GarageLifecycleFilter {
|
||||
prefix: Some("documents/".into()),
|
||||
..Default::default()
|
||||
},
|
||||
expiration: None,
|
||||
abort_incomplete_mpu_days: Some(7),
|
||||
},
|
||||
GarageLifecycleRule {
|
||||
id: Some("id2".into()),
|
||||
enabled: true,
|
||||
filter: GarageLifecycleFilter {
|
||||
prefix: Some("logs/".into()),
|
||||
size_gt: Some(1000000),
|
||||
..Default::default()
|
||||
},
|
||||
expiration: Some(GarageLifecycleExpiration::AfterDays(365)),
|
||||
abort_incomplete_mpu_days: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(validated, ref_config);
|
||||
|
||||
let message3 = to_xml_with_header(&LifecycleConfiguration::from_garage_lifecycle_config(
|
||||
&validated,
|
||||
))
|
||||
.expect("serialize xml");
|
||||
assert_eq!(unprettify_xml(message), unprettify_xml(&message3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
pub mod cors;
|
||||
pub mod lifecycle;
|
||||
pub mod website;
|
||||
|
||||
use serde::{Deserialize, Serialize, Serializer};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
pub fn to_xml_with_header<T: Serialize>(x: &T) -> Result<String, quick_xml::se::SeError> {
|
||||
use quick_xml::se::{self, EmptyElementHandling, QuoteLevel};
|
||||
|
||||
let mut xml = r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string();
|
||||
|
||||
let mut ser = se::Serializer::new(&mut xml);
|
||||
ser.set_quote_level(QuoteLevel::Full)
|
||||
.empty_element_handling(EmptyElementHandling::Expanded);
|
||||
let _serialized = x.serialize(ser)?;
|
||||
Ok(xml)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn unprettify_xml(xml_in: &str) -> String {
|
||||
xml_in.trim().lines().fold(String::new(), |mut val, line| {
|
||||
val.push_str(line.trim());
|
||||
val
|
||||
})
|
||||
}
|
||||
|
||||
pub fn xmlns_tag<S: Serializer>(_v: &(), s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str("http://s3.amazonaws.com/doc/2006-03-01/")
|
||||
}
|
||||
|
||||
pub fn xmlns_xsi_tag<S: Serializer>(_v: &(), s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str("http://www.w3.org/2001/XMLSchema-instance")
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = xml::Value)]
|
||||
pub struct Value(#[serde(rename = "$value")] pub String);
|
||||
|
||||
impl From<&str> for Value {
|
||||
fn from(s: &str) -> Value {
|
||||
Value(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = xml::IntValue)]
|
||||
pub struct IntValue(#[serde(rename = "$value")] pub i64);
|
||||
@@ -0,0 +1,423 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use garage_model::bucket_table::{self, RoutingRule as GarageRoutingRule, WebsiteConfig};
|
||||
|
||||
use crate::common_error::CommonError as Error;
|
||||
use crate::xml::{xmlns_tag, IntValue, Value};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct WebsiteConfiguration {
|
||||
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag", skip_deserializing)]
|
||||
pub xmlns: (),
|
||||
#[serde(rename = "ErrorDocument", skip_serializing_if = "Option::is_none")]
|
||||
pub error_document: Option<Key>,
|
||||
#[serde(rename = "IndexDocument", skip_serializing_if = "Option::is_none")]
|
||||
pub index_document: Option<Suffix>,
|
||||
#[serde(
|
||||
rename = "RedirectAllRequestsTo",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub redirect_all_requests_to: Option<Target>,
|
||||
#[serde(
|
||||
rename = "RoutingRules",
|
||||
default,
|
||||
skip_serializing_if = "RoutingRules::is_empty"
|
||||
)]
|
||||
pub routing_rules: RoutingRules,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub struct RoutingRules {
|
||||
#[serde(rename = "RoutingRule")]
|
||||
pub rules: Vec<RoutingRule>,
|
||||
}
|
||||
|
||||
impl RoutingRules {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = website::RoutingRule)]
|
||||
pub struct RoutingRule {
|
||||
#[serde(rename = "Condition")]
|
||||
pub condition: Option<Condition>,
|
||||
#[serde(rename = "Redirect")]
|
||||
pub redirect: Redirect,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = website::Key)]
|
||||
pub struct Key {
|
||||
#[serde(rename = "Key")]
|
||||
pub key: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = website::Suffix)]
|
||||
pub struct Suffix {
|
||||
#[serde(rename = "Suffix")]
|
||||
pub suffix: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = website::Target)]
|
||||
pub struct Target {
|
||||
#[serde(rename = "HostName")]
|
||||
pub hostname: Value,
|
||||
#[serde(rename = "Protocol")]
|
||||
pub protocol: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = website::Condition)]
|
||||
pub struct Condition {
|
||||
#[serde(
|
||||
rename = "HttpErrorCodeReturnedEquals",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub http_error_code: Option<IntValue>,
|
||||
#[serde(rename = "KeyPrefixEquals", skip_serializing_if = "Option::is_none")]
|
||||
pub prefix: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
|
||||
#[schema(as = website::Redirect)]
|
||||
pub struct Redirect {
|
||||
#[serde(rename = "HostName", skip_serializing_if = "Option::is_none")]
|
||||
pub hostname: Option<Value>,
|
||||
#[serde(rename = "Protocol", skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<Value>,
|
||||
#[serde(rename = "HttpRedirectCode", skip_serializing_if = "Option::is_none")]
|
||||
pub http_redirect_code: Option<IntValue>,
|
||||
#[serde(
|
||||
rename = "ReplaceKeyPrefixWith",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub replace_prefix: Option<Value>,
|
||||
#[serde(rename = "ReplaceKeyWith", skip_serializing_if = "Option::is_none")]
|
||||
pub replace_full: Option<Value>,
|
||||
}
|
||||
|
||||
impl WebsiteConfiguration {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
if self.redirect_all_requests_to.is_some()
|
||||
&& (self.error_document.is_some()
|
||||
|| self.index_document.is_some()
|
||||
|| !self.routing_rules.is_empty())
|
||||
{
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: can't have RedirectAllRequestsTo and other fields",
|
||||
));
|
||||
}
|
||||
if let Some(ref ed) = self.error_document {
|
||||
ed.validate()?;
|
||||
}
|
||||
if let Some(ref id) = self.index_document {
|
||||
id.validate()?;
|
||||
}
|
||||
if let Some(ref rart) = self.redirect_all_requests_to {
|
||||
rart.validate()?;
|
||||
}
|
||||
for rr in &self.routing_rules.rules {
|
||||
rr.validate()?;
|
||||
}
|
||||
if self.routing_rules.rules.len() > 1000 {
|
||||
// we will do linear scans, best to avoid overly long configuration. The
|
||||
// limit was chosen arbitrarily
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: RoutingRules can't have more than 1000 child elements",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn into_garage_website_config(self) -> Result<WebsiteConfig, Error> {
|
||||
if self.redirect_all_requests_to.is_some() {
|
||||
Err(Error::NotImplemented(
|
||||
"RedirectAllRequestsTo is not currently implemented in Garage, however its effect can be emulated using a single unconditional RoutingRule.".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(WebsiteConfig {
|
||||
index_document: self
|
||||
.index_document
|
||||
.map(|x| x.suffix.0)
|
||||
.unwrap_or_else(|| "index.html".to_string()),
|
||||
error_document: self.error_document.map(|x| x.key.0),
|
||||
redirect_all: None,
|
||||
routing_rules: self
|
||||
.routing_rules
|
||||
.rules
|
||||
.into_iter()
|
||||
.map(RoutingRule::into_garage_routing_rule)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Key {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
if self.key.0.is_empty() {
|
||||
Err(Error::bad_request(
|
||||
"Bad XML: error document specified but empty",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Suffix {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
if self.suffix.0.is_empty() | self.suffix.0.contains('/') {
|
||||
Err(Error::bad_request(
|
||||
"Bad XML: index document is empty or contains /",
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Target {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
if let Some(ref protocol) = self.protocol {
|
||||
if protocol.0 != "http" && protocol.0 != "https" {
|
||||
return Err(Error::bad_request("Bad XML: invalid protocol"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RoutingRule {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
if let Some(condition) = &self.condition {
|
||||
condition.validate()?;
|
||||
}
|
||||
self.redirect.validate()
|
||||
}
|
||||
|
||||
pub fn from_garage_routing_rule(rule: GarageRoutingRule) -> Self {
|
||||
RoutingRule {
|
||||
condition: rule.condition.map(|cond| Condition {
|
||||
http_error_code: cond.http_error_code.map(|c| IntValue(c as i64)),
|
||||
prefix: cond.prefix.map(Value),
|
||||
}),
|
||||
redirect: Redirect {
|
||||
hostname: rule.redirect.hostname.map(Value),
|
||||
http_redirect_code: Some(IntValue(rule.redirect.http_redirect_code as i64)),
|
||||
protocol: rule.redirect.protocol.map(Value),
|
||||
replace_full: rule.redirect.replace_key.map(Value),
|
||||
replace_prefix: rule.redirect.replace_key_prefix.map(Value),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_garage_routing_rule(self) -> bucket_table::RoutingRule {
|
||||
bucket_table::RoutingRule {
|
||||
condition: self
|
||||
.condition
|
||||
.map(|condition| bucket_table::RedirectCondition {
|
||||
http_error_code: condition.http_error_code.map(|c| c.0 as u16),
|
||||
prefix: condition.prefix.map(|p| p.0),
|
||||
}),
|
||||
redirect: bucket_table::Redirect {
|
||||
hostname: self.redirect.hostname.map(|h| h.0),
|
||||
protocol: self.redirect.protocol.map(|p| p.0),
|
||||
// aws default to 301, which i find punitive in case of
|
||||
// misconfiguration (can be permanently cached on the
|
||||
// user agent)
|
||||
http_redirect_code: self
|
||||
.redirect
|
||||
.http_redirect_code
|
||||
.map(|c| c.0 as u16)
|
||||
.unwrap_or(302),
|
||||
replace_key_prefix: self.redirect.replace_prefix.map(|k| k.0),
|
||||
replace_key: self.redirect.replace_full.map(|k| k.0),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Condition {
|
||||
pub fn validate(&self) -> Result<bool, Error> {
|
||||
if let Some(ref error_code) = self.http_error_code {
|
||||
// TODO do other error codes make sense? Aws only allows 4xx and 5xx
|
||||
if error_code.0 != 404 {
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: HttpErrorCodeReturnedEquals must be 404 or absent",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(self.prefix.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
impl Redirect {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
if self.replace_prefix.is_some() && self.replace_full.is_some() {
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set",
|
||||
));
|
||||
}
|
||||
if let Some(ref protocol) = self.protocol {
|
||||
if protocol.0 != "http" && protocol.0 != "https" {
|
||||
return Err(Error::bad_request("Bad XML: invalid protocol"));
|
||||
}
|
||||
}
|
||||
if let Some(ref http_redirect_code) = self.http_redirect_code {
|
||||
match http_redirect_code.0 {
|
||||
// aws allows all 3xx except 300, but some are non-sensical (not modified,
|
||||
// use proxy...)
|
||||
301 | 302 | 303 | 307 | 308 => {
|
||||
if self.hostname.is_none() && self.protocol.is_some() {
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: HostName must be set if Protocol is set",
|
||||
));
|
||||
}
|
||||
}
|
||||
// aws doesn't allow these codes, but netlify does, and it seems like a
|
||||
// cool feature (change the page seen without changing the url shown by the
|
||||
// user agent)
|
||||
200 | 404 => {
|
||||
if self.hostname.is_some() || self.protocol.is_some() {
|
||||
// hostname would mean different bucket, protocol doesn't make
|
||||
// sense
|
||||
return Err(Error::bad_request(
|
||||
"Bad XML: an HttpRedirectCode of 200 is not acceptable alongside HostName or Protocol",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::bad_request("Bad XML: invalid HttpRedirectCode"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::xml::{to_xml_with_header, unprettify_xml};
|
||||
|
||||
use super::*;
|
||||
|
||||
use quick_xml::de::from_str;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize() {
|
||||
let message = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<ErrorDocument>
|
||||
<Key>my-error-doc</Key>
|
||||
</ErrorDocument>
|
||||
<IndexDocument>
|
||||
<Suffix>my-index</Suffix>
|
||||
</IndexDocument>
|
||||
<RedirectAllRequestsTo>
|
||||
<HostName>garage.tld</HostName>
|
||||
<Protocol>https</Protocol>
|
||||
</RedirectAllRequestsTo>
|
||||
<RoutingRules>
|
||||
<RoutingRule>
|
||||
<Condition>
|
||||
<HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
|
||||
<KeyPrefixEquals>prefix1</KeyPrefixEquals>
|
||||
</Condition>
|
||||
<Redirect>
|
||||
<HostName>gara.ge</HostName>
|
||||
<Protocol>http</Protocol>
|
||||
<HttpRedirectCode>303</HttpRedirectCode>
|
||||
<ReplaceKeyPrefixWith>prefix2</ReplaceKeyPrefixWith>
|
||||
<ReplaceKeyWith>fullkey</ReplaceKeyWith>
|
||||
</Redirect>
|
||||
</RoutingRule>
|
||||
<RoutingRule>
|
||||
<Condition>
|
||||
<KeyPrefixEquals></KeyPrefixEquals>
|
||||
</Condition>
|
||||
<Redirect>
|
||||
<HttpRedirectCode>404</HttpRedirectCode>
|
||||
<ReplaceKeyWith>missing</ReplaceKeyWith>
|
||||
</Redirect>
|
||||
</RoutingRule>
|
||||
</RoutingRules>
|
||||
</WebsiteConfiguration>"#;
|
||||
let conf: WebsiteConfiguration =
|
||||
from_str(message).expect("failed to deserialize xml in `WebsiteConfiguration`");
|
||||
let ref_value = WebsiteConfiguration {
|
||||
xmlns: (),
|
||||
error_document: Some(Key {
|
||||
key: Value("my-error-doc".to_owned()),
|
||||
}),
|
||||
index_document: Some(Suffix {
|
||||
suffix: Value("my-index".to_owned()),
|
||||
}),
|
||||
redirect_all_requests_to: Some(Target {
|
||||
hostname: Value("garage.tld".to_owned()),
|
||||
protocol: Some(Value("https".to_owned())),
|
||||
}),
|
||||
routing_rules: RoutingRules {
|
||||
rules: vec![
|
||||
RoutingRule {
|
||||
condition: Some(Condition {
|
||||
http_error_code: Some(IntValue(404)),
|
||||
prefix: Some(Value("prefix1".to_owned())),
|
||||
}),
|
||||
redirect: Redirect {
|
||||
hostname: Some(Value("gara.ge".to_owned())),
|
||||
protocol: Some(Value("http".to_owned())),
|
||||
http_redirect_code: Some(IntValue(303)),
|
||||
replace_prefix: Some(Value("prefix2".to_owned())),
|
||||
replace_full: Some(Value("fullkey".to_owned())),
|
||||
},
|
||||
},
|
||||
RoutingRule {
|
||||
condition: Some(Condition {
|
||||
http_error_code: None,
|
||||
prefix: Some(Value("".to_owned())),
|
||||
}),
|
||||
redirect: Redirect {
|
||||
hostname: None,
|
||||
protocol: None,
|
||||
http_redirect_code: Some(IntValue(404)),
|
||||
replace_prefix: None,
|
||||
replace_full: Some(Value("missing".to_owned())),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
assert_eq! {
|
||||
ref_value,
|
||||
conf
|
||||
}
|
||||
|
||||
let message2 = to_xml_with_header(&ref_value).expect("xml serialization");
|
||||
|
||||
assert_eq!(unprettify_xml(message), unprettify_xml(&message2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_empty() {
|
||||
let conf = WebsiteConfiguration {
|
||||
xmlns: (),
|
||||
error_document: None,
|
||||
index_document: None,
|
||||
redirect_all_requests_to: None,
|
||||
routing_rules: RoutingRules { rules: vec![] },
|
||||
};
|
||||
let serialized_ref = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
</WebsiteConfiguration>"#;
|
||||
|
||||
let serialized = to_xml_with_header(&conf).expect("xml serialization");
|
||||
assert_eq!(unprettify_xml(&serialized), unprettify_xml(&serialized_ref));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_api_k2v"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
@@ -35,3 +35,6 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
opentelemetry.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -111,7 +111,7 @@ impl ApiHandler for K2VApiServer {
|
||||
Method::GET | Method::HEAD | Method::POST => {
|
||||
find_matching_cors_rule(&bucket_params, &req)
|
||||
.ok_or_internal_error("Error looking up CORS rule")?
|
||||
.cloned()
|
||||
.map(|(rule, origin)| (rule.clone(), origin.to_string()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@@ -164,8 +164,8 @@ impl ApiHandler for K2VApiServer {
|
||||
// If request was a success and we have a CORS rule that applies to it,
|
||||
// add the corresponding CORS headers to the response
|
||||
let mut resp_ok = resp?;
|
||||
if let Some(rule) = matching_cors_rule {
|
||||
add_cors_headers(&mut resp_ok, &rule)
|
||||
if let Some((rule, origin)) = matching_cors_rule {
|
||||
add_cors_headers(&mut resp_ok, &rule, &origin)
|
||||
.ok_or_internal_error("Invalid bucket CORS configuration")?;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,13 @@ pub enum Error {
|
||||
|
||||
// Category: cannot process
|
||||
/// Authorization Header Malformed
|
||||
#[error("Authorization header malformed, unexpected scope: {0}")]
|
||||
AuthorizationHeaderMalformed(String),
|
||||
#[error(
|
||||
"Authorization header malformed, unexpected scope: '{unexpected}', expected: '{expected}'"
|
||||
)]
|
||||
AuthorizationHeaderMalformed {
|
||||
unexpected: String,
|
||||
expected: String,
|
||||
},
|
||||
|
||||
/// The provided digest (checksum) value was invalid
|
||||
#[error("Invalid digest: {0}")]
|
||||
@@ -54,9 +59,13 @@ impl From<SignatureError> for Error {
|
||||
fn from(err: SignatureError) -> Self {
|
||||
match err {
|
||||
SignatureError::Common(c) => Self::Common(c),
|
||||
SignatureError::AuthorizationHeaderMalformed(c) => {
|
||||
Self::AuthorizationHeaderMalformed(c)
|
||||
}
|
||||
SignatureError::AuthorizationHeaderMalformed {
|
||||
unexpected,
|
||||
expected,
|
||||
} => Self::AuthorizationHeaderMalformed {
|
||||
unexpected,
|
||||
expected,
|
||||
},
|
||||
SignatureError::InvalidUtf8Str(i) => Self::InvalidUtf8Str(i),
|
||||
SignatureError::InvalidDigest(d) => Self::InvalidDigest(d),
|
||||
}
|
||||
@@ -72,7 +81,7 @@ impl Error {
|
||||
Error::Common(c) => c.aws_code(),
|
||||
Error::NoSuchKey => "NoSuchKey",
|
||||
Error::NotAcceptable(_) => "NotAcceptable",
|
||||
Error::AuthorizationHeaderMalformed(_) => "AuthorizationHeaderMalformed",
|
||||
Error::AuthorizationHeaderMalformed { .. } => "AuthorizationHeaderMalformed",
|
||||
Error::InvalidBase64(_) => "InvalidBase64",
|
||||
Error::InvalidUtf8Str(_) => "InvalidUtf8String",
|
||||
Error::InvalidCausalityToken => "CausalityToken",
|
||||
@@ -88,7 +97,7 @@ impl ApiError for Error {
|
||||
Error::Common(c) => c.http_status_code(),
|
||||
Error::NoSuchKey => StatusCode::NOT_FOUND,
|
||||
Error::NotAcceptable(_) => StatusCode::NOT_ACCEPTABLE,
|
||||
Error::AuthorizationHeaderMalformed(_)
|
||||
Error::AuthorizationHeaderMalformed { .. }
|
||||
| Error::InvalidBase64(_)
|
||||
| Error::InvalidUtf8Str(_)
|
||||
| Error::InvalidDigest(_)
|
||||
|
||||
@@ -97,7 +97,7 @@ impl ReturnFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle ReadItem request
|
||||
/// Handle `ReadItem` request
|
||||
#[allow(clippy::ptr_arg)]
|
||||
pub async fn handle_read_item(
|
||||
ctx: ReqCtx,
|
||||
@@ -201,7 +201,7 @@ pub async fn handle_delete_item(
|
||||
.body(empty_body())?)
|
||||
}
|
||||
|
||||
/// Handle ReadItem request
|
||||
/// Handle `ReadItem` request
|
||||
#[allow(clippy::ptr_arg)]
|
||||
pub async fn handle_poll_item(
|
||||
ctx: ReqCtx,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Utility module for retrieving ranges of items in Garage tables
|
||||
//! Implements parameters (prefix, start, end, limit) as specified
|
||||
//! for endpoints ReadIndex, ReadBatch and DeleteBatch
|
||||
//! for endpoints `ReadIndex`, `ReadBatch` and `DeleteBatch`
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ pub enum Endpoint {
|
||||
impl Endpoint {
|
||||
/// Determine which S3 endpoint a request is for using the request, and a bucket which was
|
||||
/// possibly extracted from the Host header.
|
||||
/// Returns Self plus bucket name, if endpoint is not Endpoint::ListBuckets
|
||||
/// Returns Self plus bucket name, if endpoint is not `Endpoint::ListBuckets`
|
||||
pub fn from_request<T>(req: &Request<T>) -> Result<(Self, String), Error> {
|
||||
let uri = req.uri();
|
||||
let path = uri.path().trim_start_matches('/');
|
||||
@@ -62,7 +62,7 @@ impl Endpoint {
|
||||
let (bucket, partition_key) = path
|
||||
.split_once('/')
|
||||
.map(|(b, p)| (b.to_owned(), p.trim_start_matches('/')))
|
||||
.unwrap_or((path.to_owned(), ""));
|
||||
.unwrap_or_else(|| (path.to_owned(), ""));
|
||||
|
||||
if bucket.is_empty() {
|
||||
return Err(Error::bad_request("Missing bucket name"));
|
||||
@@ -90,7 +90,7 @@ impl Endpoint {
|
||||
};
|
||||
|
||||
if let Some(message) = query.nonempty_message() {
|
||||
debug!("Unused query parameter: {}", message)
|
||||
debug!("Unused query parameter: {}", message);
|
||||
}
|
||||
Ok((res, bucket))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_api_s3"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
@@ -58,3 +58,6 @@ serde_json.workspace = true
|
||||
quick-xml.workspace = true
|
||||
|
||||
opentelemetry.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -13,6 +13,7 @@ use garage_util::socket_address::UnixOrTCPSocketAddress;
|
||||
use garage_model::garage::Garage;
|
||||
use garage_model::key_table::Key;
|
||||
|
||||
use garage_api_common::common_error::CommonError;
|
||||
use garage_api_common::cors::*;
|
||||
use garage_api_common::generic_server::*;
|
||||
use garage_api_common::helpers::*;
|
||||
@@ -64,7 +65,7 @@ impl S3ApiServer {
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
match endpoint {
|
||||
Endpoint::ListBuckets => handle_list_buckets(&self.garage, &api_key).await,
|
||||
endpoint => Err(Error::NotImplemented(endpoint.name().to_owned())),
|
||||
endpoint => Err(CommonError::NotImplemented(endpoint.name().to_owned()).into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,7 +159,8 @@ impl ApiHandler for S3ApiServer {
|
||||
return Err(Error::forbidden("Operation is not allowed for this key."));
|
||||
}
|
||||
|
||||
let matching_cors_rule = find_matching_cors_rule(&bucket_params, &req)?.cloned();
|
||||
let matching_cors = find_matching_cors_rule(&bucket_params, &req)?
|
||||
.map(|(rule, origin)| (rule.clone(), origin.to_string()));
|
||||
|
||||
let ctx = ReqCtx {
|
||||
garage,
|
||||
@@ -327,14 +329,14 @@ impl ApiHandler for S3ApiServer {
|
||||
Endpoint::GetBucketLifecycleConfiguration {} => handle_get_lifecycle(ctx).await,
|
||||
Endpoint::PutBucketLifecycleConfiguration {} => handle_put_lifecycle(ctx, req).await,
|
||||
Endpoint::DeleteBucketLifecycle {} => handle_delete_lifecycle(ctx).await,
|
||||
endpoint => Err(Error::NotImplemented(endpoint.name().to_owned())),
|
||||
endpoint => Err(CommonError::NotImplemented(endpoint.name().to_owned()).into()),
|
||||
};
|
||||
|
||||
// If request was a success and we have a CORS rule that applies to it,
|
||||
// add the corresponding CORS headers to the response
|
||||
let mut resp_ok = resp?;
|
||||
if let Some(rule) = matching_cors_rule {
|
||||
add_cors_headers(&mut resp_ok, &rule)
|
||||
if let Some((rule, origin)) = matching_cors {
|
||||
add_cors_headers(&mut resp_ok, &rule, &origin)
|
||||
.ok_or_internal_error("Invalid bucket CORS configuration")?;
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ pub async fn handle_list_buckets(
|
||||
for (alias, _, _active) in bucket.aliases().iter().filter(|(_, _, active)| *active) {
|
||||
let alias_opt = garage.bucket_alias_table.get(&EmptyKey, alias).await?;
|
||||
if let Some(alias_ent) = alias_opt {
|
||||
if *alias_ent.state.get() == Some(*bucket_id) {
|
||||
if alias_ent.state.get().inner() == Some(bucket_id) {
|
||||
aliases.insert(alias_ent.name().to_string(), *bucket_id);
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ pub async fn handle_list_buckets(
|
||||
}
|
||||
|
||||
for (alias, _, id_opt) in key_p.local_aliases.items() {
|
||||
if let Some(id) = id_opt {
|
||||
if let Some(id) = id_opt.inner() {
|
||||
aliases.insert(alias.clone(), *id);
|
||||
}
|
||||
}
|
||||
@@ -198,12 +198,14 @@ pub async fn handle_create_bucket(
|
||||
.await?;
|
||||
|
||||
if let Some(bucket) = existing_bucket {
|
||||
// Check we have write or owner permission on the bucket,
|
||||
// in that case it's fine, return 200 OK, bucket exists;
|
||||
// otherwise return a forbidden error.
|
||||
// According to https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html
|
||||
// in such case we have to return 409 BucketAlreadyOwnedByYou if request was sent
|
||||
// by bucket owner and 409 BucketAlreadyExists otherwise.
|
||||
let kp = api_key.bucket_permissions(&bucket.id);
|
||||
if !(kp.allow_write || kp.allow_owner) {
|
||||
return Err(CommonError::BucketAlreadyExists.into());
|
||||
} else {
|
||||
return Err(CommonError::BucketAlreadyOwnedByYou.into());
|
||||
}
|
||||
} else {
|
||||
// Check user is allowed to create bucket
|
||||
@@ -254,7 +256,10 @@ pub async fn handle_delete_bucket(ctx: ReqCtx) -> Result<Response<ResBody>, Erro
|
||||
|
||||
let key_params = api_key.params().unwrap();
|
||||
|
||||
let is_local_alias = matches!(key_params.local_aliases.get(bucket_name), Some(Some(_)));
|
||||
let is_local_alias = matches!(
|
||||
key_params.local_aliases.get(bucket_name).map(|x| x.inner()),
|
||||
Some(Some(_))
|
||||
);
|
||||
|
||||
// If the bucket has no other aliases, this is a true deletion.
|
||||
// Otherwise, it is just an alias removal.
|
||||
@@ -328,8 +333,8 @@ fn parse_create_bucket_xml(xml_bytes: &[u8]) -> Option<Option<String>> {
|
||||
// Returns Some(None) if no location constraint is given
|
||||
// Returns Some(Some("xxxx")) where xxxx is the given location constraint
|
||||
|
||||
let xml_str = std::str::from_utf8(xml_bytes).ok()?;
|
||||
if xml_str.trim_matches(char::is_whitespace).is_empty() {
|
||||
let xml_str = std::str::from_utf8(xml_bytes).ok()?.trim();
|
||||
if xml_str.is_empty() {
|
||||
return Some(None);
|
||||
}
|
||||
|
||||
@@ -371,6 +376,7 @@ mod tests {
|
||||
#[test]
|
||||
fn create_bucket() {
|
||||
assert_eq!(parse_create_bucket_xml(br#""#), Some(None));
|
||||
assert_eq!(parse_create_bucket_xml(br#" "#), Some(None));
|
||||
assert_eq!(
|
||||
parse_create_bucket_xml(
|
||||
br#"
|
||||
|
||||
@@ -706,7 +706,7 @@ pub async fn handle_upload_part_copy(
|
||||
|
||||
let checksums = checksummer.finalize();
|
||||
let etag = dest_encryption.etag_from_md5(&checksums.md5);
|
||||
let checksum = checksums.extract(dest_object_checksum_algorithm.map(|(algo, _)| algo));
|
||||
let checksum = checksums.extract(dest_object_checksum_algorithm.map(|(algo, _)| algo))?;
|
||||
|
||||
// Put the part's ETag in the Versiontable
|
||||
dest_mpu.parts.put(
|
||||
@@ -853,7 +853,7 @@ pub struct CopyObjectResult {
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct CopyPartResult {
|
||||
#[serde(serialize_with = "xmlns_tag")]
|
||||
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
|
||||
pub xmlns: (),
|
||||
#[serde(rename = "LastModified")]
|
||||
pub last_modified: s3_xml::Value,
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
use quick_xml::de::from_reader;
|
||||
|
||||
use hyper::{header::HeaderName, Method, Request, Response, StatusCode};
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use garage_model::bucket_table::{Bucket, CorsRule as GarageCorsRule};
|
||||
use garage_model::bucket_table::Bucket;
|
||||
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::xml::cors::*;
|
||||
|
||||
use crate::api_server::{ReqBody, ResBody};
|
||||
use crate::error::*;
|
||||
use crate::xml::{to_xml_with_header, xmlns_tag, IntValue, Value};
|
||||
use crate::xml::to_xml_with_header;
|
||||
|
||||
pub async fn handle_get_cors(ctx: ReqCtx) -> Result<Response<ResBody>, Error> {
|
||||
let ReqCtx { bucket_params, .. } = ctx;
|
||||
if let Some(cors) = bucket_params.cors_config.get() {
|
||||
if let Some(cors) = bucket_params.cors_config.get().inner() {
|
||||
let wc = CorsConfiguration {
|
||||
xmlns: (),
|
||||
cors_rules: cors
|
||||
@@ -28,9 +27,7 @@ pub async fn handle_get_cors(ctx: ReqCtx) -> Result<Response<ResBody>, Error> {
|
||||
.header(http::header::CONTENT_TYPE, "application/xml")
|
||||
.body(string_body(xml))?)
|
||||
} else {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(empty_body())?)
|
||||
Err(Error::NoSuchCORSConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +38,7 @@ pub async fn handle_delete_cors(ctx: ReqCtx) -> Result<Response<ResBody>, Error>
|
||||
mut bucket_params,
|
||||
..
|
||||
} = ctx;
|
||||
bucket_params.cors_config.update(None);
|
||||
bucket_params.cors_config.update(None.into());
|
||||
garage
|
||||
.bucket_table
|
||||
.insert(&Bucket::present(bucket_id, bucket_params))
|
||||
@@ -70,7 +67,7 @@ pub async fn handle_put_cors(
|
||||
|
||||
bucket_params
|
||||
.cors_config
|
||||
.update(Some(conf.into_garage_cors_config()?));
|
||||
.update(Some(conf.into_garage_cors_config()?).into());
|
||||
garage
|
||||
.bucket_table
|
||||
.insert(&Bucket::present(bucket_id, bucket_params))
|
||||
@@ -80,194 +77,3 @@ pub async fn handle_put_cors(
|
||||
.status(StatusCode::OK)
|
||||
.body(empty_body())?)
|
||||
}
|
||||
|
||||
// ---- SERIALIZATION AND DESERIALIZATION TO/FROM S3 XML ----
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename = "CORSConfiguration")]
|
||||
pub struct CorsConfiguration {
|
||||
#[serde(serialize_with = "xmlns_tag", skip_deserializing)]
|
||||
pub xmlns: (),
|
||||
#[serde(rename = "CORSRule")]
|
||||
pub cors_rules: Vec<CorsRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct CorsRule {
|
||||
#[serde(rename = "ID")]
|
||||
pub id: Option<Value>,
|
||||
#[serde(rename = "MaxAgeSeconds")]
|
||||
pub max_age_seconds: Option<IntValue>,
|
||||
#[serde(rename = "AllowedOrigin")]
|
||||
pub allowed_origins: Vec<Value>,
|
||||
#[serde(rename = "AllowedMethod")]
|
||||
pub allowed_methods: Vec<Value>,
|
||||
#[serde(rename = "AllowedHeader", default)]
|
||||
pub allowed_headers: Vec<Value>,
|
||||
#[serde(rename = "ExposeHeader", default)]
|
||||
pub expose_headers: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct AllowedMethod {
|
||||
#[serde(rename = "AllowedMethod")]
|
||||
pub allowed_method: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct AllowedHeader {
|
||||
#[serde(rename = "AllowedHeader")]
|
||||
pub allowed_header: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ExposeHeader {
|
||||
#[serde(rename = "ExposeHeader")]
|
||||
pub expose_header: Value,
|
||||
}
|
||||
|
||||
impl CorsConfiguration {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
for r in self.cors_rules.iter() {
|
||||
r.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn into_garage_cors_config(self) -> Result<Vec<GarageCorsRule>, Error> {
|
||||
Ok(self
|
||||
.cors_rules
|
||||
.iter()
|
||||
.map(CorsRule::to_garage_cors_rule)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl CorsRule {
|
||||
pub fn validate(&self) -> Result<(), Error> {
|
||||
for method in self.allowed_methods.iter() {
|
||||
method
|
||||
.0
|
||||
.parse::<Method>()
|
||||
.ok_or_bad_request("Invalid CORSRule method")?;
|
||||
}
|
||||
for header in self
|
||||
.allowed_headers
|
||||
.iter()
|
||||
.chain(self.expose_headers.iter())
|
||||
{
|
||||
header
|
||||
.0
|
||||
.parse::<HeaderName>()
|
||||
.ok_or_bad_request("Invalid HTTP header name")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn to_garage_cors_rule(&self) -> GarageCorsRule {
|
||||
let convert_vec =
|
||||
|vval: &[Value]| vval.iter().map(|x| x.0.to_owned()).collect::<Vec<String>>();
|
||||
GarageCorsRule {
|
||||
id: self.id.as_ref().map(|x| x.0.to_owned()),
|
||||
max_age_seconds: self.max_age_seconds.as_ref().map(|x| x.0 as u64),
|
||||
allow_origins: convert_vec(&self.allowed_origins),
|
||||
allow_methods: convert_vec(&self.allowed_methods),
|
||||
allow_headers: convert_vec(&self.allowed_headers),
|
||||
expose_headers: convert_vec(&self.expose_headers),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_garage_cors_rule(rule: &GarageCorsRule) -> Self {
|
||||
let convert_vec = |vval: &[String]| {
|
||||
vval.iter()
|
||||
.map(|x| Value(x.clone()))
|
||||
.collect::<Vec<Value>>()
|
||||
};
|
||||
Self {
|
||||
id: rule.id.as_ref().map(|x| Value(x.clone())),
|
||||
max_age_seconds: rule.max_age_seconds.map(|x| IntValue(x as i64)),
|
||||
allowed_origins: convert_vec(&rule.allow_origins),
|
||||
allowed_methods: convert_vec(&rule.allow_methods),
|
||||
allowed_headers: convert_vec(&rule.allow_headers),
|
||||
expose_headers: convert_vec(&rule.expose_headers),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use quick_xml::de::from_str;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize() -> Result<(), Error> {
|
||||
let message = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<CORSRule>
|
||||
<AllowedOrigin>http://www.example.com</AllowedOrigin>
|
||||
|
||||
<AllowedMethod>PUT</AllowedMethod>
|
||||
<AllowedMethod>POST</AllowedMethod>
|
||||
<AllowedMethod>DELETE</AllowedMethod>
|
||||
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
</CORSRule>
|
||||
<CORSRule>
|
||||
<AllowedOrigin>*</AllowedOrigin>
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
</CORSRule>
|
||||
<CORSRule>
|
||||
<ID>qsdfjklm</ID>
|
||||
<MaxAgeSeconds>12345</MaxAgeSeconds>
|
||||
<AllowedOrigin>https://perdu.com</AllowedOrigin>
|
||||
|
||||
<AllowedMethod>GET</AllowedMethod>
|
||||
<AllowedMethod>DELETE</AllowedMethod>
|
||||
<AllowedHeader>*</AllowedHeader>
|
||||
<ExposeHeader>*</ExposeHeader>
|
||||
</CORSRule>
|
||||
</CORSConfiguration>"#;
|
||||
let conf: CorsConfiguration = from_str(message).unwrap();
|
||||
let ref_value = CorsConfiguration {
|
||||
xmlns: (),
|
||||
cors_rules: vec![
|
||||
CorsRule {
|
||||
id: None,
|
||||
max_age_seconds: None,
|
||||
allowed_origins: vec!["http://www.example.com".into()],
|
||||
allowed_methods: vec!["PUT".into(), "POST".into(), "DELETE".into()],
|
||||
allowed_headers: vec!["*".into()],
|
||||
expose_headers: vec![],
|
||||
},
|
||||
CorsRule {
|
||||
id: None,
|
||||
max_age_seconds: None,
|
||||
allowed_origins: vec!["*".into()],
|
||||
allowed_methods: vec!["GET".into()],
|
||||
allowed_headers: vec![],
|
||||
expose_headers: vec![],
|
||||
},
|
||||
CorsRule {
|
||||
id: Some("qsdfjklm".into()),
|
||||
max_age_seconds: Some(IntValue(12345)),
|
||||
allowed_origins: vec!["https://perdu.com".into()],
|
||||
allowed_methods: vec!["GET".into(), "DELETE".into()],
|
||||
allowed_headers: vec!["*".into()],
|
||||
expose_headers: vec!["*".into()],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq! {
|
||||
ref_value,
|
||||
conf
|
||||
};
|
||||
|
||||
let message2 = to_xml_with_header(&ref_value)?;
|
||||
|
||||
let cleanup = |c: &str| c.replace(char::is_whitespace, "");
|
||||
assert_eq!(cleanup(message), cleanup(&message2));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,13 +125,22 @@ fn parse_delete_objects_xml(xml: &roxmltree::Document) -> Option<DeleteRequest>
|
||||
};
|
||||
|
||||
let root = xml.root();
|
||||
let delete = root.first_child()?;
|
||||
let delete = root.children().find(|n| n.is_element())?;
|
||||
|
||||
if !delete.has_tag_name("Delete") {
|
||||
return None;
|
||||
}
|
||||
|
||||
for item in delete.children() {
|
||||
// Skip text nodes introduced by formatted XML.
|
||||
if !item.is_element() {
|
||||
// text nodes are allowed only if they contain whitespace characters only
|
||||
if !item.text()?.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if item.has_tag_name("Object") {
|
||||
let key = item.children().find(|e| e.has_tag_name("Key"))?;
|
||||
let key_str = key.text()?;
|
||||
@@ -147,3 +156,59 @@ fn parse_delete_objects_xml(xml: &roxmltree::Document) -> Option<DeleteRequest>
|
||||
|
||||
Some(ret)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_delete_objects_xml_with_formatting() {
|
||||
let body = r#"
|
||||
<Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Object>
|
||||
<Key>1_746573745f66696c65</Key>
|
||||
</Object>
|
||||
<Quiet>true</Quiet>
|
||||
</Delete>
|
||||
"#;
|
||||
let xml = roxmltree::Document::parse(body).expect("valid delete XML");
|
||||
let req = parse_delete_objects_xml(&xml).expect("request should be parsed");
|
||||
|
||||
assert_eq!(req.objects.len(), 1);
|
||||
assert_eq!(req.objects[0].key, "1_746573745f66696c65");
|
||||
assert!(req.quiet);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_delete_objects_xml_rejects_non_whitespace_text_node() {
|
||||
let body = r#"<Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/">oops<Object><Key>1_746573745f66696c65</Key></Object></Delete>"#;
|
||||
let xml = roxmltree::Document::parse(body).expect("valid XML");
|
||||
let req = parse_delete_objects_xml(&xml);
|
||||
assert!(req.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_delete_objects_xml_rejects_pretty_print_with_stray_text() {
|
||||
let body = r#"
|
||||
<Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
oops
|
||||
<Object>
|
||||
<Key>1_746573745f66696c65</Key>
|
||||
</Object>
|
||||
</Delete>
|
||||
"#;
|
||||
let xml = roxmltree::Document::parse(body).expect("valid XML");
|
||||
let req = parse_delete_objects_xml(&xml);
|
||||
assert!(req.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_delete_objects_xml_accepts_compact_valid_xml() {
|
||||
let body = r#"<Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Object><Key>1_746573745f66696c65</Key></Object><Quiet>false</Quiet></Delete>"#;
|
||||
let xml = roxmltree::Document::parse(body).expect("valid XML");
|
||||
let req = parse_delete_objects_xml(&xml).expect("request should be parsed");
|
||||
assert_eq!(req.objects.len(), 1);
|
||||
assert_eq!(req.objects[0].key, "1_746573745f66696c65");
|
||||
assert!(!req.quiet);
|
||||
}
|
||||
}
|
||||
|
||||