Compare commits

...

104 Commits

Author SHA1 Message Date
Alex Auvolat 7b119c0b4f bump version number to v2.3.0 2026-04-16 18:34:27 +02:00
Alex Auvolat 02d5e67698 db: avoid iterating bounded from empty slice (fix #1401) (#1408)
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1408
Co-authored-by: Alex Auvolat <lx@deuxfleurs.fr>
Co-committed-by: Alex Auvolat <lx@deuxfleurs.fr>
2026-04-16 16:33:28 +00:00
maximilien 854280e957 Merge pull request 'helm: Conditionally skip CRD management RBAC rule' (#1248) from boris.m/garage:feat/drop-crd-management-rbac-rule into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1248
Reviewed-by: maximilien <git@mricher.fr>
2026-04-16 16:22:17 +00:00
B Marinov 9ea2b1d628 helm: Conditionally skip CRD management RBAC rule
Remove rule permitting changes to CRDs when garage.kubernetesSkipCrd is  set to true.
2026-04-16 16:22:17 +00:00
maximilien 7b7548a4f7 Merge pull request 'Fix helm existing configmap volume ref in workload' (#1388) from PhilleZi/garage:fix-helm-existing-configmap into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1388
Reviewed-by: maximilien <git@mricher.fr>
2026-04-16 16:20:27 +00:00
Philip Zingmark a2e410f8b6 Fix helm existing configmap volume ref in workload 2026-04-16 16:20:01 +00:00
Alex 690729ccdb Merge pull request 'fix: bound known_addrs growth and add TCP connect timeout' (#1345) from rajsinghtech/garage:fix/peering-stale-addr-reconnection into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1345
2026-04-15 11:42:38 +00:00
Alex Auvolat ff743453b6 garage_net: make pruning logic simpler and add test 2026-04-15 11:42:38 +00:00
Raj Singh f34a7db48a fix: bound known_addrs growth
known_addrs in PeerInfoInternal is append-only — addresses accumulate
via add_addr() and PeerList gossip but are never removed. In dynamic
environments (k8s pod restarts, DHCP, NAT traversal), this list grows
unboundedly with stale addresses.

Combined with sequential iteration in try_connect() and no TCP connect
timeout in netapp.rs, each unreachable address blocks reconnection for
the kernel's TCP SYN timeout (75-130s on Linux). With 10+ stale
addresses, worst-case reconnection exceeds 750s — a full outage for
replication_factor=3 clusters.

This commit contains the two following changes:

1. Address failure tracking and pruning (peering.rs): Track consecutive
   connection failures per address in PeerInfoInternal. After 3 failures,
   prune from known_addrs. Reset count when address is re-advertised via
   gossip or incoming connection. Prevents unbounded list growth.

2. Shuffle before connecting (peering.rs): Randomize address order in
   try_connect() so the valid address (often appended last) gets a fair
   chance instead of always trying stale addresses first.
2026-04-15 11:42:38 +00:00
Raj Singh 3a355b1617 fix: add TCP connect timeout
known_addrs in PeerInfoInternal is append-only — addresses accumulate
via add_addr() and PeerList gossip but are never removed. In dynamic
environments (k8s pod restarts, DHCP, NAT traversal), this list grows
unboundedly with stale addresses.

Combined with sequential iteration in try_connect() and no TCP connect
timeout in netapp.rs, each unreachable address blocks reconnection for
the kernel's TCP SYN timeout (75-130s on Linux). With 10+ stale
addresses, worst-case reconnection exceeds 750s — a full outage for
replication_factor=3 clusters.

This patches includes a first change to fix this issue:

1. TCP connect timeout (netapp.rs): Wrap TcpStream::connect() in
   tokio::time::timeout(10s). Caps per-address attempt from 75-130s
   to 10s, reducing worst-case 10-addr reconnection from ~750s to ~100s.
2026-04-15 11:42:38 +00:00
Alex 0b5e82a18b Merge pull request 'Cherry-pick #1396 for main-v2' (#1404) from fix-starvation into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1404
2026-04-15 10:35:22 +00:00
Gauthier Zirnhelt 2798667345 Fix the LifecycleWorker being uncooperative (#1396)
## Summary

This PR ensures that the `LifecycleWorker` yields at least once to the Tokio scheduler in between each batch of 100 objects.

## Problem being solved

I'm administrating a Garage cluster which has been experiencing timeouts on all endpoints while the lifecycle worker is running at midnight UTC : `Ping timeout` error messages and even requests eventually failing due to `Could not reach quorum ...`.

I have found that this happens while the lifecycle worker is working on a big bucket (containing millions of objects) with a lifecycle rule that applies to very few objects.
The `process_object()` function does not hit any `await`:
- `last_bucket` is always the same, so the `bucket_table` is not read asynchronously
- no transaction is made on the `object_table` because my lifecycle rule (almost) never applies to any object

The first commit in this PR adds an executable which reproduces the problem that I've been experiencing in a self-contained way : the lifecycle worker starves the Tokio scheduler so much that no other task is able to run (or very rarely).
To run it : `cargo run -p garage_model --bin lifecycle-starvation-test`.
This commit can be dropped post-review, as it's only useful to demonstrate the starvation.

The error messages completely stopped after adding the extra yield to the nodes of my cluster.
The duration of the lifecycle worker task does not appear to have changed at all from what I can see (looking at the timestamps produced either by the self-contained binary or by each of my nodes with the `Lifecycle worker finished` message).

## Note

An other potential fix would have been to force the `WorkerProcessor` to yield before re-enqueuing a busy task, but this would have affected all Garage workers even though it's only the `LifecycleWorker` being uncooperative.

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1396
Reviewed-by: Alex <lx@deuxfleurs.fr>
Co-authored-by: Gauthier Zirnhelt <gauthier.zirnhelt@insimo.fr>
Co-committed-by: Gauthier Zirnhelt <gauthier.zirnhelt@insimo.fr>
2026-04-15 12:13:18 +02:00
Alex b1660f0cba Merge pull request 'document known issues' (#1379) from doc-known-issues into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1379
2026-04-15 10:11:39 +00:00
Alex Auvolat dfb20ba87f doc: write details of known issues 2026-04-15 10:11:39 +00:00
maximilien 7279cb9113 Add comment on tags 2026-04-15 10:11:39 +00:00
Alex Auvolat 56cb89d153 wip: list known issues in documentation 2026-04-15 10:11:39 +00:00
Armael 6fd9bba0cb WebsiteConfiguration: do not emit empty XML attributes for absent values (#1391)
This fixes a regression wrt garage-v1, likely caused by the version upgrade of quick_xml.

Currently, garage-v2 will emit empty ErrorDocument/IndexDocument/RedirectAllRequestsTo attributes in the response of GetBucketWebsite if there are no corresponding values.
This is somewhat wrong; at least, the S3 documentation for RedirectAllRequestsTo (https://docs.aws.amazon.com/AmazonS3/latest/API/API_RedirectAllRequestsTo.html) writes that it has a required HostName field. So emitting an empty RedirectAllRequestsTo is invalid.

This PR skips emitting XML attributes for these parameters if they contain no value.

Co-authored-by: Armaël Guéneau <armael.gueneau@ens-lyon.org>
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1391
Co-authored-by: Armael <armael@noreply.localhost>
Co-committed-by: Armael <armael@noreply.localhost>
2026-04-13 13:59:32 +00:00
Jul Lang f9605fae78 fix typo (#1402)
found by [typos](https://github.com/crate-ci/typos)

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1402
Co-authored-by: Jul Lang <jullanggit@proton.me>
Co-committed-by: Jul Lang <jullanggit@proton.me>
2026-04-13 12:12:57 +00:00
Armael 9969c3e599 Fix: correctly parse CORS website configuration with no rules (#1392)
This is a port of #1320 on top of the main-v2 branch.

Co-authored-by: Armaël Guéneau <armael.gueneau@ens-lyon.org>
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1392
Co-authored-by: Armael <armael@noreply.localhost>
Co-committed-by: Armael <armael@noreply.localhost>
2026-03-22 17:09:16 +00:00
Alex a69a8d3b21 Merge pull request 'force uri encoding before check signature' (#1382) from gwenlg/garage:signature_doesnt_match_1155 into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1382
Reviewed-by: Alex <lx@deuxfleurs.fr>
2026-03-22 10:59:43 +00:00
Gwen Lg 3a97b13e2f wip: add percent_decode before uri_encode for check signature
this avoid error when request uri is not encoded for signature
2026-03-22 10:59:43 +00:00
Gwen Lg 4efaea60bb tests: check request signatures with 'badly-encoded' uri
test related to issue #1155 and #1255
2026-03-22 10:59:43 +00:00
Gwen Lg 06e9756729 test: some error rework 2026-03-22 10:59:43 +00:00
trinity-1686a 8341b7f914 log api error in one self-sufficient line (fix #1381) (#1390)
this makes it more easy to correlate an error with the request that caused it. This can be helpful during debugging, or when setting up some sort of automation based on log content

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1390
Reviewed-by: Alex <lx@deuxfleurs.fr>
Reviewed-by: maximilien <git@mricher.fr>
Co-authored-by: trinity-1686a <trinity@deuxfleurs.fr>
Co-committed-by: trinity-1686a <trinity@deuxfleurs.fr>
2026-03-20 20:22:34 +00:00
MrSnowy 96b986a0a0 Add completions sub-command for generating shell completions (#1386)
Made a quick pr to add a sub-command called completions for generating shell completions, was going pretty crazy that this wasn't a thing :P.

Tried my best to do everything properly, let me know if I need to change something, I tested it and it works perfectly.

Co-authored-by: MrSnowy <snow@mrsnowy.dev>
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1386
Reviewed-by: Alex <lx@deuxfleurs.fr>
Co-authored-by: MrSnowy <mrsnowy@noreply.localhost>
Co-committed-by: MrSnowy <mrsnowy@noreply.localhost>
2026-03-17 18:17:51 +00:00
trinity-1686a 60244b60dd don't panic on missing checksum (fix #1387) (#1389)
fix https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/1387

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1389
Reviewed-by: Alex <lx@deuxfleurs.fr>
Co-authored-by: trinity-1686a <trinity-1686a@noreply.localhost>
Co-committed-by: trinity-1686a <trinity-1686a@noreply.localhost>
2026-03-17 18:16:37 +00:00
Alex 9848ec7f4e Merge pull request 'add missing admin API endpoints for admin UI' (#1376) from admin-json-statistics into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1376
2026-03-17 17:44:29 +00:00
Alex Auvolat b81eae3f65 admin api: don't fail in getclusterstatistics when counting total objects/bytes 2026-03-17 17:44:29 +00:00
Alex Auvolat 6131318c80 admin api: don't gather all bucket statistics if too many buckets 2026-03-17 17:44:29 +00:00
Alex Auvolat 4566020360 admin api: convert new fields to Option<T> 2026-03-17 17:44:29 +00:00
Alex Auvolat de10dc43d5 admin api: return total buckets, objects and bytes in GetClusterStatistics 2026-03-17 17:44:29 +00:00
Alex Auvolat 8abd0fee86 admin api: add fixme comments for cleanup for v3 release 2026-03-17 17:44:29 +00:00
Alex Auvolat af5f68a34d admin api: allow updating website routing rules 2026-03-17 17:44:29 +00:00
Alex Auvolat 19e5f83164 admin api: update cors and lifecycle rules in UpdateBucket 2026-03-17 17:44:29 +00:00
Alex Auvolat 64087172ff admin api: expose routing rules, cors rules and lifecycle rules 2026-03-17 17:44:29 +00:00
Alex Auvolat 6c0bb1c9b6 refactoring: move xml definitions for bucket cors/lifecycle/website config
move these defnitions to garage_api_common so that they can also be used
in admin api
2026-03-17 17:44:29 +00:00
Alex Auvolat 124a9eb521 admin api: export node statistics as structured json 2026-03-17 17:44:29 +00:00
Alex Auvolat 03e6020c6b admin api: report avilable space numerically in GetClusterStatistics 2026-03-17 17:44:29 +00:00
milouz1985 836657565e s3: fix DeleteObjects XML parsing with pretty-printed bodies (#1374)
## Summary

This PR fixes S3 `DeleteObjects` XML parsing when the request body is pretty-printed (contains indentation/newlines as whitespace text nodes).

Although PR #1324 already tried to address this, parsing could still fail with:

`InvalidRequest: Bad request: Invalid delete XML query`

because non-element nodes were validated but not actually skipped in the parsing loop.

## What changed

- In `src/api/s3/delete.rs`:
  - Properly skip non-element whitespace text nodes while iterating over `<Delete>` children.
  - Keep rejecting non-whitespace stray text content.
  - Parse the root `<Delete>` element more robustly by selecting the first element child.

## Tests added

New unit tests in `src/api/s3/delete.rs`:

- `parse_delete_objects_xml_with_formatting`
  - pretty-printed valid XML is accepted.
- `parse_delete_objects_xml_accepts_compact_valid_xml`
  - compact valid XML is accepted.
- `parse_delete_objects_xml_rejects_non_whitespace_text_node`
  - compact XML with stray text is rejected.
- `parse_delete_objects_xml_rejects_pretty_print_with_stray_text`
  - pretty-printed XML with stray text is rejected.

## Validation

Executed:

```bash
cargo test -p garage_api_s3 parse_delete_objects_xml -- --nocapture
```

Result: all parser tests pass.
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1374
Co-authored-by: milouz1985 <francois.hoyez@gmail.com>
Co-committed-by: milouz1985 <francois.hoyez@gmail.com>
2026-03-15 10:40:50 +00:00
trinity-1686a 76592723de don't send empty 404 on GetBucketCORS/GetBucketLifecycle (#1378)
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1378
Reviewed-by: Alex <lx@deuxfleurs.fr>
Co-authored-by: trinity-1686a <trinity@deuxfleurs.fr>
Co-committed-by: trinity-1686a <trinity@deuxfleurs.fr>
2026-03-10 09:41:08 +00:00
Ira Iva d2f033641e Suppress log noise from /metrics and /health endpoints [#1292]. Change log level for 'netapp: incomming connection ...' message [#1310] (#1361)
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1361
Co-authored-by: Ira Iva <xatikopro@gmail.com>
Co-committed-by: Ira Iva <xatikopro@gmail.com>
2026-03-03 15:52:53 +00:00
Roman Ivanov 2cfd92e0c3 Use error NoSuchAccessKey in get info request processing (#1293) (#1356)
Fix for https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/1293

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1356
Reviewed-by: Alex <lx@deuxfleurs.fr>
Co-authored-by: Roman Ivanov <xatikopro@gmail.com>
Co-committed-by: Roman Ivanov <xatikopro@gmail.com>
2026-02-27 18:11:57 +00:00
Quentin Dufour f796df8c34 Support streaming of gzip content involving multiple Content-Encoding headers (#1369)
## Problem

`hugo deploy` is broken with Garage on recent hugo versions when using gzip matchers

## Why?

We don't support multi-value headers correctly, in this case this specific headers combination:

```
Content-Encoding: gzip
Content-Encoding: aws-chunked
```

is interpreted as:

```
Content-Encoding: gzip
```

instead of:

```
Content-Encoding: gzip,aws-chunked
```

It fails both 1. the signature check and 2. the streaming check.

## Proposed fix

 - Taking into account multi-value headers when building Canonical Request (validated with hugo deploy + AWS SDK v2)
 - Taking into account multi-value headers (both comma separated and HeaderEntry separated) when removing `aws-chunked` (validated with hugo deploy + AWS SDK v2)

## Full explanation

Currently, `hugo deploy` on version `hugo v0.152.2` or more recent uses AWS SDK v2 only and supports for sending gzipped content.
That's configured with a matcher like that:

```yaml
deployment:
  matchers:
    - pattern: "^.+\\.(woff2|woff|svg|ttf|otf|eot|js|css)$"
      cacheControl: "max-age=31536000, no-transform, public"
      gzip: true  # <-------- here
```

Also, with SDK v2, hugo is streaming all of its files.
Thus, it sends that kind of requests:

```python
Request {
  method: PUT,
  uri: /sebou/pagefind/pagefind.js?x-id=PutObject,
  version: HTTP/1.1,
  headers: {
    "host": "localhost",
    "user-agent": "aws-sdk-go-v2/1.39.2 ua/2.1 os/linux lang/go#1.25.6 md/GOOS#linux md/GOARCH#amd64 api/s3#1.84.0 ft/s3-transfer m/E,G,Z,g",
    "content-length": "10026",
    "accept-encoding": "identity",
    "amz-sdk-invocation-id": "aed6df34-a67c-4bab-b63b-2b3777b751a0",
    "amz-sdk-request": "attempt=1; max=3",
    "authorization": "AWS4-HMAC-SHA256 Credential=GKxxxxx/20260227/garage/s3/aws4_request, SignedHeaders=accept-encoding;amz-sdk-invocation-id;amz-sdk-request;cache-control;content-encoding;content-length;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length;x-amz-meta-md5chksum;x-amz-trailer, Signature=76cd9b77f693ca89c2e6dd2a4dc55f83d4a82eca0f563d9d095ff96076f7b057",
    "cache-control": "max-age=31536000, no-transform, public",
    "content-encoding": "gzip",                                           # <---- see here 1st instance of Content-Encoding
    "content-encoding": "aws-chunked",                                    # <---- 2nd instance of Content-Encoding
    "content-type": "text/javascript",
    "via": "2.0 Caddy",
    "x-amz-content-sha256": "STREAMING-UNSIGNED-PAYLOAD-TRAILER",
    "x-amz-date": "20260227T132212Z",
    "x-amz-decoded-content-length": "9982",
    "x-amz-meta-md5chksum": "aad88ac0bf704e91584b8d9ad9796670",
    "x-amz-trailer": "x-amz-checksum-crc32",
    "x-forwarded-for": "::1",
    "x-forwarded-host": "localhost",
    "x-forwarded-proto": "https"
  },
  body: Body(Streaming)
}
```

But our canonical request function only calls `HeaderMap.get()` that returns only the 1st value and not `HeaderMap.get_all()` that returns all the values for a header.
Leading to the following invalid `CanonicalRequest` value:

```python
PUT
/sebou/pagefind/pagefind.js
x-id=PutObject
accept-encoding:identity
amz-sdk-invocation-id:aed6df34-a67c-4bab-b63b-2b3777b751a0
amz-sdk-request:attempt=1; max=3
cache-control:max-age=31536000, no-transform, public
content-encoding:gzip                                                             # <----- see here, we kept only gzip and dropped aws-chunked
content-length:10026
content-type:text/javascript
host:localhost
x-amz-content-sha256:STREAMING-UNSIGNED-PAYLOAD-TRAILER
x-amz-date:20260227T132212Z
x-amz-decoded-content-length:9982
x-amz-meta-md5chksum:aad88ac0bf704e91584b8d9ad9796670
x-amz-trailer:x-amz-checksum-crc32

accept-encoding;amz-sdk-invocation-id;amz-sdk-request;cache-control;content-encoding;content-length;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length;x-amz-meta-md5chksum;x-amz-trailer
```

Amazon is crystal clear that, instead of dropping the other values, we should concatenate them with a comma:

![20260227_17h26m20s_grim](/attachments/e3edf7bf-7dff-43d7-80d9-cf276ae94ed5)

https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html#create-canonical-request
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1369
Reviewed-by: Alex <lx@deuxfleurs.fr>
Co-authored-by: Quentin Dufour <quentin@deuxfleurs.fr>
Co-committed-by: Quentin Dufour <quentin@deuxfleurs.fr>
2026-02-27 18:02:31 +00:00
trinity-1686a 668dfea4e2 fix silent write errors (#1360)
same as #1358 for garage-v2

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1360
Co-authored-by: trinity-1686a <trinity@deuxfleurs.fr>
Co-committed-by: trinity-1686a <trinity@deuxfleurs.fr>
2026-02-24 14:40:11 +00:00
maximilien 7f61bbbebb Merge pull request 'helm: add priorityClassName support' (#1357) from blue.lion4023/garage:helm-add-priority-class-name into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1357
Reviewed-by: maximilien <git@mricher.fr>
2026-02-21 08:23:14 +00:00
blue.lion4023 8105ca888d helm: add priorityClassName support to pod spec 2026-02-20 21:36:08 +00:00
Alex d0166fe938 Merge pull request 'Upgrade quick-xml crate to 0.39' (#1319) from gwenlg/garage:quick_xml_upgrade into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1319
Reviewed-by: Alex <lx@deuxfleurs.fr>
2026-02-20 21:29:26 +00:00
Gwen Lg 290a7f5ab6 fix: VersioningConfiguration xml reference
empty element handling is set as expanded and be consistant.
2026-02-20 21:29:26 +00:00
Gwen Lg 2576626240 fix: configure xmk serializer to expand empty elements 2026-02-20 21:29:26 +00:00
Gwen Lg 6591044c2e fix: set quote level to full for xml serialization
also remove use of intermediate String
2026-02-20 21:29:26 +00:00
Gwen Lg 1ae4e5d438 fix: mark to skip serialization of Option when None
mark with `skip_serializing_if = "Option::is_none"`
2026-02-20 21:29:26 +00:00
Gwen Lg d80096de92 fixup: fix xml attibute xmlns serialization
rename var with "@xmlns"
2026-02-20 21:29:26 +00:00
Gwen Lg 674c2c1cb1 chore: update quick-xml dep
rework associated error, and fixup error for XML serialization.
Should be InternalError/INTERNAL_SERVER_ERROR not MalformedXML/BAD_REQUEST
2026-02-20 21:29:26 +00:00
Gwen Lg 3c5018bd6b refactor: use str trim result to parse xml
- use `trim` method of `str` instead of manual implementation with `trim_matches(char::is_whitespace)`
- use result of `trim` for xml parsing instead of use the `str` before trim.
2026-02-20 21:29:26 +00:00
Gwen Lg 93cd71eb72 test: add unprettify_xml helper and use it
this replace previous cleanup which remove space between `element` and `attribute` name in xml
2026-02-20 21:29:26 +00:00
Gwen Lg 507be60890 test: use assert_eq instead of assert to improve failed output 2026-02-20 21:29:26 +00:00
Gwen Lg 780f389973 test: replace some unwrap with expect in tests
this add more information in case of failure
2026-02-20 21:29:26 +00:00
rajsinghtech 69cd230568 fix: enable TCP keepalive on RPC connections (#1348)
Garage RPC connections have no TCP keepalive enabled. When a connection dies silently (proxy pod restart, NAT timeout, network partition), it's only detected by application-level pings after ~60s (4 failed pings x 15s interval). During this window, the node appears connected but all RPC calls to it fail.

Enable TCP keepalive on both outgoing and incoming RPC connections via socket2:
- Idle time before first probe: 30s (TCP_KEEPALIVE_TIME)
- Probe interval after first: 10s (TCP_KEEPALIVE_INTERVAL)

A helper set_keepalive() function avoids duplicating the socket2 setup. Incoming connection keepalive failures are logged as warnings but don't reject the connection.

Companion to #1345 (stale address pruning + connect timeout). Together they address both halves of the reconnection problem: faster detection (this PR) and faster recovery.

Co-authored-by: Raj Singh <raj@tailscale.com>
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1348
Reviewed-by: maximilien <git@mricher.fr>
Co-authored-by: rajsinghtech <rajsinghtech@noreply.localhost>
Co-committed-by: rajsinghtech <rajsinghtech@noreply.localhost>
2026-02-20 21:28:29 +00:00
Malte Swart 55370d9b4d consul: support token auth for catalog api requests, too (#1353)
Even when using the catalog an dedicated token for authentication
might be needed.

**Approach**: Support the token header even with client certs was the simplist approach and somebody might need/want to use it.

**Background**: I want to run garage via Nomad but within containers (with host volumes). Nomad generates consul tokens (but at least not at the moment client certs). I need to use the catalog as with the services API garage tries to use the host/node IPs (instead of the actual service IPs).

**Tests**: I deployed this version and it works well.

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1353
Reviewed-by: Alex <lx@deuxfleurs.fr>
Co-authored-by: Malte Swart <mswart@devtation.de>
Co-committed-by: Malte Swart <mswart@devtation.de>
2026-02-20 21:27:59 +00:00
Roman Ivanov ce1ea79bf1 Implement error 409 BucketAlreadyOwnedByYou (#1352)
Fix for Deuxfleurs/garage#1322

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1352
Co-authored-by: Roman Ivanov <xatikopro@gmail.com>
Co-committed-by: Roman Ivanov <xatikopro@gmail.com>
2026-02-18 11:20:24 +00:00
Alex 2803c73045 Merge pull request 'code maintenance with help clippy' (#1314) from gwenlg/garage:code_maintenance_part2 into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1314
Reviewed-by: Alex <lx@deuxfleurs.fr>
2026-02-17 18:38:44 +00:00
Gwen Lg 9b3e4716bf refactor: rework uri_encode to limit allocation
add add some related tests.

catched from clippy lint `format_collect`
message: use of `format!` to build up a string from an iterator
  --> src/api/common/encoding.rs:12:17
   |
12 |                   let value = format!("{}", c)
   |  _____________________________^
13 | |                     .bytes()
14 | |                     .map(|b| format!("%{:02X}", b))
15 | |                     .collect::<String>();
   | |________________________________________^
   |
help: call `fold` instead
  --> src/api/common/encoding.rs:14:7
   |
14 |                     .map(|b| format!("%{:02X}", b))
   |                      ^^^
help: ... and use the `write!` macro here
  --> src/api/common/encoding.rs:14:15
   |
14 |                     .map(|b| format!("%{:02X}", b))
   |                              ^^^^^^^^^^^^^^^^^^^^^
   = note: this can be written more efficiently by appending to a `String` directly
   = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#format_collect
2026-02-17 18:38:44 +00:00
Gwen Lg 83f8bdbacd refactor: remove unnecessarily wrap value into a Result
this simplify code and remove some unwrap.
warning: this function's return value is unnecessarily wrapped by `Result`
help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#unnecessary_wraps
2026-02-17 18:38:44 +00:00
Gwen Lg b060a7e0f1 fix: remove func call from alternative value.
this avoid useless allocation or non-trivial work, if the default value
is not needed/used.

add a line in Cargo.toml to easly enable or_fun_call lint
help: https://rust-lang.github.io/rust-clippy/master/index.html?search=or_fun_call
2026-02-17 18:38:44 +00:00
Gwen Lg f6414210fa refactor: rework bucket value get, relateted to or_func_call
this avoid bucket_website_config value compute if not needed
2026-02-17 18:38:44 +00:00
Gwen Lg bbb62dfa85 refactor: use u64::midpoint instead of manual implementation
warning: manual implementation of `midpoint` which can overflow
help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#manual_midpoint
2026-02-17 18:38:44 +00:00
Gwen Lg f59a8b7f62 style: corrects the use of ';' to improve readability
- remove unnecessary semicolon
and enable lint warning: unnecessary semicolon
help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#unnecessary_semicolon
- add `;` to the last statement for consitent formatting
and enable lint `clippy::semicolon_if_nothing_returned`
warning: consider adding a `;` to the last statement for consistent formatting
help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.93.0/index.html#semicolon_if_nothing_returned
2026-02-17 18:38:44 +00:00
Gwen Lg 08fd6e659f docs: add missing backticks in documentation
this improve readability of documentation.
enable associated clippy lint `doc_markdown`
2026-02-17 18:38:44 +00:00
Gwen Lg 6cde00073f chore: enable workspace configuration of lints in Cargo.toml
and use workspace configuration in each package.
This allow to customize clippy and rust lint configuration for project.
No particular configuration in this commit.
2026-02-17 18:38:44 +00:00
Gwen Lg 0043ad08fa docs: remove obsolete mention of cargo2nix tool (#1350)
fix issue #1333

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1350
Co-authored-by: Gwen Lg <me@gwenlg.fr>
Co-committed-by: Gwen Lg <me@gwenlg.fr>
2026-02-17 18:16:04 +00:00
Maximilien Richer 0c70c87391 Add FOSDEM 2026 talk (#1344)
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1344
Co-authored-by: Maximilien Richer <me@mricher.fr>
Co-committed-by: Maximilien Richer <me@mricher.fr>
2026-02-15 15:17:27 +00:00
trinity-1686a b0840ab256 emit headers on Not Modified per RFC-9110, fix #1330 (#1340)
also fix a small information disclosure where a client with valid token, but no encryption keys, can use Not Modified has an oracle to know if etag matches or not

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1340
Co-authored-by: trinity-1686a <trinity@deuxfleurs.fr>
Co-committed-by: trinity-1686a <trinity@deuxfleurs.fr>
2026-02-15 11:00:31 +00:00
Alex Auvolat 7ad0f96222 release builds: set lto="thin" and strip="debuginfo" (#1342)
- thin LTO is much much faster to compile, this will help when making
  release builds
- strip="debuginfo" allows to still have symbols when unwinding stack
  traces, which are usefull to have in user's bug reports. Binary size
  is increased from 23M to 27M, so a reasonable increase

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1342
Co-authored-by: Alex Auvolat <lx@deuxfleurs.fr>
Co-committed-by: Alex Auvolat <lx@deuxfleurs.fr>
2026-02-15 08:58:57 +00:00
trinity-1686a c373222e3a run push CI only on main branch (#1343)
currently, ci runs twice for people pushing directly to this repository (e.g. https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1340, you can see both woodpecker/{pr,pull}/debug)
that's a waste of resources, we'll do twice exactly the same thing.
i propose we only run push ci on `main-*` branches, so that creating a PR doesn't create two jobs

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1343
Co-authored-by: trinity-1686a <trinity@deuxfleurs.fr>
Co-committed-by: trinity-1686a <trinity@deuxfleurs.fr>
2026-02-15 08:57:52 +00:00
Alex c73268fd77 Merge pull request 'chore: update nom dependency to 0.8' (#1341) from gwenlg/garage:nom_upgrade into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1341
Reviewed-by: Alex <lx@deuxfleurs.fr>
2026-02-14 19:25:49 +00:00
Gwen Lg c82015f6cf chore: update nom dependency to 0.8
update syntax with :
- add explicit call of `parse` method
- add ref slice management for `tag` fn
2026-02-14 19:25:49 +00:00
Alex 3af9e8d3d2 Merge pull request 'Make initial setup easier' (#1329) from easy-bootstrap into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1329
2026-02-14 18:26:43 +00:00
Alex Auvolat 1d588282bf print path to configuration file in startup logs 2026-02-14 19:25:47 +01:00
Alex Auvolat f7ec4b1338 update quick start guide 2026-02-14 19:25:47 +01:00
Alex Auvolat 00cbc5c31d relax requirements on imported access keys to allow easier transition from other S3 storage providers (fix #1262) 2026-02-14 19:23:44 +01:00
Alex Auvolat 95aa3bc795 bootstrap: add --default-access-key and --default-bucket flags 2026-02-14 19:23:44 +01:00
Alex Auvolat 53ace58e44 bootstrap: add --single-node flag that creates a single-node layout 2026-02-14 19:23:44 +01:00
Alex c22c4ff2e5 Merge pull request 'style: replace wildcard import of garage model in website' (#1334) from gwenlg/garage:avoid_import_conflict into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1334
2026-02-14 18:21:31 +00:00
Gwen Lg 6f511fc149 style: replace wildcard import of garage model in website
this avoid rust-analyzer indicate invalid field error on `Redirect` for
`replace_prefix` and `replace_full` because of a conflict between struct :
`api::s3::website::Redirect` and `model::bucket_table::Redirect`
2026-02-14 18:21:31 +00:00
Alex 70b8ebc8b6 Merge pull request 'Document how to use Apache as a reverse proxy' (#1331) from jasonaowen/garage:cookbook-reverse-proxy-apache into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1331
2026-02-14 18:08:08 +00:00
Alex 02db2d5f9d Merge pull request 'fix path missmatch between config and docker in quickstart doc' (#1339) from 1686a/doc-incoherent-path into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1339
Reviewed-by: maximilien <git@mricher.fr>
2026-02-14 17:53:36 +00:00
trinity-1686a cc749a6290 fix path missmatch between config and docker in quickstart doc 2026-02-14 11:19:28 +01:00
Jason Owen 9f969ef43e Document how to use Apache as a reverse proxy
Replace the TODO in the reverse proxy cookbook entry with instructions
on how to configure Apache httpd as a reverse proxy for Garage.
2026-02-12 00:21:40 -08:00
Alex 4989be7853 Merge pull request 'update almost all dependencies to the last version' (#1316) from gwenlg/garage:maintenance_deps into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1316
2026-02-10 19:16:49 +00:00
Gwen Lg 8ce4415edb chore: update k8s related dependencies
- k8s-openapi, kube and schemars
2026-02-10 19:16:49 +00:00
Gwen Lg 75c28faa5b chore: update tokio and hyper-rustls dependencies 2026-02-10 19:16:49 +00:00
Gwen Lg 8644511ebc chore: update aws dependencies
aws-sigv4, aws-smithy-runtime, aws-sdk-config, aws-sdk-s3
2026-02-10 19:16:49 +00:00
Gwen Lg e5627bbd6b chore: update reqwest dependency to 0.13 2026-02-10 19:16:49 +00:00
Gwen Lg 3aeb6d88af update fjall dep to 2.11 2026-02-10 19:16:49 +00:00
Gwen Lg 6f44631973 chore: update various dependencies than don't need code changes
and run cargo update
2026-02-10 19:16:49 +00:00
Gwen Lg 566a0b44c0 chore: update bytesize dependency to 2.3
include code update to follow display management change.
2026-02-10 19:16:49 +00:00
Gwen Lg 934c4c31f1 chore: update dependency rand to 0.9 2026-02-10 19:16:49 +00:00
Gwen Lg be8e4c9dcf chore: remove patch version of deps in Cargo.toml
as cargo can update a crate to last patch version anyway, it's can be
confusing.
2026-02-10 19:16:49 +00:00
Alex 1b20713421 Merge pull request 'adapt code to unsafety of env::set_var fn' (#1317) from gwenlg/garage:env_set_var into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1317
2026-02-10 19:12:52 +00:00
Gwen Lg 473b66ca5b fix: mark unsafety of std::env::set_var
and document the function.
2026-02-10 18:46:55 +01:00
Gwen Lg 5c8a31708e refactor: manualy build tokio runtime for k2v-cli 2026-02-10 18:46:55 +01:00
Gwen Lg ca1211d927 refactor: manualy build tokio runtime for garage
to allow do some initialization before
2026-02-10 18:46:55 +01:00
bytechunk b012431df0 S3 api DeleteObject fix invalid XML (#1324)
linked to #1323

Do only check element nodes when validating XML content (skip text
nodes).

If text nodes are skipped then the validation fails when providing
formatted XML content as body of the request.

Co-authored-by: frederic vroman <fred@lesmouths.net>
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1324
Co-authored-by: bytechunk <bytechunk.a52b055@track-it.pw>
Co-committed-by: bytechunk <bytechunk.a52b055@track-it.pw>
2026-02-09 13:05:58 +00:00
Gwen Lg 6e98f5e74e upgrade heed to version 0.22 (#1318)
- migration from `ByteSlice` to `Bytes` heed type.
- not sure about the impact of only one `read_txn` for the entire function `list_trees`, whereas before `open_database` call uses their own access controller.

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1318
Co-authored-by: Gwen Lg <me@gwenlg.fr>
Co-committed-by: Gwen Lg <me@gwenlg.fr>
2026-02-07 13:22:46 +00:00
167 changed files with 5448 additions and 2427 deletions
+8 -7
View File
@@ -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
Generated
+1102 -514
View File
File diff suppressed because it is too large Load Diff
+100 -64
View File
@@ -24,108 +24,127 @@ 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"
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"
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",
"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 +153,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
+462 -13
View File
@@ -12,7 +12,7 @@
"name": "AGPL-3.0",
"identifier": "AGPL-3.0"
},
"version": "v2.2.0"
"version": "v2.3.0"
},
"servers": [
{
@@ -2340,6 +2340,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 +2373,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 +2443,15 @@
},
"indexDocument": {
"type": "string"
},
"routingRules": {
"type": [
"array",
"null"
],
"items": {
"$ref": "#/components/schemas/website.RoutingRule"
}
}
}
},
@@ -2581,8 +2610,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 +3137,8 @@
],
"properties": {
"dbEngine": {
"type": "string"
"type": "string",
"description": "database engine used for metadata"
},
"garageFeatures": {
"type": [
@@ -3064,16 +3147,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 +3176,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 +3500,8 @@
],
"properties": {
"dbEngine": {
"type": "string"
"type": "string",
"description": "database engine used for metadata"
},
"garageFeatures": {
"type": [
@@ -3394,16 +3510,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 +3565,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 +3927,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 +4118,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": [
@@ -4117,7 +4340,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 +4350,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 +4413,15 @@
"string",
"null"
]
},
"routingRules": {
"type": [
"array",
"null"
],
"items": {
"$ref": "#/components/schemas/website.RoutingRule"
}
}
}
},
@@ -4413,6 +4663,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": {
+5 -5
View File
@@ -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:
+68 -1
View File
@@ -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
-23
View File
@@ -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.
+211 -138
View File
@@ -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,58 @@ 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:
```bash
docker exec garage-container /garage 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 +210,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> /garage"
```
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 +359,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 +404,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 +430,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.
+2 -1
View File
@@ -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" ]
+188
View File
@@ -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
```
+18
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
talk.pdf: talk.tex
pdflatex talk.tex
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+330
View File
@@ -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 :
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
# garage
![Version: 0.9.2](https://img.shields.io/badge/Version-0.9.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v2.2.0](https://img.shields.io/badge/AppVersion-v2.2.0-informational?style=flat-square)
![Version: 0.9.3](https://img.shields.io/badge/Version-0.9.3-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v2.3.0](https://img.shields.io/badge/AppVersion-v2.3.0-informational?style=flat-square)
S3-compatible object store for small self-hosted geo-distributed deployments
@@ -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
+4 -1
View File
@@ -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:
@@ -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 }}
+4
View File
@@ -238,6 +238,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: {}
+4 -1
View File
@@ -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
+93 -2
View File
@@ -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 ----
@@ -843,9 +869,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 +902,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 +963,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 +976,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,9 +1153,17 @@ 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,
}
@@ -1121,8 +1173,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 ----
+76 -8
View File
@@ -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?;
@@ -293,10 +294,28 @@ 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()
.as_ref()
.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()
.as_ref()
.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.",
@@ -322,6 +341,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);
}
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);
}
garage.bucket_table.insert(&bucket).await?;
Ok(UpdateBucketResponse(
@@ -557,7 +608,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 +616,7 @@ impl RequestHandler for AddBucketAliasRequest {
} => {
helper
.set_local_bucket_alias(bucket_id, &access_key_id, &local_alias)
.await?
.await?;
}
}
@@ -591,7 +642,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 +650,7 @@ impl RequestHandler for RemoveBucketAliasRequest {
} => {
helper
.unset_local_bucket_alias(bucket_id, &access_key_id, &local_alias)
.await?
.await?;
}
}
@@ -693,8 +744,25 @@ async fn bucket_info_results(
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().as_ref().map(|rules| {
rules
.iter()
.map(xml::cors::CorsRule::from_garage_cors_rule)
.collect::<Vec<_>>()
}),
lifecycle_rules: state.lifecycle_config.get().as_ref().map(|lc| {
lc.iter()
.map(xml::lifecycle::LifecycleRule::from_garage_lifecycle_rule)
.collect::<Vec<_>>()
}),
keys: relevant_keys
.into_values()
.filter_map(|key| {
+104 -27
View File
@@ -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,
})
}
}
+3 -1
View File
@@ -76,7 +76,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()
+75 -53
View File
@@ -22,8 +22,12 @@ 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();
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()),
@@ -57,46 +61,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 +120,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,
})
}
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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)
+7 -1
View File
@@ -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
+18 -5
View File
@@ -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) {
+92 -6
View File
@@ -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"
);
}
}
+20 -5
View File
@@ -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!()))))
+1
View File
@@ -10,3 +10,4 @@ pub mod generic_server;
pub mod helpers;
pub mod router_macros;
pub mod signature;
pub mod xml;
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -89,7 +89,7 @@ impl ReqBody {
checksummer
})
.await
.unwrap()
.unwrap();
}
Err(frame) => {
let trailers = frame.into_trailers().unwrap();
+39 -10
View File
@@ -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(),
)
})?))
}
})
}
}
+7 -2
View File
@@ -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
+21 -7
View File
@@ -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
@@ -357,11 +361,18 @@ pub fn canonical_request(
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);
}
Ok(format!("{}:{}", name.as_str(), built_string.trim()))
})
.collect::<Result<Vec<String>, Error>>()?
.join("\n");
@@ -393,7 +404,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
+62 -21
View File
@@ -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)));
}
};
+222
View File
@@ -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));
}
}
+345
View File
@@ -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));
}
}
+48
View File
@@ -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);
+423
View File
@@ -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));
}
}
+4 -1
View File
@@ -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
+16 -7
View File
@@ -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(_)
+2 -2
View File
@@ -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 -1
View File
@@ -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;
+3 -3
View File
@@ -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))
}
+4 -1
View File
@@ -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
+3 -2
View File
@@ -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()),
}
}
}
@@ -327,7 +328,7 @@ 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,
+8 -5
View File
@@ -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
@@ -328,8 +330,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 +373,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#"
+2 -2
View File
@@ -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,
+5 -199
View File
@@ -1,16 +1,15 @@
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;
@@ -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)
}
}
@@ -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(())
}
}
+66 -1
View File
@@ -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);
}
}
+2 -2
View File
@@ -660,11 +660,11 @@ mod tests {
#[tokio::test]
async fn test_encrypt_block() {
test_block_enc(None).await
test_block_enc(None).await;
}
#[tokio::test]
async fn test_encrypt_block_compressed() {
test_block_enc(Some(1)).await
test_block_enc(Some(1)).await;
}
}
+46 -30
View File
@@ -31,8 +31,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 object requested don't exists
#[error("Key not found")]
@@ -42,6 +47,14 @@ pub enum Error {
#[error("Upload not found")]
NoSuchUpload,
/// CORS configuration doesn't exist for this bucket
#[error("The CORS configuration does not exist")]
NoSuchCORSConfiguration,
/// CORS configuration doesn't exist for this bucket
#[error("The lifecycle configuration does not exist")]
NoSuchLifecycleConfiguration,
/// Precondition failed (e.g. x-amz-copy-source-if-match)
#[error("At least one of the preconditions you specified did not hold")]
PreconditionFailed,
@@ -50,11 +63,11 @@ pub enum Error {
#[error("Parts given to CompleteMultipartUpload do not match uploaded parts")]
InvalidPart,
/// Parts given to CompleteMultipartUpload were not in ascending order
/// Parts given to `CompleteMultipartUpload` were not in ascending order
#[error("Parts given to CompleteMultipartUpload were not in ascending order")]
InvalidPartOrder,
/// In CompleteMultipartUpload: not enough data
/// In `CompleteMultipartUpload`: not enough data
/// (here we are more lenient than AWS S3)
#[error("Proposed upload is smaller than the minimum allowed object size")]
EntityTooSmall,
@@ -69,8 +82,16 @@ pub enum Error {
InvalidUtf8String(#[from] std::string::FromUtf8Error),
/// The client sent invalid XML data
#[error("Invalid XML: {0}")]
InvalidXml(String),
#[error("failed to deserialize XML")]
InvalidXml(#[from] roxmltree::Error),
/// The client sent invalid XML data
#[error("XML deserialization failed")]
InvalidXmlDe(#[from] quick_xml::de::DeError),
/// The server failed to serialize data into XML
#[error("failed to serialize XML")]
InvalidXmlSe(#[from] quick_xml::se::SeError),
/// The client sent a range header with invalid value
#[error("Invalid HTTP range: {0:?}")]
@@ -83,10 +104,6 @@ pub enum Error {
/// The provided digest (checksum) value was invalid
#[error("Invalid digest: {0}")]
InvalidDigest(String),
/// The client sent a request for an action not supported by garage
#[error("Unimplemented action: {0}")]
NotImplemented(String),
}
commonErrorDerivative!(Error);
@@ -105,25 +122,17 @@ impl From<(http_range::HttpRangeParseError, u64)> for Error {
}
}
impl From<roxmltree::Error> for Error {
fn from(err: roxmltree::Error) -> Self {
Self::InvalidXml(format!("{}", err))
}
}
impl From<quick_xml::de::DeError> for Error {
fn from(err: quick_xml::de::DeError) -> Self {
Self::InvalidXml(format!("{}", err))
}
}
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),
}
@@ -146,13 +155,16 @@ impl Error {
Error::InvalidPart => "InvalidPart",
Error::InvalidPartOrder => "InvalidPartOrder",
Error::EntityTooSmall => "EntityTooSmall",
Error::AuthorizationHeaderMalformed(_) => "AuthorizationHeaderMalformed",
Error::NotImplemented(_) => "NotImplemented",
Error::AuthorizationHeaderMalformed { .. } => "AuthorizationHeaderMalformed",
Error::InvalidXml(_) => "MalformedXML",
Error::InvalidXmlDe(_) => "MalformedXML",
Error::InvalidXmlSe(_) => "InternalError",
Error::InvalidRange(_) => "InvalidRange",
Error::InvalidDigest(_) => "InvalidDigest",
Error::InvalidUtf8Str(_) | Error::InvalidUtf8String(_) => "InvalidRequest",
Error::InvalidEncryptionAlgorithm(_) => "InvalidEncryptionAlgorithmError",
Error::NoSuchCORSConfiguration => "NoSuchCORSConfiguration",
Error::NoSuchLifecycleConfiguration => "NoSuchLifecycleConfiguration",
}
}
}
@@ -162,17 +174,21 @@ impl ApiError for Error {
fn http_status_code(&self) -> StatusCode {
match self {
Error::Common(c) => c.http_status_code(),
Error::NoSuchKey | Error::NoSuchUpload => StatusCode::NOT_FOUND,
Error::NoSuchKey
| Error::NoSuchUpload
| Error::NoSuchCORSConfiguration
| Error::NoSuchLifecycleConfiguration => StatusCode::NOT_FOUND,
Error::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
Error::InvalidRange(_) => StatusCode::RANGE_NOT_SATISFIABLE,
Error::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED,
Error::AuthorizationHeaderMalformed(_)
Error::InvalidXmlSe(_) => StatusCode::INTERNAL_SERVER_ERROR,
Error::AuthorizationHeaderMalformed { .. }
| Error::InvalidPart
| Error::InvalidPartOrder
| Error::EntityTooSmall
| Error::InvalidDigest(_)
| Error::InvalidEncryptionAlgorithm(_)
| Error::InvalidXml(_)
| Error::InvalidXmlDe(_)
| Error::InvalidUtf8Str(_)
| Error::InvalidUtf8String(_) => StatusCode::BAD_REQUEST,
}
+48 -20
View File
@@ -120,16 +120,42 @@ fn getobject_override_headers(
fn handle_http_precondition(
version: &ObjectVersion,
version_meta: &ObjectVersionMeta,
meta_inner: &ObjectVersionMetaInner,
encryption: EncryptionParams,
req: &Request<()>,
) -> Result<Option<Response<ResBody>>, Error> {
let precondition_headers = PreconditionHeaders::parse(req)?;
if let Some(status_code) = precondition_headers.check(version, &version_meta.etag)? {
if let Some(status_code) = precondition_headers.check(version, &version_meta.etag) {
let mut response = object_headers(
version,
version_meta,
meta_inner,
encryption,
ChecksumMode { enabled: false },
);
if let Some(header_map) = response.headers_mut() {
use http::header;
let headers_to_keep: Vec<_> = header_map
.drain()
.filter(|(k, _v)| {
k.as_ref().is_some_and(|k| {
[
header::CONTENT_LOCATION,
header::DATE,
header::ETAG,
header::VARY,
header::CACHE_CONTROL,
header::EXPIRES,
]
.contains(k)
})
})
.collect();
header_map.extend(headers_to_keep);
}
Ok(Some(
Response::builder()
.status(status_code)
.body(empty_body())
.unwrap(),
response.status(status_code).body(empty_body()).unwrap(),
))
} else {
Ok(None)
@@ -178,10 +204,6 @@ pub async fn handle_head_without_ctx(
_ => unreachable!(),
};
if let Some(res) = handle_http_precondition(object_version, version_meta, req)? {
return Ok(res);
}
let (encryption, headers) = EncryptionParams::check_decrypt(
&garage,
req.headers(),
@@ -189,6 +211,12 @@ pub async fn handle_head_without_ctx(
OekDerivationInfo::for_object(&object, object_version),
)?;
if let Some(res) =
handle_http_precondition(object_version, version_meta, &headers, encryption, req)?
{
return Ok(res);
}
let checksum_mode = checksum_mode(req);
if let Some(part_number) = part_number {
@@ -305,10 +333,6 @@ pub async fn handle_get_without_ctx(
ObjectVersionData::FirstBlock(meta, _) => meta,
};
if let Some(res) = handle_http_precondition(last_v, last_v_meta, req)? {
return Ok(res);
}
let (enc, headers) = EncryptionParams::check_decrypt(
&garage,
req.headers(),
@@ -316,6 +340,10 @@ pub async fn handle_get_without_ctx(
OekDerivationInfo::for_object(&object, last_v),
)?;
if let Some(res) = handle_http_precondition(last_v, last_v_meta, &headers, enc, req)? {
return Ok(res);
}
let checksum_mode = checksum_mode(req);
let handle_get_info = HandleGetInfo {
@@ -849,7 +877,7 @@ impl PreconditionHeaders {
})
}
fn check(&self, v: &ObjectVersion, etag: &str) -> Result<Option<StatusCode>, Error> {
fn check(&self, v: &ObjectVersion, etag: &str) -> Option<StatusCode> {
// we store date with ms precision, but headers are precise to the second: truncate
// the timestamp to handle the same-second edge case
let v_date = UNIX_EPOCH + Duration::from_secs(v.timestamp / 1000);
@@ -859,32 +887,32 @@ impl PreconditionHeaders {
if let Some(im) = &self.if_match {
// Step 1: if-match is present
if !im.iter().any(|x| x == etag || x == "*") {
return Ok(Some(StatusCode::PRECONDITION_FAILED));
return Some(StatusCode::PRECONDITION_FAILED);
}
} else if let Some(ius) = &self.if_unmodified_since {
// Step 2: if-unmodified-since is present, and if-match is absent
if v_date > *ius {
return Ok(Some(StatusCode::PRECONDITION_FAILED));
return Some(StatusCode::PRECONDITION_FAILED);
}
}
if let Some(inm) = &self.if_none_match {
// Step 3: if-none-match is present
if inm.iter().any(|x| x == etag || x == "*") {
return Ok(Some(StatusCode::NOT_MODIFIED));
return Some(StatusCode::NOT_MODIFIED);
}
} else if let Some(ims) = &self.if_modified_since {
// Step 4: if-modified-since is present, and if-none-match is absent
if v_date <= *ims {
return Ok(Some(StatusCode::NOT_MODIFIED));
return Some(StatusCode::NOT_MODIFIED);
}
}
Ok(None)
None
}
pub(crate) fn check_copy_source(&self, v: &ObjectVersion, etag: &str) -> Result<(), Error> {
match self.check(v, etag)? {
match self.check(v, etag) {
Some(_) => Err(Error::PreconditionFailed),
None => Ok(()),
}
+4 -330
View File
@@ -2,18 +2,14 @@ use quick_xml::de::from_reader;
use hyper::{Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use garage_api_common::helpers::*;
use garage_api_common::xml::lifecycle::*;
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;
use garage_model::bucket_table::{
parse_lifecycle_date, Bucket, LifecycleExpiration as GarageLifecycleExpiration,
LifecycleFilter as GarageLifecycleFilter, LifecycleRule as GarageLifecycleRule,
};
use garage_model::bucket_table::Bucket;
pub async fn handle_get_lifecycle(ctx: ReqCtx) -> Result<Response<ResBody>, Error> {
let ReqCtx { bucket_params, .. } = ctx;
@@ -26,9 +22,7 @@ pub async fn handle_get_lifecycle(ctx: ReqCtx) -> Result<Response<ResBody>, Erro
.header(http::header::CONTENT_TYPE, "application/xml")
.body(string_body(xml))?)
} else {
Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(empty_body())?)
Err(Error::NoSuchLifecycleConfiguration)
}
}
@@ -78,323 +72,3 @@ pub async fn handle_put_lifecycle(
.status(StatusCode::OK)
.body(empty_body())?)
}
// ---- SERIALIZATION AND DESERIALIZATION TO/FROM S3 XML ----
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct LifecycleConfiguration {
#[serde(serialize_with = "xmlns_tag", skip_deserializing)]
pub xmlns: (),
#[serde(rename = "Rule")]
pub lifecycle_rules: Vec<LifecycleRule>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct LifecycleRule {
#[serde(rename = "ID")]
pub id: Option<Value>,
#[serde(rename = "Status")]
pub status: Value,
#[serde(rename = "Filter", default)]
pub filter: Option<Filter>,
#[serde(rename = "Expiration", default)]
pub expiration: Option<Expiration>,
#[serde(rename = "AbortIncompleteMultipartUpload", default)]
pub abort_incomplete_mpu: Option<AbortIncompleteMpu>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct Filter {
#[serde(rename = "And")]
pub and: Option<Box<Filter>>,
#[serde(rename = "Prefix")]
pub prefix: Option<Value>,
#[serde(rename = "ObjectSizeGreaterThan")]
pub size_gt: Option<IntValue>,
#[serde(rename = "ObjectSizeLessThan")]
pub size_lt: Option<IntValue>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Expiration {
#[serde(rename = "Days")]
pub days: Option<IntValue>,
#[serde(rename = "Date")]
pub at_date: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
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 super::*;
use quick_xml::de::from_str;
#[test]
fn test_deserialize_lifecycle_config() -> Result<(), Error> {
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)?;
let cleanup = |c: &str| c.replace(char::is_whitespace, "");
assert_eq!(cleanup(message), cleanup(&message2));
// Check validation
let validated = ref_value
.validate_into_garage_lifecycle_config()
.ok_or_bad_request("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,
))?;
assert_eq!(cleanup(message), cleanup(&message3));
Ok(())
}
}
+15 -17
View File
@@ -296,7 +296,7 @@ pub async fn handle_list_parts(
},
);
let (info, next) = fetch_part_info(query, &mpu)?;
let (info, next) = fetch_part_info(query, &mpu);
let result = s3_xml::ListPartsResult {
xmlns: (),
@@ -484,7 +484,7 @@ where
iter.next();
}
_ => (),
};
}
while let Some(object) = iter.peek() {
if !object.key.starts_with(&query.prefix) {
@@ -508,7 +508,7 @@ where
ExtractionResult::NoMore => {
return Ok(None);
}
};
}
}
if !server_more {
@@ -526,7 +526,7 @@ where
fn fetch_part_info<'a>(
query: &ListPartsQuery,
mpu: &'a MultipartUpload,
) -> Result<(Vec<PartInfo<'a>>, Option<u64>), Error> {
) -> (Vec<PartInfo<'a>>, Option<u64>) {
assert!((1..=1000).contains(&query.max_parts)); // see s3/api_server.rs
// Parse multipart upload part list, removing parts not yet finished
@@ -565,10 +565,10 @@ fn fetch_part_info<'a>(
if parts.len() > query.max_parts as usize {
parts.truncate(query.max_parts as usize);
let pagination = Some(parts.last().unwrap().part_number);
return Ok((parts, pagination));
return (parts, pagination);
}
Ok((parts, None))
(parts, None)
}
/*
@@ -756,7 +756,7 @@ impl<K: std::cmp::Ord, V> Accumulator<K, V> {
None => Some(ExtractionResult::NoMore),
}
}
};
}
}
}
@@ -939,7 +939,7 @@ fn common_prefix<'a>(object: &'a Object, query: &ListQueryCommon) -> Option<&'a
}
}
/// URIencode a value if needed
/// `URIencode` a value if needed
fn uriencode_maybe(s: &str, yes: bool) -> s3_xml::Value {
if yes {
s3_xml::Value(uri_encode(s, true))
@@ -1069,7 +1069,7 @@ mod tests {
assert_eq!(upload, Uuid::from([0x8f; 32]));
}
_ => panic!("wrong result"),
};
}
assert_eq!(acc.keys.len(), 2);
assert_eq!(
@@ -1098,7 +1098,7 @@ mod tests {
match acc.extract(&(query().common), &start, &mut iter) {
ExtractionResult::Extracted { key } if key.as_str() == "b" => (),
_ => panic!("wrong result"),
};
}
}
#[tokio::test]
@@ -1255,7 +1255,7 @@ mod tests {
}
#[test]
fn test_fetch_part_info() -> Result<(), Error> {
fn test_fetch_part_info() {
let mut query = ListPartsQuery {
bucket_name: "a".to_string(),
key: "a".to_string(),
@@ -1267,7 +1267,7 @@ mod tests {
let mpu = mpu();
// Start from the beginning but with limited size to trigger pagination
let (info, pagination) = fetch_part_info(&query, &mpu)?;
let (info, pagination) = fetch_part_info(&query, &mpu);
assert_eq!(pagination.unwrap(), 3);
assert_eq!(
info,
@@ -1291,7 +1291,7 @@ mod tests {
// Use previous pagination to make a new request
query.part_number_marker = Some(pagination.unwrap());
let (info, pagination) = fetch_part_info(&query, &mpu)?;
let (info, pagination) = fetch_part_info(&query, &mpu);
assert!(pagination.is_none());
assert_eq!(
info,
@@ -1315,14 +1315,14 @@ mod tests {
// Trying to access a part that is way larger than registered ones
query.part_number_marker = Some(9999);
let (info, pagination) = fetch_part_info(&query, &mpu)?;
let (info, pagination) = fetch_part_info(&query, &mpu);
assert!(pagination.is_none());
assert_eq!(info, vec![]);
// Try without any limitation
query.max_parts = 1000;
query.part_number_marker = None;
let (info, pagination) = fetch_part_info(&query, &mpu)?;
let (info, pagination) = fetch_part_info(&query, &mpu);
assert!(pagination.is_none());
assert_eq!(
info,
@@ -1357,7 +1357,5 @@ mod tests {
},
]
);
Ok(())
}
}
+3 -3
View File
@@ -225,7 +225,7 @@ pub async fn handle_put_part(
MpuPart {
version: version_uuid,
etag: Some(etag.clone()),
checksum: checksums.extract(checksum_algorithm.map(|(algo, _)| algo)),
checksum: checksums.extract(checksum_algorithm.map(|(algo, _)| algo))?,
size: Some(total_size),
},
);
@@ -370,7 +370,7 @@ pub async fn handle_complete_multipart_upload(
req_part.part_number, req_part.checksum, part.checksum
)));
}
parts.push(*part)
parts.push(*part);
}
_ => return Err(Error::InvalidPart),
}
@@ -494,7 +494,7 @@ pub async fn handle_complete_multipart_upload(
.root_domain
.as_ref()
.map(|rd| s3_xml::Value(format!("https://{}.{}/{}", bucket_name, rd, key)))
.or(Some(s3_xml::Value(format!("/{}/{}", bucket_name, key)))),
.or_else(|| Some(s3_xml::Value(format!("/{}/{}", bucket_name, key)))),
bucket: s3_xml::Value(bucket_name.to_string()),
key: s3_xml::Value(key),
etag: s3_xml::Value(format!("\"{}\"", etag)),
+8 -8
View File
@@ -178,7 +178,7 @@ pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
checksums.verify(&expected)?;
}
ChecksumMode::Calculate(algo) => {
meta.checksum = checksums.extract(algo);
meta.checksum = checksums.extract(algo)?;
}
ChecksumMode::VerifyFrom {
checksummer,
@@ -189,10 +189,10 @@ pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
.await
.ok_or_internal_error("checksum calculation")??;
if let Some(algo) = trailer_algo {
meta.checksum = checksums.extract(Some(algo));
meta.checksum = checksums.extract(Some(algo))?;
}
}
};
}
let size = first_block.len() as u64;
check_quotas(ctx, size, existing_object.as_ref()).await?;
@@ -280,7 +280,7 @@ pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
checksums.verify(&expected)?;
}
ChecksumMode::Calculate(algo) => {
meta.checksum = checksums.extract(algo);
meta.checksum = checksums.extract(algo)?;
}
ChecksumMode::VerifyFrom {
checksummer,
@@ -290,10 +290,10 @@ pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
.await
.ok_or_internal_error("checksum calculation")??;
if let Some(algo) = trailer_algo {
meta.checksum = checksums.extract(Some(algo));
meta.checksum = checksums.extract(Some(algo))?;
}
}
};
}
// Verify quotas are respsected
check_quotas(ctx, total_size, existing_object.as_ref()).await?;
@@ -339,7 +339,7 @@ pub(crate) async fn check_quotas(
let quotas = bucket_params.quotas.get();
if quotas.max_objects.is_none() && quotas.max_size.is_none() {
return Ok(());
};
}
let counters = garage
.object_counter_table
@@ -436,7 +436,7 @@ pub(crate) async fn read_and_put_blocks<S: Stream<Item = Result<Bytes, Error>> +
tracer.start("Hash block (md5, sha256)"),
))
.await
.unwrap()
.unwrap();
}
Err(e) => {
block_tx2.send(Err(e)).await?;
+6 -6
View File
@@ -309,7 +309,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>,
bucket: Option<String>,
@@ -330,7 +330,7 @@ impl Endpoint {
} else {
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 *req.method() == Method::OPTIONS {
@@ -365,7 +365,7 @@ impl Endpoint {
}
if let Some(message) = query.nonempty_message() {
debug!("Unused query parameter: {}", message)
debug!("Unused query parameter: {}", message);
}
Ok((res, Some(bucket)))
}
@@ -580,7 +580,7 @@ impl Endpoint {
pub fn authorization_type(&self) -> Authorization {
if let Endpoint::ListBuckets = self {
return Authorization::None;
};
}
let readonly = router_match! {
@match
self,
@@ -725,7 +725,7 @@ mod tests {
) -> (Endpoint, Option<String>) {
let mut req = Request::builder().method(method).uri(uri);
if let Some((k, v)) = header {
req = req.header(k, v)
req = req.header(k, v);
}
let req = req.body(()).unwrap();
@@ -859,7 +859,7 @@ mod tests {
.body(())
.unwrap();
assert!(Endpoint::from_request(&req, None).is_err())
assert!(Endpoint::from_request(&req, None).is_err());
}
#[test]
+4 -384
View File
@@ -1,15 +1,15 @@
use quick_xml::de::from_reader;
use hyper::{header::HeaderName, Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use garage_model::bucket_table::{self, *};
use garage_model::bucket_table::Bucket;
use garage_api_common::helpers::*;
use garage_api_common::xml::website::*;
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, Value};
pub const X_AMZ_WEBSITE_REDIRECT_LOCATION: HeaderName =
HeaderName::from_static("x-amz-website-redirect-location");
@@ -31,21 +31,7 @@ pub async fn handle_get_website(ctx: ReqCtx) -> Result<Response<ResBody>, Error>
.routing_rules
.clone()
.into_iter()
.map(|rule| 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),
},
})
.map(RoutingRule::from_garage_routing_rule)
.collect(),
},
};
@@ -107,369 +93,3 @@ pub async fn handle_put_website(
.status(StatusCode::OK)
.body(empty_body())?)
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct WebsiteConfiguration {
#[serde(serialize_with = "xmlns_tag", skip_deserializing)]
pub xmlns: (),
#[serde(rename = "ErrorDocument")]
pub error_document: Option<Key>,
#[serde(rename = "IndexDocument")]
pub index_document: Option<Suffix>,
#[serde(rename = "RedirectAllRequestsTo")]
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, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct RoutingRule {
#[serde(rename = "Condition")]
pub condition: Option<Condition>,
#[serde(rename = "Redirect")]
pub redirect: Redirect,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Key {
#[serde(rename = "Key")]
pub key: Value,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Suffix {
#[serde(rename = "Suffix")]
pub suffix: Value,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Target {
#[serde(rename = "HostName")]
pub hostname: Value,
#[serde(rename = "Protocol")]
pub protocol: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Condition {
#[serde(rename = "HttpErrorCodeReturnedEquals")]
pub http_error_code: Option<IntValue>,
#[serde(rename = "KeyPrefixEquals")]
pub prefix: Option<Value>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Redirect {
#[serde(rename = "HostName")]
pub hostname: Option<Value>,
#[serde(rename = "Protocol")]
pub protocol: Option<Value>,
#[serde(rename = "HttpRedirectCode")]
pub http_redirect_code: Option<IntValue>,
#[serde(rename = "ReplaceKeyPrefixWith")]
pub replace_prefix: Option<Value>,
#[serde(rename = "ReplaceKeyWith")]
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(|rule| {
bucket_table::RoutingRule {
condition: rule.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: rule.redirect.hostname.map(|h| h.0),
protocol: rule.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: rule
.redirect
.http_redirect_code
.map(|c| c.0 as u16)
.unwrap_or(302),
replace_key_prefix: rule.redirect.replace_prefix.map(|k| k.0),
replace_key: rule.redirect.replace_full.map(|k| k.0),
},
}
})
.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()
}
}
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 super::*;
use quick_xml::de::from_str;
#[test]
fn test_deserialize() -> Result<(), Error> {
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).unwrap();
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)?;
let cleanup = |c: &str| c.replace(char::is_whitespace, "");
assert_eq!(cleanup(message), cleanup(&message2));
Ok(())
}
}
+53 -73
View File
@@ -1,33 +1,6 @@
use quick_xml::se::to_string;
use serde::{Deserialize, Serialize, Serializer};
use serde::Serialize;
use crate::error::Error as ApiError;
pub fn to_xml_with_header<T: Serialize>(x: &T) -> Result<String, ApiError> {
let mut xml = r#"<?xml version="1.0" encoding="UTF-8"?>"#.to_string();
xml.push_str(&to_string(x)?);
Ok(xml)
}
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, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Value(#[serde(rename = "$value")] pub String);
impl From<&str> for Value {
fn from(s: &str) -> Value {
Value(s.to_string())
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct IntValue(#[serde(rename = "$value")] pub i64);
pub use garage_api_common::xml::{to_xml_with_header, xmlns_tag, xmlns_xsi_tag, IntValue, Value};
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct Bucket {
@@ -61,7 +34,7 @@ pub struct ListAllMyBucketsResult {
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct LocationConstraint {
#[serde(serialize_with = "xmlns_tag")]
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
pub xmlns: (),
#[serde(rename = "$value")]
pub region: String,
@@ -97,13 +70,13 @@ pub struct DeleteError {
pub key: Option<Value>,
#[serde(rename = "Message")]
pub message: Value,
#[serde(rename = "VersionId")]
#[serde(rename = "VersionId", skip_serializing_if = "Option::is_none")]
pub version_id: Option<Value>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct DeleteResult {
#[serde(serialize_with = "xmlns_tag")]
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
pub xmlns: (),
#[serde(rename = "Deleted")]
pub deleted: Vec<Deleted>,
@@ -113,7 +86,7 @@ pub struct DeleteResult {
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct InitiateMultipartUploadResult {
#[serde(serialize_with = "xmlns_tag")]
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
pub xmlns: (),
#[serde(rename = "Bucket")]
pub bucket: Value,
@@ -125,7 +98,7 @@ pub struct InitiateMultipartUploadResult {
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct CompleteMultipartUploadResult {
#[serde(serialize_with = "xmlns_tag")]
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
pub xmlns: (),
#[serde(rename = "Location")]
pub location: Option<Value>,
@@ -135,17 +108,17 @@ pub struct CompleteMultipartUploadResult {
pub key: Value,
#[serde(rename = "ETag")]
pub etag: Value,
#[serde(rename = "ChecksumCRC32")]
#[serde(rename = "ChecksumCRC32", skip_serializing_if = "Option::is_none")]
pub checksum_crc32: Option<Value>,
#[serde(rename = "ChecksumCRC32C")]
#[serde(rename = "ChecksumCRC32C", skip_serializing_if = "Option::is_none")]
pub checksum_crc32c: Option<Value>,
#[serde(rename = "ChecksumCR64NVME")]
#[serde(rename = "ChecksumCR64NVME", skip_serializing_if = "Option::is_none")]
pub checksum_crc64nvme: Option<Value>,
#[serde(rename = "ChecksumSHA1")]
#[serde(rename = "ChecksumSHA1", skip_serializing_if = "Option::is_none")]
pub checksum_sha1: Option<Value>,
#[serde(rename = "ChecksumSHA256")]
#[serde(rename = "ChecksumSHA256", skip_serializing_if = "Option::is_none")]
pub checksum_sha256: Option<Value>,
#[serde(rename = "ChecksumType")]
#[serde(rename = "ChecksumType", skip_serializing_if = "Option::is_none")]
pub checksum_type: Option<Value>,
}
@@ -175,21 +148,21 @@ pub struct ListMultipartItem {
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct ListMultipartUploadsResult {
#[serde(serialize_with = "xmlns_tag")]
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
pub xmlns: (),
#[serde(rename = "Bucket")]
pub bucket: Value,
#[serde(rename = "KeyMarker")]
#[serde(rename = "KeyMarker", skip_serializing_if = "Option::is_none")]
pub key_marker: Option<Value>,
#[serde(rename = "UploadIdMarker")]
#[serde(rename = "UploadIdMarker", skip_serializing_if = "Option::is_none")]
pub upload_id_marker: Option<Value>,
#[serde(rename = "NextKeyMarker")]
#[serde(rename = "NextKeyMarker", skip_serializing_if = "Option::is_none")]
pub next_key_marker: Option<Value>,
#[serde(rename = "NextUploadIdMarker")]
#[serde(rename = "NextUploadIdMarker", skip_serializing_if = "Option::is_none")]
pub next_upload_id_marker: Option<Value>,
#[serde(rename = "Prefix")]
pub prefix: Value,
#[serde(rename = "Delimiter")]
#[serde(rename = "Delimiter", skip_serializing_if = "Option::is_none")]
pub delimiter: Option<Value>,
#[serde(rename = "MaxUploads")]
pub max_uploads: IntValue,
@@ -199,7 +172,7 @@ pub struct ListMultipartUploadsResult {
pub upload: Vec<ListMultipartItem>,
#[serde(rename = "CommonPrefixes")]
pub common_prefixes: Vec<CommonPrefix>,
#[serde(rename = "EncodingType")]
#[serde(rename = "EncodingType", skip_serializing_if = "Option::is_none")]
pub encoding_type: Option<Value>,
}
@@ -213,21 +186,21 @@ pub struct PartItem {
pub part_number: IntValue,
#[serde(rename = "Size")]
pub size: IntValue,
#[serde(rename = "ChecksumCRC32")]
#[serde(rename = "ChecksumCRC32", skip_serializing_if = "Option::is_none")]
pub checksum_crc32: Option<Value>,
#[serde(rename = "ChecksumCRC32C")]
#[serde(rename = "ChecksumCRC32C", skip_serializing_if = "Option::is_none")]
pub checksum_crc32c: Option<Value>,
#[serde(rename = "ChecksumCRC64NVME")]
#[serde(rename = "ChecksumCRC64NVME", skip_serializing_if = "Option::is_none")]
pub checksum_crc64nvme: Option<Value>,
#[serde(rename = "ChecksumSHA1")]
#[serde(rename = "ChecksumSHA1", skip_serializing_if = "Option::is_none")]
pub checksum_sha1: Option<Value>,
#[serde(rename = "ChecksumSHA256")]
#[serde(rename = "ChecksumSHA256", skip_serializing_if = "Option::is_none")]
pub checksum_sha256: Option<Value>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct ListPartsResult {
#[serde(serialize_with = "xmlns_tag")]
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
pub xmlns: (),
#[serde(rename = "Bucket")]
pub bucket: Value,
@@ -235,9 +208,12 @@ pub struct ListPartsResult {
pub key: Value,
#[serde(rename = "UploadId")]
pub upload_id: Value,
#[serde(rename = "PartNumberMarker")]
#[serde(rename = "PartNumberMarker", skip_serializing_if = "Option::is_none")]
pub part_number_marker: Option<IntValue>,
#[serde(rename = "NextPartNumberMarker")]
#[serde(
rename = "NextPartNumberMarker",
skip_serializing_if = "Option::is_none"
)]
pub next_part_number_marker: Option<IntValue>,
#[serde(rename = "MaxParts")]
pub max_parts: IntValue,
@@ -275,29 +251,32 @@ pub struct CommonPrefix {
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct ListBucketResult {
#[serde(serialize_with = "xmlns_tag")]
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
pub xmlns: (),
#[serde(rename = "Name")]
pub name: Value,
#[serde(rename = "Prefix")]
pub prefix: Value,
#[serde(rename = "Marker")]
#[serde(rename = "Marker", skip_serializing_if = "Option::is_none")]
pub marker: Option<Value>,
#[serde(rename = "NextMarker")]
#[serde(rename = "NextMarker", skip_serializing_if = "Option::is_none")]
pub next_marker: Option<Value>,
#[serde(rename = "StartAfter")]
#[serde(rename = "StartAfter", skip_serializing_if = "Option::is_none")]
pub start_after: Option<Value>,
#[serde(rename = "ContinuationToken")]
#[serde(rename = "ContinuationToken", skip_serializing_if = "Option::is_none")]
pub continuation_token: Option<Value>,
#[serde(rename = "NextContinuationToken")]
#[serde(
rename = "NextContinuationToken",
skip_serializing_if = "Option::is_none"
)]
pub next_continuation_token: Option<Value>,
#[serde(rename = "KeyCount")]
#[serde(rename = "KeyCount", skip_serializing_if = "Option::is_none")]
pub key_count: Option<IntValue>,
#[serde(rename = "MaxKeys")]
pub max_keys: IntValue,
#[serde(rename = "Delimiter")]
#[serde(rename = "Delimiter", skip_serializing_if = "Option::is_none")]
pub delimiter: Option<Value>,
#[serde(rename = "EncodingType")]
#[serde(rename = "EncodingType", skip_serializing_if = "Option::is_none")]
pub encoding_type: Option<Value>,
#[serde(rename = "IsTruncated")]
pub is_truncated: Value,
@@ -309,15 +288,15 @@ pub struct ListBucketResult {
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct VersioningConfiguration {
#[serde(serialize_with = "xmlns_tag")]
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
pub xmlns: (),
#[serde(rename = "Status")]
#[serde(rename = "Status", skip_serializing_if = "Option::is_none")]
pub status: Option<Value>,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct PostObject {
#[serde(serialize_with = "xmlns_tag")]
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
pub xmlns: (),
#[serde(rename = "Location")]
pub location: Value,
@@ -331,11 +310,11 @@ pub struct PostObject {
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct Grantee {
#[serde(rename = "xmlns:xsi", serialize_with = "xmlns_xsi_tag")]
#[serde(rename = "@xmlns:xsi", serialize_with = "xmlns_xsi_tag")]
pub xmlns_xsi: (),
#[serde(rename = "xsi:type")]
#[serde(rename = "@xsi:type")]
pub typ: String,
#[serde(rename = "DisplayName")]
#[serde(rename = "DisplayName", skip_serializing_if = "Option::is_none")]
pub display_name: Option<Value>,
#[serde(rename = "ID")]
pub id: Option<Value>,
@@ -357,9 +336,9 @@ pub struct AccessControlList {
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct AccessControlPolicy {
#[serde(serialize_with = "xmlns_tag")]
#[serde(rename = "@xmlns", serialize_with = "xmlns_tag")]
pub xmlns: (),
#[serde(rename = "Owner")]
#[serde(rename = "Owner", skip_serializing_if = "Option::is_none")]
pub owner: Option<Owner>,
#[serde(rename = "AccessControlList")]
pub acl: AccessControlList,
@@ -368,6 +347,7 @@ pub struct AccessControlPolicy {
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error as ApiError;
use garage_util::time::*;
@@ -458,7 +438,7 @@ mod tests {
assert_eq!(
to_xml_with_header(&get_bucket_versioning)?,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
<VersioningConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"/>"
<VersioningConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"></VersioningConfiguration>"
);
let get_bucket_versioning2 = VersioningConfiguration {
xmlns: (),
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "garage_block"
version = "2.2.0"
version = "2.3.0"
authors = ["Alex Auvolat <alex@adnab.me>"]
edition = "2018"
license = "AGPL-3.0"
@@ -40,3 +40,6 @@ tokio-util.workspace = true
[features]
system-libs = ["zstd/pkg-config"]
[lints]
workspace = true
+2 -2
View File
@@ -21,7 +21,7 @@ pub(crate) struct DataLayout {
pub(crate) data_dirs: Vec<DataDir>,
markers: HashMap<PathBuf, String>,
/// Primary storage location (index in data_dirs) for each partition
/// Primary storage location (index in `data_dirs`) for each partition
/// = the location where the data is supposed to be, blocks are always
/// written there (copies in other dirs may be deleted if they exist)
pub(crate) part_prim: Vec<Idx>,
@@ -159,7 +159,7 @@ impl DataLayout {
for (idir, parts) in dir_prim.iter().enumerate() {
for part in parts.iter() {
assert!(part_prim[*part].is_none());
part_prim[*part] = Some(idir as Idx)
part_prim[*part] = Some(idir as Idx);
}
}
+2 -1
View File
@@ -780,7 +780,7 @@ impl BlockManagerLocked {
assert!(to_delete.as_ref() != Some(&tgt_path));
let mut path_tmp = tgt_path.clone();
let tmp_extension = format!("tmp{}", hex::encode(thread_rng().gen::<[u8; 4]>()));
let tmp_extension = format!("tmp{}", hex::encode(rand::rng().random::<[u8; 4]>()));
path_tmp.set_extension(tmp_extension);
fs::create_dir_all(&directory).await?;
@@ -789,6 +789,7 @@ impl BlockManagerLocked {
let mut f = fs::File::create(&path_tmp).await?;
f.write_all(data).await?;
f.flush().await?;
mgr.metrics.bytes_written.add(data.len() as u64);
if mgr.data_fsync {
+3 -3
View File
@@ -6,7 +6,7 @@ use opentelemetry::{global, metrics::*};
use garage_db as db;
/// TableMetrics reference all counter used for metrics
/// `TableMetrics` reference all counter used for metrics
pub struct BlockManagerMetrics {
pub(crate) _compression_level: ValueObserver<u64>,
pub(crate) _rc_size: ValueObserver<u64>,
@@ -52,7 +52,7 @@ impl BlockManagerMetrics {
_rc_size: meter
.u64_value_observer("block.rc_size", move |observer| {
if let Ok(value) = rc_tree.approximate_len() {
observer.observe(value as u64, &[])
observer.observe(value as u64, &[]);
}
})
.with_description("Number of blocks known to the reference counter")
@@ -78,7 +78,7 @@ impl BlockManagerMetrics {
_buffer_free_kb: meter
.u64_value_observer("block.ram_buffer_free_kb", move |observer| {
observer.observe(buffer_semaphore.available_permits() as u64, &[])
observer.observe(buffer_semaphore.available_permits() as u64, &[]);
})
.with_description(
"Available RAM in KiB to use for buffering data blocks to be written to remote nodes",
+6 -6
View File
@@ -37,7 +37,7 @@ impl BlockRc {
match old_rc.increment().serialize() {
Some(x) => tx.insert(&self.rc_table, hash, x)?,
None => unreachable!(),
};
}
Ok(old_rc.is_zero())
}
@@ -52,7 +52,7 @@ impl BlockRc {
match new_rc.serialize() {
Some(x) => tx.insert(&self.rc_table, hash, x)?,
None => tx.remove(&self.rc_table, hash)?,
};
}
Ok(matches!(new_rc, RcEntry::Deletable { .. }))
}
@@ -72,7 +72,7 @@ impl BlockRc {
tx.remove(&self.rc_table, hash)?;
}
_ => (),
};
}
Ok(())
})?;
Ok(())
@@ -136,14 +136,14 @@ impl BlockRc {
pub(crate) enum RcEntry {
/// Present: the block has `count` references, with `count` > 0.
///
/// This is stored as u64::to_be_bytes(count)
/// This is stored as `u64::to_be_bytes(count)`
Present { count: u64 },
/// Deletable: the block has zero references, and can be deleted
/// once time (returned by now_msec) is larger than at_time
/// once time (returned by `now_msec`) is larger than `at_time`
/// (in millis since Unix epoch)
///
/// This is stored as [0u8; 8] followed by u64::to_be_bytes(at_time),
/// This is stored as [0u8; 8] followed by `u64::to_be_bytes(at_time)`,
/// (this allows for the data format to be backwards compatible with
/// previous Garage versions that didn't have this intermediate state)
Deletable { at_time: u64 },
+5 -5
View File
@@ -127,7 +127,7 @@ impl Worker for RepairWorker {
self.manager
.resync
.put_to_resync(&hash, Duration::from_secs(0))?;
self.next_start = Some(hash)
self.next_start = Some(hash);
}
Ok(WorkerState::Busy)
@@ -248,7 +248,7 @@ fn randomize_next_scrub_run_time(timestamp: u64) -> u64 {
timestamp
+ SCRUB_INTERVAL
.saturating_add(Duration::from_secs(
rand::thread_rng().gen_range(0..3600 * 24 * 10),
rand::rng().random_range(0..3600 * 24 * 10),
))
.as_millis() as u64
}
@@ -440,7 +440,7 @@ impl Worker for ScrubWorker {
Ok(cmd) => self.handle_cmd(cmd).await,
Err(mpsc::error::TryRecvError::Disconnected) => return Ok(WorkerState::Done),
Err(mpsc::error::TryRecvError::Empty) => (),
};
}
match &mut self.work {
ScrubWorkerState::Running { iterator, t_cp } => {
@@ -455,7 +455,7 @@ impl Worker for ScrubWorker {
}
Err(e) => return Err(e),
_ => (),
};
}
if now - *t_cp > 60 * 1000 {
self.persister
@@ -570,7 +570,7 @@ impl Worker for RebalanceWorker {
format!("Started: {}", msec_to_rfc3339(self.t_started)),
];
if let Some(t_fin) = self.t_finished {
freeform.push(format!("Finished: {}", msec_to_rfc3339(t_fin)))
freeform.push(format!("Finished: {}", msec_to_rfc3339(t_fin)));
}
WorkerStatus {
progress: Some(format!("{:.2}%", self.block_iter.progress() * 100.)),
+1 -1
View File
@@ -588,7 +588,7 @@ impl Worker for ResyncWorker {
async fn wait_for_work(&mut self) -> WorkerState {
while self.index >= self.persister.get_with(|x| x.n_workers) {
self.manager.resync.notify.notified().await
self.manager.resync.notify.notified().await;
}
select! {
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "garage_db"
version = "2.2.0"
version = "2.3.0"
authors = ["Alex Auvolat <alex@adnab.me>"]
edition = "2018"
license = "AGPL-3.0"
@@ -33,3 +33,6 @@ bundled-libs = ["rusqlite?/bundled"]
lmdb = ["heed"]
fjall = ["dep:fjall", "dep:parking_lot"]
sqlite = ["rusqlite", "r2d2", "r2d2_sqlite"]
[lints]
workspace = true
+7 -3
View File
@@ -222,9 +222,13 @@ impl Tree {
}
#[inline]
pub fn get_gt<T: AsRef<[u8]>>(&self, from: T) -> Result<Option<(Value, Value)>> {
self.range((Bound::Excluded(from), Bound::Unbounded))?
.next()
.transpose()
if from.as_ref().is_empty() {
self.iter()?.next().transpose()
} else {
self.range((Bound::Excluded(from), Bound::Unbounded))?
.next()
.transpose()
}
}
/// Returns the old value if there was one
+30 -22
View File
@@ -7,8 +7,10 @@ use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use heed::types::ByteSlice;
use heed::{BytesDecode, Env, RoTxn, RwTxn, UntypedDatabase as Database};
use heed::types::Bytes;
use heed::{BytesDecode, Env, EnvFlags, RoTxn, RwTxn, WithTls};
type Database = heed::Database<Bytes, Bytes>;
use crate::{
open::{Engine, OpenOpt},
@@ -37,14 +39,15 @@ pub(crate) fn open_db(path: &PathBuf, opt: &OpenOpt) -> Result<Db> {
env_builder.max_dbs(100);
env_builder.map_size(map_size);
env_builder.max_readers(2048);
unsafe {
env_builder.flag(heed::flags::Flags::MdbNoRdAhead);
env_builder.flag(heed::flags::Flags::MdbNoMetaSync);
if !opt.fsync {
env_builder.flag(heed::flags::Flags::MdbNoSync);
}
let mut env_flags = EnvFlags::NO_READ_AHEAD | EnvFlags::NO_META_SYNC;
if !opt.fsync {
env_flags |= EnvFlags::NO_SYNC;
}
match env_builder.open(path) {
let open_res = unsafe {
env_builder.flags(env_flags);
env_builder.open(path)
};
match open_res {
Err(heed::Error::Io(e)) if e.kind() == std::io::ErrorKind::OutOfMemory => Err(Error(
"OutOfMemory error while trying to open LMDB database. This can happen \
if your operating system is not allowing you to use sufficient virtual \
@@ -109,7 +112,9 @@ impl IDb for LmdbDb {
if let Some(i) = trees.1.get(name) {
Ok(*i)
} else {
let tree = self.db.create_database(Some(name))?;
let mut wtxn = self.db.write_txn()?;
let tree = self.db.create_database(&mut wtxn, Some(name))?;
wtxn.commit()?;
let i = trees.0.len();
trees.0.push(tree);
trees.1.insert(name.to_string(), i);
@@ -118,29 +123,32 @@ impl IDb for LmdbDb {
}
fn list_trees(&self) -> Result<Vec<String>> {
let tree0 = match self.db.open_database::<heed::types::Str, ByteSlice>(None)? {
let rtxn = self.db.read_txn()?;
let tree0 = match self
.db
.open_database::<heed::types::Str, Bytes>(&rtxn, None)?
{
Some(x) => x,
None => return Ok(vec![]),
};
let mut ret = vec![];
let tx = self.db.read_txn()?;
for item in tree0.iter(&tx)? {
for item in tree0.iter(&rtxn)? {
let (tree_name, _) = item?;
ret.push(tree_name.to_string());
}
drop(tx);
let mut ret2 = vec![];
for tree_name in ret {
if self
.db
.open_database::<ByteSlice, ByteSlice>(Some(&tree_name))?
.open_database::<Bytes, Bytes>(&rtxn, Some(&tree_name))?
.is_some()
{
ret2.push(tree_name);
}
}
drop(rtxn);
Ok(ret2)
}
@@ -258,11 +266,11 @@ impl IDb for LmdbDb {
Ok(on_commit)
}
TxFnResult::Abort => {
tx.tx.abort().map_err(Error::from).map_err(TxError::Db)?;
tx.tx.abort();
Err(TxError::Abort(()))
}
TxFnResult::DbErr => {
tx.tx.abort().map_err(Error::from).map_err(TxError::Db)?;
tx.tx.abort();
Err(TxError::Db(Error(
"(this message will be discarded)".into(),
)))
@@ -275,7 +283,7 @@ impl IDb for LmdbDb {
struct LmdbTx<'a> {
trees: &'a [Database],
tx: RwTxn<'a, 'a>,
tx: RwTxn<'a>,
}
impl<'a> LmdbTx<'a> {
@@ -355,15 +363,15 @@ impl<'a> ITx for LmdbTx<'a> {
// therefore a bit of unsafe code (it is a self-referential struct)
type IteratorItem<'a> = heed::Result<(
<ByteSlice as BytesDecode<'a>>::DItem,
<ByteSlice as BytesDecode<'a>>::DItem,
<Bytes as BytesDecode<'a>>::DItem,
<Bytes as BytesDecode<'a>>::DItem,
)>;
struct TxAndIterator<'a, I>
where
I: Iterator<Item = IteratorItem<'a>> + 'a,
{
tx: RoTxn<'a>,
tx: RoTxn<'a, WithTls>,
iter: Option<I>,
_pin: PhantomPinned,
}
@@ -378,7 +386,7 @@ where
}
/// Safety: iterfun must not store its argument anywhere but in its result.
unsafe fn make<F>(tx: RoTxn<'a>, iterfun: F) -> Result<ValueIter<'a>>
unsafe fn make<F>(tx: RoTxn<'a, WithTls>, iterfun: F) -> Result<ValueIter<'a>>
where
F: FnOnce(&'a RoTxn<'a>) -> Result<I>,
{
+1 -1
View File
@@ -564,7 +564,7 @@ fn bounds_sql<'r>(low: Bound<&'r [u8]>, high: Bound<&'r [u8]>) -> (String, Vec<V
params.push(b.to_vec());
}
Bound::Unbounded => (),
};
}
match high {
Bound::Included(b) => {
+6 -4
View File
@@ -130,10 +130,12 @@ fn test_lmdb_db() {
use crate::lmdb_adapter::LmdbDb;
let path = mktemp::Temp::new_dir().unwrap();
let db = heed::EnvOpenOptions::new()
.max_dbs(100)
.open(&path)
.unwrap();
let db = unsafe {
heed::EnvOpenOptions::new()
.max_dbs(100)
.open(&path)
.unwrap()
};
let db = LmdbDb::init(db);
test_suite(db);
drop(path);
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "garage"
version = "2.2.0"
version = "2.3.0"
authors = ["Alex Auvolat <alex@adnab.me>"]
edition = "2018"
license = "AGPL-3.0"
@@ -119,3 +119,6 @@ system-libs = [
"garage_rpc/system-libs",
"sodiumoxide/use-pkg-config",
]
[lints]
workspace = true
+10
View File
@@ -0,0 +1,10 @@
use structopt::{clap::Shell, StructOpt};
use crate::Opt;
pub(crate) fn generate_completions(shell: Shell) {
let mut command = Opt::clap();
let command_name = command.get_name().to_string();
command.gen_completions_to(command_name, shell, &mut std::io::stdout());
}
+1 -1
View File
@@ -7,7 +7,7 @@ use garage_db::*;
/// K2V command line interface
#[derive(StructOpt, Debug)]
pub struct ConvertDbOpt {
/// Input database path (not the same as metadata_dir, see
/// Input database path (not the same as `metadata_dir`, see
/// <https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#db_engine>
#[structopt(short = "i")]
input_path: PathBuf,
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod completions;
pub(crate) mod convert_db;
pub(crate) mod init;
pub(crate) mod repair;
+9 -7
View File
@@ -1,3 +1,5 @@
use std::borrow::Cow;
use format_table::format_table;
use chrono::Local;
@@ -42,18 +44,18 @@ impl Cli {
table_list_abbr(&tok.scope)
};
let exp = if tok.expired {
"expired".to_string()
Cow::Borrowed("expired")
} else {
tok.expiration
.map(|x| x.with_timezone(&Local).to_string())
.unwrap_or("never".into())
.map(|x| x.with_timezone(&Local).to_string().into())
.unwrap_or(Cow::Borrowed("never"))
};
table.push(format!(
"{}\t{}\t{}\t{}\t{}",
tok.id.as_deref().unwrap_or("-"),
tok.created
.map(|x| x.with_timezone(&Local).date_naive().to_string())
.unwrap_or("-".into()),
.map(|x| x.with_timezone(&Local).date_naive().to_string().into())
.unwrap_or(Cow::Borrowed("-")),
tok.name,
exp,
scope,
@@ -236,8 +238,8 @@ fn print_token_info(token: &GetAdminTokenInfoResponse) {
"Expiration:\t{}",
token
.expiration
.map(|x| x.with_timezone(&Local).to_string())
.unwrap_or("never".into())
.map(|x| x.with_timezone(&Local).to_string().into())
.unwrap_or(Cow::Borrowed("never"))
),
String::new(),
];
+22 -13
View File
@@ -295,28 +295,39 @@ impl Cli {
));
}
// Destructure becket info to allow separate use of `id` and `website_config`
let GetBucketInfoResponse {
id: bucket_id,
website_config: bucket_website_config,
..
} = bucket;
let wa = if opt.allow {
UpdateBucketWebsiteAccess {
enabled: true,
index_document: Some(opt.index_document.clone()),
error_document: opt
.error_document
.or(bucket.website_config.and_then(|x| x.error_document.clone())),
.or_else(|| bucket_website_config.and_then(|x| x.error_document.clone())),
routing_rules: None,
}
} else {
UpdateBucketWebsiteAccess {
enabled: false,
index_document: None,
error_document: None,
routing_rules: None,
}
};
let res = self
.api_request(UpdateBucketRequest {
id: bucket.id,
id: bucket_id,
body: UpdateBucketRequestBody {
website_access: Some(wa),
quotas: None,
cors_rules: None,
lifecycle_rules: None,
},
})
.await?;
@@ -367,6 +378,8 @@ impl Cli {
body: UpdateBucketRequestBody {
website_access: None,
quotas: Some(new_quotas),
cors_rules: None,
lifecycle_rules: None,
},
})
.await?;
@@ -437,8 +450,8 @@ impl Cli {
let bs = bytesize::ByteSize::b(size);
tab.push(format!(
"Size:\t{} ({})",
bs.to_string_as(true),
bs.to_string_as(false)
bs.display().si(),
bs.display().iec()
));
tab.push(format!("Size (exact):\t{}", size));
if !ver.blocks.is_empty() {
@@ -488,11 +501,7 @@ fn print_bucket_info(bucket: &GetBucketInfoResponse) {
String::new(),
{
let size = bytesize::ByteSize::b(bucket.bytes as u64);
format!(
"Size:\t{} ({})",
size.to_string_as(true),
size.to_string_as(false)
)
format!("Size:\t{} ({})", size.display().si(), size.display().iec())
},
format!("Objects:\t{}", bucket.objects),
];
@@ -509,8 +518,8 @@ fn print_bucket_info(bucket: &GetBucketInfoResponse) {
bytesize::ByteSize::b(bucket.unfinished_multipart_upload_bytes as u64);
format!(
"Size of unfinished multipart uploads:\t{} ({})",
mpu_size.to_string_as(true),
mpu_size.to_string_as(false),
mpu_size.display().si(),
mpu_size.display().iec(),
)
},
]);
@@ -538,8 +547,8 @@ fn print_bucket_info(bucket: &GetBucketInfoResponse) {
let ms = bytesize::ByteSize::b(ms);
info.push(format!(
" maximum size:\t{} ({})",
ms.to_string_as(true),
ms.to_string_as(false)
ms.display().si(),
ms.display().iec()
));
}
if let Some(mo) = bucket.quotas.max_objects {
+7 -5
View File
@@ -1,3 +1,5 @@
use std::borrow::Cow;
use format_table::format_table;
use chrono::Local;
@@ -33,11 +35,11 @@ impl Cli {
let mut table = vec!["ID\tCreated\tName\tExpiration".to_string()];
for key in keys.0.iter() {
let exp = if key.expired {
"expired".to_string()
Cow::from("expired")
} else {
key.expiration
.map(|x| x.with_timezone(&Local).to_string())
.unwrap_or("never".into())
.map(|x| x.with_timezone(&Local).to_string().into())
.unwrap_or(Cow::Borrowed("never"))
};
table.push(format!(
"{}\t{}\t{}\t{}",
@@ -288,8 +290,8 @@ fn print_key_info(key: &GetKeyInfoResponse) {
format!(
"Expiration:\t{}",
key.expiration
.map(|x| x.with_timezone(&Local).to_string())
.unwrap_or("never".into())
.map(|x| x.with_timezone(&Local).to_string().into())
.unwrap_or(Cow::Borrowed("never"))
),
String::new(),
format!("Can create buckets:\t{}", key.permissions.create_bucket),
+3 -3
View File
@@ -313,7 +313,7 @@ To know the correct value of the new layout version, invoke `garage layout show`
pub fn capacity_string(v: Option<u64>) -> String {
match v {
Some(c) => ByteSize::b(c).to_string_as(false),
Some(c) => ByteSize::b(c).display().iec().to_string(),
None => "gateway".to_string(),
}
}
@@ -383,7 +383,7 @@ pub fn print_cluster_layout(layout: &GetClusterLayoutResponse, empty_msg: &str)
tags,
role.zone,
capacity_string(role.capacity),
ByteSize::b(usable_capacity).to_string_as(false),
ByteSize::b(usable_capacity).display().iec(),
(100.0 * usable_capacity as f32) / (capacity as f32)
));
} else {
@@ -394,7 +394,7 @@ pub fn print_cluster_layout(layout: &GetClusterLayoutResponse, empty_msg: &str)
role.zone,
capacity_string(role.capacity),
));
};
}
}
if table.len() > 1 {
format_table(table);
+1 -1
View File
@@ -102,7 +102,7 @@ impl Cli {
s => {
table.push(format!("Worker state:\t{}", format_worker_state(s)));
}
};
}
if let Some(tql) = info.tranquility {
table.push(format!("Tranquility:\t{}", tql));
}
+34 -10
View File
@@ -1,4 +1,4 @@
use structopt::StructOpt;
use structopt::{clap::Shell, StructOpt};
use garage_util::version::garage_version;
@@ -8,7 +8,7 @@ use crate::cli::local::convert_db;
pub enum Command {
/// Run Garage server
#[structopt(name = "server", version = garage_version())]
Server,
Server(ServerOpt),
/// Get network status
#[structopt(name = "status", version = garage_version())]
@@ -71,12 +71,36 @@ pub enum Command {
/// The result is printed to `stdout` in JSON format.
#[structopt(name = "json-api", version = garage_version())]
JsonApi {
/// The admin API endpoint to invoke, e.g. GetClusterStatus
/// The admin API endpoint to invoke, e.g. `GetClusterStatus`
endpoint: String,
/// The JSON payload, or `-` to read from `stdin`
#[structopt(default_value = "null")]
payload: String,
},
/// Generate completions for a shell
#[structopt(name = "completions", version = garage_version())]
Completions { shell: Shell },
}
// ---------------------------
// ---- garage server ... ----
// ---------------------------
#[derive(StructOpt, Debug)]
pub struct ServerOpt {
/// Automatically configure a single-node layout in the cluster.
/// Garage will refuse to run if the cluster already has other nodes.
#[structopt(long = "single-node")]
pub(crate) single_node: bool,
/// Configure a default S3 API key using environment variables `GARAGE_DEFAULT_ACCESS_KEY` and
/// `GARAGE_DEFAULT_SECRET_KEY`. Requires `--single-node`.
#[structopt(long = "default-access-key")]
pub(crate) default_access_key: bool,
/// Configure a default bucket using environment variable `GARAGE_DEFAULT_BUCKET`.
/// Implies `--default-access-key`. Requires `--single-node`.
#[structopt(long = "default-bucket")]
pub(crate) default_bucket: bool,
}
// -------------------------
@@ -455,7 +479,7 @@ pub struct KeyNewOpt {
#[structopt(default_value = "Unnamed key")]
pub name: String,
/// Set an expiration time for the access key
/// (see docs.rs/parse_duration for date format)
/// (see `docs.rs/parse_duration` for date format)
#[structopt(long = "expires-in")]
pub expires_in: Option<String>,
}
@@ -466,7 +490,7 @@ pub struct KeySetOpt {
pub key_pattern: String,
/// Set an expiration time for the access key
/// (see docs.rs/parse_duration for date format)
/// (see `docs.rs/parse_duration` for date format)
#[structopt(long = "expires-in")]
pub expires_in: Option<String>,
/// Set the access key to never expire
@@ -498,7 +522,7 @@ pub struct KeyPermOpt {
/// ID or name of the key
pub key_pattern: String,
/// Flag that allows key to create buckets using S3's CreateBucket call
/// Flag that allows key to create buckets using S3's `CreateBucket` call
#[structopt(long = "create-bucket")]
pub create_bucket: bool,
}
@@ -577,12 +601,12 @@ pub enum AdminTokenOperation {
pub struct AdminTokenCreateOp {
/// Set a name for the token
pub name: Option<String>,
/// Set an expiration time for the token (see docs.rs/parse_duration for date
/// Set an expiration time for the token (see `docs.rs/parse_duration` for date
/// format)
#[structopt(long = "expires-in")]
pub expires_in: Option<String>,
/// Set a limited scope for the token, as a comma-separated list of
/// admin API functions (e.g. GetClusterStatus, etc.). The default scope
/// admin API functions (e.g. `GetClusterStatus`, etc.). The default scope
/// is `*`, which allows access to all admin API functions.
/// Note that granting a scope that allows `CreateAdminToken` or
/// `UpdateAdminToken` allows for privilege escalation, and is therefore
@@ -599,7 +623,7 @@ pub struct AdminTokenSetOp {
/// Name or prefix of the ID of the token to modify
pub api_token: String,
/// Set an expiration time for the token (see docs.rs/parse_duration for date
/// Set an expiration time for the token (see `docs.rs/parse_duration` for date
/// format)
#[structopt(long = "expires-in")]
pub expires_in: Option<String>,
@@ -608,7 +632,7 @@ pub struct AdminTokenSetOp {
pub never_expires: bool,
/// Set a limited scope for the token, as a comma-separated list of
/// admin API functions (e.g. GetClusterStatus, etc.), or `*` to allow
/// admin API functions (e.g. `GetClusterStatus`, etc.), or `*` to allow
/// all admin API functions.
/// Use `--scope=+Scope1,Scope2` to add scopes to the existing list,
/// and `--scope=-Scope1,Scope2` to remove scopes from the existing list.
+27 -11
View File
@@ -63,8 +63,7 @@ struct Opt {
cmd: Command,
}
#[tokio::main]
async fn main() {
fn main() {
// Initialize version and features info
let features = &[
#[cfg(feature = "bundled-libs")]
@@ -145,8 +144,21 @@ async fn main() {
sodiumoxide::init().expect("Unable to init sodiumoxide");
let res = match opt.cmd {
Command::Server => server::run_server(opt.config_file, opt.secrets).await,
let res = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("build tokio multi_thread runtime failed")
.block_on(run(opt));
if let Err(e) = res {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
async fn run(opt: Opt) -> Result<(), Error> {
match opt.cmd {
Command::Server(sopt) => server::run_server(opt.config_file, opt.secrets, sopt).await,
Command::OfflineRepair(repair_opt) => {
cli::local::repair::offline_repair(opt.config_file, opt.secrets, repair_opt).await
}
@@ -165,22 +177,26 @@ async fn main() {
);
Ok(())
}
Command::Completions { shell } => {
cli::local::completions::generate_completions(shell);
Ok(())
}
_ => cli_command(opt).await,
};
if let Err(e) = res {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
/// # Safety
///
/// should be called before tokio runtime initialization
/// to limit multithread problem with `std::env::set_var` which is unsafe
fn init_logging(opt: &Opt) {
if std::env::var("RUST_LOG").is_err() {
let default_log = match &opt.cmd {
Command::Server => "netapp=info,garage=info",
Command::Server(_) => "netapp=info,garage=info",
_ => "netapp=warn,garage=warn",
};
std::env::set_var("RUST_LOG", default_log)
unsafe { std::env::set_var("RUST_LOG", default_log) };
}
let env_filter = tracing_subscriber::filter::EnvFilter::from_default_env();

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