mirror of
https://github.com/deuxfleurs-org/garage.git
synced 2026-08-17 17:17:47 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca2a190b2f | |||
| b275dc5000 | |||
| 0a48d504fe | |||
| c834f3f024 |
Generated
+15
-27
@@ -867,9 +867,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.15"
|
version = "0.5.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
@@ -1200,7 +1200,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage"
|
name = "garage"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assert-json-diff",
|
"assert-json-diff",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1250,13 +1250,12 @@ dependencies = [
|
|||||||
"timeago",
|
"timeago",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-journald",
|
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_api_admin"
|
name = "garage_api_admin"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1282,7 +1281,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_api_common"
|
name = "garage_api_common"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1316,7 +1315,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_api_k2v"
|
name = "garage_api_k2v"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"err-derive",
|
"err-derive",
|
||||||
@@ -1339,7 +1338,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_api_s3"
|
name = "garage_api_s3"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
@@ -1384,7 +1383,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_block"
|
name = "garage_block"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
@@ -1409,7 +1408,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_db"
|
name = "garage_db"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"err-derive",
|
"err-derive",
|
||||||
"heed",
|
"heed",
|
||||||
@@ -1422,7 +1421,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_model"
|
name = "garage_model"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
@@ -1449,7 +1448,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_net"
|
name = "garage_net"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1474,7 +1473,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_rpc"
|
name = "garage_rpc"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1506,7 +1505,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_table"
|
name = "garage_table"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1527,7 +1526,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_util"
|
name = "garage_util"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -1559,7 +1558,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "garage_web"
|
name = "garage_web"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"err-derive",
|
"err-derive",
|
||||||
"garage_api_common",
|
"garage_api_common",
|
||||||
@@ -4515,17 +4514,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-journald"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fc0b4143302cf1022dac868d521e36e8b27691f72c84b3311750d5188ebba657"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"tracing-core",
|
|
||||||
"tracing-subscriber",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-log"
|
name = "tracing-log"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|||||||
+12
-13
@@ -24,18 +24,18 @@ default-members = ["src/garage"]
|
|||||||
|
|
||||||
# Internal Garage crates
|
# Internal Garage crates
|
||||||
format_table = { version = "0.1.1", path = "src/format-table" }
|
format_table = { version = "0.1.1", path = "src/format-table" }
|
||||||
garage_api_common = { version = "1.2.0", path = "src/api/common" }
|
garage_api_common = { version = "1.1.0", path = "src/api/common" }
|
||||||
garage_api_admin = { version = "1.2.0", path = "src/api/admin" }
|
garage_api_admin = { version = "1.1.0", path = "src/api/admin" }
|
||||||
garage_api_s3 = { version = "1.2.0", path = "src/api/s3" }
|
garage_api_s3 = { version = "1.1.0", path = "src/api/s3" }
|
||||||
garage_api_k2v = { version = "1.2.0", path = "src/api/k2v" }
|
garage_api_k2v = { version = "1.1.0", path = "src/api/k2v" }
|
||||||
garage_block = { version = "1.2.0", path = "src/block" }
|
garage_block = { version = "1.1.0", path = "src/block" }
|
||||||
garage_db = { version = "1.2.0", path = "src/db", default-features = false }
|
garage_db = { version = "1.1.0", path = "src/db", default-features = false }
|
||||||
garage_model = { version = "1.2.0", path = "src/model", default-features = false }
|
garage_model = { version = "1.1.0", path = "src/model", default-features = false }
|
||||||
garage_net = { version = "1.2.0", path = "src/net" }
|
garage_net = { version = "1.1.0", path = "src/net" }
|
||||||
garage_rpc = { version = "1.2.0", path = "src/rpc" }
|
garage_rpc = { version = "1.1.0", path = "src/rpc" }
|
||||||
garage_table = { version = "1.2.0", path = "src/table" }
|
garage_table = { version = "1.1.0", path = "src/table" }
|
||||||
garage_util = { version = "1.2.0", path = "src/util" }
|
garage_util = { version = "1.1.0", path = "src/util" }
|
||||||
garage_web = { version = "1.2.0", path = "src/web" }
|
garage_web = { version = "1.1.0", path = "src/web" }
|
||||||
k2v-client = { version = "0.0.4", path = "src/k2v-client" }
|
k2v-client = { version = "0.0.4", path = "src/k2v-client" }
|
||||||
|
|
||||||
# External crates from crates.io
|
# External crates from crates.io
|
||||||
@@ -83,7 +83,6 @@ pretty_env_logger = "0.5"
|
|||||||
structopt = { version = "0.3", default-features = false }
|
structopt = { version = "0.3", default-features = false }
|
||||||
syslog-tracing = "0.3"
|
syslog-tracing = "0.3"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-journald = "0.3.1"
|
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
heed = { version = "0.11", default-features = false, features = ["lmdb"] }
|
heed = { version = "0.11", default-features = false, features = ["lmdb"] }
|
||||||
|
|||||||
@@ -8,18 +8,18 @@ have published Ansible roles. We list them and compare them below.
|
|||||||
|
|
||||||
## Comparison of Ansible roles
|
## Comparison of Ansible roles
|
||||||
|
|
||||||
| Feature | [ansible-role-garage](#zorun-ansible-role-garage) | [garage-docker-ansible-deploy](#moan0s-garage-docker-ansible-deploy) | [eddster ansible-role-garage](#eddster-ansible-role-garage) |
|
| Feature | [ansible-role-garage](#zorun-ansible-role-garage) | [garage-docker-ansible-deploy](#moan0s-garage-docker-ansible-deploy) |
|
||||||
|------------------------------------|---------------------------------------------|---------------------------------------------------------------|---------------------------------|
|
|------------------------------------|---------------------------------------------|---------------------------------------------------------------|
|
||||||
| **Runtime** | Systemd | Docker | Systemd |
|
| **Runtime** | Systemd | Docker |
|
||||||
| **Target OS** | Any Linux | Any Linux | Any Linux |
|
| **Target OS** | Any Linux | Any Linux |
|
||||||
| **Architecture** | amd64, arm64, i686 | amd64, arm64 | arm64, arm, 386, amd64 |
|
| **Architecture** | amd64, arm64, i686 | amd64, arm64 |
|
||||||
| **Additional software** | None | Traefik | Ngnix and Keepalived (optional) |
|
| **Additional software** | None | Traefik |
|
||||||
| **Automatic node connection** | ❌ | ✅ | ✅ |
|
| **Automatic node connection** | ❌ | ✅ |
|
||||||
| **Layout management** | ❌ | ✅ | ✅ |
|
| **Layout management** | ❌ | ✅ |
|
||||||
| **Manage buckets & keys** | ❌ | ✅ (basic) | ✅ |
|
| **Manage buckets & keys** | ❌ | ✅ (basic) |
|
||||||
| **Allow custom Garage config** | ✅ | ❌ | ❌ |
|
| **Allow custom Garage config** | ✅ | ❌ |
|
||||||
| **Facilitate Garage upgrades** | ✅ | ❌ | ✅ |
|
| **Facilitate Garage upgrades** | ✅ | ❌ |
|
||||||
| **Multiple instances on one host** | ✅ | ✅ | ❌ |
|
| **Multiple instances on one host** | ✅ | ✅ |
|
||||||
|
|
||||||
|
|
||||||
## zorun/ansible-role-garage
|
## zorun/ansible-role-garage
|
||||||
@@ -49,15 +49,3 @@ structured DNS names, etc).
|
|||||||
|
|
||||||
As a result, this role makes it easier to start with Garage on Ansible,
|
As a result, this role makes it easier to start with Garage on Ansible,
|
||||||
but is less flexible.
|
but is less flexible.
|
||||||
|
|
||||||
## eddster2309/ansible-role-garage
|
|
||||||
|
|
||||||
[Source code](https://github.com/eddster2309/ansible-role-garage), [Ansible galaxy](https://galaxy.ansible.com/ui/standalone/roles/eddster2309/garage/)
|
|
||||||
|
|
||||||
This role is a opinionated but customisable role using the official Garage
|
|
||||||
static binaries and only requires Systemd. As such it should work on any
|
|
||||||
Linux based host. It includes all the nesscary configuration to
|
|
||||||
automatically setup a clustered Garage deployment. Most Garage
|
|
||||||
configuration options are exposed through Ansible variables so while you
|
|
||||||
can't provide a custom config you can get very close. It can optionally
|
|
||||||
installed a HA nginx deployment with Keepalived.
|
|
||||||
|
|||||||
@@ -96,14 +96,14 @@ to store 2 TB of data in total.
|
|||||||
## Get a Docker image
|
## 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).
|
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. `v1.2.0`) and not the `latest` tag.
|
We encourage you to use a fixed tag (eg. `v1.1.0`) and not the `latest` tag.
|
||||||
For this example, we will use the latest published version at the time of the writing which is `v1.2.0` but it's up to you
|
For this example, we will use the latest published version at the time of the writing which is `v1.1.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).
|
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:
|
For example:
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo docker pull dxflrs/garage:v1.2.0
|
sudo docker pull dxflrs/garage:v1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deploying and configuring Garage
|
## Deploying and configuring Garage
|
||||||
@@ -171,7 +171,7 @@ docker run \
|
|||||||
-v /etc/garage.toml:/etc/garage.toml \
|
-v /etc/garage.toml:/etc/garage.toml \
|
||||||
-v /var/lib/garage/meta:/var/lib/garage/meta \
|
-v /var/lib/garage/meta:/var/lib/garage/meta \
|
||||||
-v /var/lib/garage/data:/var/lib/garage/data \
|
-v /var/lib/garage/data:/var/lib/garage/data \
|
||||||
dxflrs/garage:v1.2.0
|
dxflrs/garage:v1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
With this command line, Garage should be started automatically at each boot.
|
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"
|
version: "3"
|
||||||
services:
|
services:
|
||||||
garage:
|
garage:
|
||||||
image: dxflrs/garage:v1.2.0
|
image: dxflrs/garage:v1.1.0
|
||||||
network_mode: "host"
|
network_mode: "host"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ docker run \
|
|||||||
-v /path/to/garage.toml:/etc/garage.toml \
|
-v /path/to/garage.toml:/etc/garage.toml \
|
||||||
-v /path/to/garage/meta:/var/lib/garage/meta \
|
-v /path/to/garage/meta:/var/lib/garage/meta \
|
||||||
-v /path/to/garage/data:/var/lib/garage/data \
|
-v /path/to/garage/data:/var/lib/garage/data \
|
||||||
dxflrs/garage:v1.2.0
|
dxflrs/garage:v1.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Under Linux, you can substitute `--network host` for `-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903`
|
Under Linux, you can substitute `--network host` for `-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903`
|
||||||
@@ -182,12 +182,11 @@ ID Hostname Address Tag Zone Capacit
|
|||||||
## Creating a cluster layout
|
## Creating a cluster layout
|
||||||
|
|
||||||
Creating a cluster layout for a Garage deployment means informing Garage
|
Creating a cluster layout for a Garage deployment means informing Garage
|
||||||
of the disk space available on each node of the cluster, `-c`,
|
of the disk space available on each node of the cluster
|
||||||
as well as the name of the zone (e.g. datacenter), `-z`, each machine is located in.
|
as well as the zone (e.g. datacenter) each machine is located in.
|
||||||
|
|
||||||
For our test deployment, we are have only one node with zone named `dc1` and a
|
For our test deployment, we are using only one node. The way in which we configure
|
||||||
capacity of `1G`, though the capacity is ignored for a single node deployment
|
it does not matter, you can simply write:
|
||||||
and can be changed later when adding new nodes.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
garage layout assign -z dc1 -c 1G <node_id>
|
garage layout assign -z dc1 -c 1G <node_id>
|
||||||
|
|||||||
@@ -93,30 +93,30 @@ The following gives details about each available configuration option.
|
|||||||
|
|
||||||
[Environment variables](#env_variables).
|
[Environment variables](#env_variables).
|
||||||
|
|
||||||
Top-level configuration options, in alphabetical order:
|
Top-level configuration options:
|
||||||
[`allow_punycode`](#allow_punycode),
|
|
||||||
[`allow_world_readable_secrets`](#allow_world_readable_secrets),
|
[`allow_world_readable_secrets`](#allow_world_readable_secrets),
|
||||||
[`block_ram_buffer_max`](#block_ram_buffer_max),
|
[`block_ram_buffer_max`](#block_ram_buffer_max),
|
||||||
[`block_size`](#block_size),
|
[`block_size`](#block_size),
|
||||||
[`bootstrap_peers`](#bootstrap_peers),
|
[`bootstrap_peers`](#bootstrap_peers),
|
||||||
[`compression_level`](#compression_level),
|
[`compression_level`](#compression_level),
|
||||||
[`consistency_mode`](#consistency_mode),
|
|
||||||
[`data_dir`](#data_dir),
|
[`data_dir`](#data_dir),
|
||||||
[`data_fsync`](#data_fsync),
|
[`data_fsync`](#data_fsync),
|
||||||
[`db_engine`](#db_engine),
|
[`db_engine`](#db_engine),
|
||||||
[`disable_scrub`](#disable_scrub),
|
[`disable_scrub`](#disable_scrub),
|
||||||
|
[`use_local_tz`](#use_local_tz),
|
||||||
[`lmdb_map_size`](#lmdb_map_size),
|
[`lmdb_map_size`](#lmdb_map_size),
|
||||||
[`metadata_auto_snapshot_interval`](#metadata_auto_snapshot_interval),
|
[`metadata_auto_snapshot_interval`](#metadata_auto_snapshot_interval),
|
||||||
[`metadata_dir`](#metadata_dir),
|
[`metadata_dir`](#metadata_dir),
|
||||||
[`metadata_fsync`](#metadata_fsync),
|
[`metadata_fsync`](#metadata_fsync),
|
||||||
[`metadata_snapshots_dir`](#metadata_snapshots_dir),
|
[`metadata_snapshots_dir`](#metadata_snapshots_dir),
|
||||||
[`replication_factor`](#replication_factor),
|
[`replication_factor`](#replication_factor),
|
||||||
|
[`consistency_mode`](#consistency_mode),
|
||||||
[`rpc_bind_addr`](#rpc_bind_addr),
|
[`rpc_bind_addr`](#rpc_bind_addr),
|
||||||
[`rpc_bind_outgoing`](#rpc_bind_outgoing),
|
[`rpc_bind_outgoing`](#rpc_bind_outgoing),
|
||||||
[`rpc_public_addr`](#rpc_public_addr),
|
[`rpc_public_addr`](#rpc_public_addr),
|
||||||
[`rpc_public_addr_subnet`](#rpc_public_addr_subnet)
|
[`rpc_public_addr_subnet`](#rpc_public_addr_subnet)
|
||||||
[`rpc_secret`/`rpc_secret_file`](#rpc_secret),
|
[`rpc_secret`/`rpc_secret_file`](#rpc_secret).
|
||||||
[`use_local_tz`](#use_local_tz).
|
[`allow_punycode`](#allow_punycode).
|
||||||
|
|
||||||
The `[consul_discovery]` section:
|
The `[consul_discovery]` section:
|
||||||
[`api`](#consul_api),
|
[`api`](#consul_api),
|
||||||
@@ -153,17 +153,13 @@ The `[admin]` section:
|
|||||||
|
|
||||||
### Environment variables {#env_variables}
|
### Environment variables {#env_variables}
|
||||||
|
|
||||||
The following configuration parameters must be specified as environment variables,
|
The following configuration parameter must be specified as an environment
|
||||||
they do not exist in the configuration file:
|
variable, it does not exist in the configuration file:
|
||||||
|
|
||||||
- `GARAGE_LOG_TO_SYSLOG` (since `v0.9.4`): set this to `1` or `true` to make the
|
- `GARAGE_LOG_TO_SYSLOG` (since `v0.9.4`): set this to `1` or `true` to make the
|
||||||
Garage daemon send its logs to `syslog` (using the libc `syslog` function)
|
Garage daemon send its logs to `syslog` (using the libc `syslog` function)
|
||||||
instead of printing to stderr.
|
instead of printing to stderr.
|
||||||
|
|
||||||
- `GARAGE_LOG_TO_JOURNALD` (since `v1.2.0`): set this to `1` or `true` to make the
|
|
||||||
Garage daemon send its logs to `journald` (using the native protocol of `systemd-journald`)
|
|
||||||
instead of printing to stderr.
|
|
||||||
|
|
||||||
The following environment variables can be used to override the corresponding
|
The following environment variables can be used to override the corresponding
|
||||||
values in the configuration file:
|
values in the configuration file:
|
||||||
|
|
||||||
@@ -175,7 +171,7 @@ values in the configuration file:
|
|||||||
|
|
||||||
### Top-level configuration options
|
### Top-level configuration options
|
||||||
|
|
||||||
#### `replication_factor` (since `v1.0.0`) {#replication_factor}
|
#### `replication_factor` {#replication_factor}
|
||||||
|
|
||||||
The replication factor can be any positive integer smaller or equal the node count in your cluster.
|
The replication factor can be any positive integer smaller or equal the node count in your cluster.
|
||||||
The chosen replication factor has a big impact on the cluster's failure tolerancy and performance characteristics.
|
The chosen replication factor has a big impact on the cluster's failure tolerancy and performance characteristics.
|
||||||
@@ -223,7 +219,7 @@ is in progress. In theory, no data should be lost as rebalancing is a
|
|||||||
routine operation for Garage, although we cannot guarantee you that everything
|
routine operation for Garage, although we cannot guarantee you that everything
|
||||||
will go right in such an extreme scenario.
|
will go right in such an extreme scenario.
|
||||||
|
|
||||||
#### `consistency_mode` (since `v1.0.0`) {#consistency_mode}
|
#### `consistency_mode` {#consistency_mode}
|
||||||
|
|
||||||
The consistency mode setting determines the read and write behaviour of your cluster.
|
The consistency mode setting determines the read and write behaviour of your cluster.
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Feel free to open a PR to suggest fixes this table. Minio is missing because the
|
|||||||
- 2022-05-25 - Many Ceph S3 endpoints are not documented but implemented. Following a notification from the Ceph community, we added them.
|
- 2022-05-25 - Many Ceph S3 endpoints are not documented but implemented. Following a notification from the Ceph community, we added them.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## High-level features
|
## High-level features
|
||||||
|
|
||||||
| Feature | Garage | [Openstack Swift](https://docs.openstack.org/swift/latest/s3_compat.html) | [Ceph Object Gateway](https://docs.ceph.com/en/latest/radosgw/s3/) | [Riak CS](https://docs.riak.com/riak/cs/2.1.1/references/apis/storage/s3/index.html) | [OpenIO](https://docs.openio.io/latest/source/arch-design/s3_compliancy.html) |
|
| Feature | Garage | [Openstack Swift](https://docs.openstack.org/swift/latest/s3_compat.html) | [Ceph Object Gateway](https://docs.ceph.com/en/latest/radosgw/s3/) | [Riak CS](https://docs.riak.com/riak/cs/2.1.1/references/apis/storage/s3/index.html) | [OpenIO](https://docs.openio.io/latest/source/arch-design/s3_compliancy.html) |
|
||||||
@@ -33,7 +34,6 @@ Feel free to open a PR to suggest fixes this table. Minio is missing because the
|
|||||||
| [URL vhost-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access) URL (eg. `bucket.host.tld/key`) | ✅ Implemented | ❌| ✅| ✅ | ✅ |
|
| [URL vhost-style](https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html#virtual-hosted-style-access) URL (eg. `bucket.host.tld/key`) | ✅ Implemented | ❌| ✅| ✅ | ✅ |
|
||||||
| [Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html) | ✅ Implemented | ❌| ✅ | ✅ | ✅(❓) |
|
| [Presigned URLs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html) | ✅ Implemented | ❌| ✅ | ✅ | ✅(❓) |
|
||||||
| [SSE-C encryption](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html) | ✅ Implemented | ❓ | ✅ | ❌ | ✅ |
|
| [SSE-C encryption](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html) | ✅ Implemented | ❓ | ✅ | ❌ | ✅ |
|
||||||
| [Bucket versioning](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) | ❌ Missing | ✅ | ✅ | ❌ | ✅ |
|
|
||||||
|
|
||||||
*Note:* OpenIO does not says if it supports presigned URLs. Because it is part
|
*Note:* OpenIO does not says if it supports presigned URLs. Because it is part
|
||||||
of signature v4 and they claim they support it without additional precisions,
|
of signature v4 and they claim they support it without additional precisions,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Example response body:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"node": "b10c110e4e854e5aa3f4637681befac755154b20059ec163254ddbfae86b09df",
|
"node": "b10c110e4e854e5aa3f4637681befac755154b20059ec163254ddbfae86b09df",
|
||||||
"garageVersion": "v1.2.0",
|
"garageVersion": "v1.1.0",
|
||||||
"garageFeatures": [
|
"garageFeatures": [
|
||||||
"k2v",
|
"k2v",
|
||||||
"lmdb",
|
"lmdb",
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ let
|
|||||||
"metrics"
|
"metrics"
|
||||||
"telemetry-otlp"
|
"telemetry-otlp"
|
||||||
"syslog"
|
"syslog"
|
||||||
"journald"
|
|
||||||
]));
|
]));
|
||||||
|
|
||||||
featuresStr = lib.concatStringsSep "," rootFeatures;
|
featuresStr = lib.concatStringsSep "," rootFeatures;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: garage
|
name: garage
|
||||||
description: S3-compatible object store for small self-hosted geo-distributed deployments
|
description: S3-compatible object store for small self-hosted geo-distributed deployments
|
||||||
type: application
|
type: application
|
||||||
version: 0.7.1
|
version: 0.7.0
|
||||||
appVersion: "v1.2.0"
|
appVersion: "v1.1.0"
|
||||||
home: https://garagehq.deuxfleurs.fr/
|
home: https://garagehq.deuxfleurs.fr/
|
||||||
icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg
|
icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# garage
|
# garage
|
||||||
|
|
||||||
  
|
  
|
||||||
|
|
||||||
S3-compatible object store for small self-hosted geo-distributed deployments
|
S3-compatible object store for small self-hosted geo-distributed deployments
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_api_admin"
|
name = "garage_api_admin"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
@@ -419,11 +419,17 @@ pub async fn handle_update_bucket(
|
|||||||
|
|
||||||
if let Some(wa) = req.website_access {
|
if let Some(wa) = req.website_access {
|
||||||
if wa.enabled {
|
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()),
|
||||||
|
};
|
||||||
state.website_config.update(Some(WebsiteConfig {
|
state.website_config.update(Some(WebsiteConfig {
|
||||||
index_document: wa.index_document.ok_or_bad_request(
|
index_document: wa.index_document.ok_or_bad_request(
|
||||||
"Please specify indexDocument when enabling website access.",
|
"Please specify indexDocument when enabling website access.",
|
||||||
)?,
|
)?,
|
||||||
error_document: wa.error_document,
|
error_document: wa.error_document,
|
||||||
|
redirect_all,
|
||||||
|
routing_rules,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
if wa.index_document.is_some() || wa.error_document.is_some() {
|
if wa.index_document.is_some() || wa.error_document.is_some() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_api_common"
|
name = "garage_api_common"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_api_k2v"
|
name = "garage_api_k2v"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_api_s3"
|
name = "garage_api_s3"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
@@ -226,7 +226,6 @@ impl ApiHandler for S3ApiServer {
|
|||||||
Endpoint::DeleteBucket {} => handle_delete_bucket(ctx).await,
|
Endpoint::DeleteBucket {} => handle_delete_bucket(ctx).await,
|
||||||
Endpoint::GetBucketLocation {} => handle_get_bucket_location(ctx),
|
Endpoint::GetBucketLocation {} => handle_get_bucket_location(ctx),
|
||||||
Endpoint::GetBucketVersioning {} => handle_get_bucket_versioning(),
|
Endpoint::GetBucketVersioning {} => handle_get_bucket_versioning(),
|
||||||
Endpoint::GetBucketAcl {} => handle_get_bucket_acl(ctx),
|
|
||||||
Endpoint::ListObjects {
|
Endpoint::ListObjects {
|
||||||
delimiter,
|
delimiter,
|
||||||
encoding_type,
|
encoding_type,
|
||||||
|
|||||||
+1
-59
@@ -5,7 +5,7 @@ use hyper::{Request, Response, StatusCode};
|
|||||||
use garage_model::bucket_alias_table::*;
|
use garage_model::bucket_alias_table::*;
|
||||||
use garage_model::bucket_table::Bucket;
|
use garage_model::bucket_table::Bucket;
|
||||||
use garage_model::garage::Garage;
|
use garage_model::garage::Garage;
|
||||||
use garage_model::key_table::{Key, KeyParams};
|
use garage_model::key_table::Key;
|
||||||
use garage_model::permission::BucketKeyPerm;
|
use garage_model::permission::BucketKeyPerm;
|
||||||
use garage_table::util::*;
|
use garage_table::util::*;
|
||||||
use garage_util::crdt::*;
|
use garage_util::crdt::*;
|
||||||
@@ -44,55 +44,6 @@ pub fn handle_get_bucket_versioning() -> Result<Response<ResBody>, Error> {
|
|||||||
.body(string_body(xml))?)
|
.body(string_body(xml))?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_get_bucket_acl(ctx: ReqCtx) -> Result<Response<ResBody>, Error> {
|
|
||||||
let ReqCtx {
|
|
||||||
bucket_id, api_key, ..
|
|
||||||
} = ctx;
|
|
||||||
let key_p = api_key.params().ok_or_internal_error(
|
|
||||||
"Key should not be in deleted state at this point (in handle_get_bucket_acl)",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let mut grants: Vec<s3_xml::Grant> = vec![];
|
|
||||||
let kp = api_key.bucket_permissions(&bucket_id);
|
|
||||||
|
|
||||||
if kp.allow_owner {
|
|
||||||
grants.push(s3_xml::Grant {
|
|
||||||
grantee: create_grantee(&key_p, &api_key),
|
|
||||||
permission: s3_xml::Value("FULL_CONTROL".to_string()),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if kp.allow_read {
|
|
||||||
grants.push(s3_xml::Grant {
|
|
||||||
grantee: create_grantee(&key_p, &api_key),
|
|
||||||
permission: s3_xml::Value("READ".to_string()),
|
|
||||||
});
|
|
||||||
grants.push(s3_xml::Grant {
|
|
||||||
grantee: create_grantee(&key_p, &api_key),
|
|
||||||
permission: s3_xml::Value("READ_ACP".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if kp.allow_write {
|
|
||||||
grants.push(s3_xml::Grant {
|
|
||||||
grantee: create_grantee(&key_p, &api_key),
|
|
||||||
permission: s3_xml::Value("WRITE".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let access_control_policy = s3_xml::AccessControlPolicy {
|
|
||||||
xmlns: (),
|
|
||||||
owner: None,
|
|
||||||
acl: s3_xml::AccessControlList { entries: grants },
|
|
||||||
};
|
|
||||||
|
|
||||||
let xml = s3_xml::to_xml_with_header(&access_control_policy)?;
|
|
||||||
trace!("xml: {}", xml);
|
|
||||||
|
|
||||||
Ok(Response::builder()
|
|
||||||
.header("Content-Type", "application/xml")
|
|
||||||
.body(string_body(xml))?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_list_buckets(
|
pub async fn handle_list_buckets(
|
||||||
garage: &Garage,
|
garage: &Garage,
|
||||||
api_key: &Key,
|
api_key: &Key,
|
||||||
@@ -360,15 +311,6 @@ fn parse_create_bucket_xml(xml_bytes: &[u8]) -> Option<Option<String>> {
|
|||||||
Some(ret)
|
Some(ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_grantee(key_params: &KeyParams, api_key: &Key) -> s3_xml::Grantee {
|
|
||||||
s3_xml::Grantee {
|
|
||||||
xmlns_xsi: (),
|
|
||||||
typ: "CanonicalUser".to_string(),
|
|
||||||
display_name: Some(s3_xml::Value(key_params.name.get().to_string())),
|
|
||||||
id: Some(s3_xml::Value(api_key.key_id.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+28
-49
@@ -26,7 +26,7 @@ use garage_api_common::signature::checksum::*;
|
|||||||
use crate::api_server::{ReqBody, ResBody};
|
use crate::api_server::{ReqBody, ResBody};
|
||||||
use crate::encryption::EncryptionParams;
|
use crate::encryption::EncryptionParams;
|
||||||
use crate::error::*;
|
use crate::error::*;
|
||||||
use crate::get::{check_version_not_deleted, full_object_byte_stream, PreconditionHeaders};
|
use crate::get::{full_object_byte_stream, PreconditionHeaders};
|
||||||
use crate::multipart;
|
use crate::multipart;
|
||||||
use crate::put::{extract_metadata_headers, save_stream, ChecksumMode, SaveStreamResult};
|
use crate::put::{extract_metadata_headers, save_stream, ChecksumMode, SaveStreamResult};
|
||||||
use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION;
|
use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION;
|
||||||
@@ -237,7 +237,6 @@ async fn handle_copy_metaonly(
|
|||||||
.get(&source_version.uuid, &EmptyKey)
|
.get(&source_version.uuid, &EmptyKey)
|
||||||
.await?;
|
.await?;
|
||||||
let source_version = source_version.ok_or(Error::NoSuchKey)?;
|
let source_version = source_version.ok_or(Error::NoSuchKey)?;
|
||||||
check_version_not_deleted(&source_version)?;
|
|
||||||
|
|
||||||
// Write an "uploading" marker in Object table
|
// Write an "uploading" marker in Object table
|
||||||
// This holds a reference to the object in the Version table
|
// This holds a reference to the object in the Version table
|
||||||
@@ -429,7 +428,6 @@ pub async fn handle_upload_part_copy(
|
|||||||
.get(&source_object_version.uuid, &EmptyKey)
|
.get(&source_object_version.uuid, &EmptyKey)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(Error::NoSuchKey)?;
|
.ok_or(Error::NoSuchKey)?;
|
||||||
check_version_not_deleted(&source_version)?;
|
|
||||||
|
|
||||||
// We want to reuse blocks from the source version as much as possible.
|
// We want to reuse blocks from the source version as much as possible.
|
||||||
// However, we still need to get the data from these blocks
|
// However, we still need to get the data from these blocks
|
||||||
@@ -561,7 +559,6 @@ pub async fn handle_upload_part_copy(
|
|||||||
|
|
||||||
let mut current_offset = 0;
|
let mut current_offset = 0;
|
||||||
let mut next_block = defragmenter.next().await?;
|
let mut next_block = defragmenter.next().await?;
|
||||||
let mut blocks_to_dup = dest_version.clone();
|
|
||||||
|
|
||||||
// TODO this could be optimized similarly to read_and_put_blocks
|
// TODO this could be optimized similarly to read_and_put_blocks
|
||||||
// low priority because uploadpartcopy is rarely used
|
// low priority because uploadpartcopy is rarely used
|
||||||
@@ -591,7 +588,8 @@ pub async fn handle_upload_part_copy(
|
|||||||
.unwrap()?;
|
.unwrap()?;
|
||||||
checksummer = checksummer_updated;
|
checksummer = checksummer_updated;
|
||||||
|
|
||||||
let (version_block_key, version_block) = (
|
dest_version.blocks.clear();
|
||||||
|
dest_version.blocks.put(
|
||||||
VersionBlockKey {
|
VersionBlockKey {
|
||||||
part_number,
|
part_number,
|
||||||
offset: current_offset,
|
offset: current_offset,
|
||||||
@@ -603,56 +601,37 @@ pub async fn handle_upload_part_copy(
|
|||||||
);
|
);
|
||||||
current_offset += data_len;
|
current_offset += data_len;
|
||||||
|
|
||||||
let next = if let Some(final_data) = data_to_upload {
|
let block_ref = BlockRef {
|
||||||
dest_version.blocks.clear();
|
block: final_hash,
|
||||||
dest_version.blocks.put(version_block_key, version_block);
|
version: dest_version_id,
|
||||||
let block_ref = BlockRef {
|
deleted: false.into(),
|
||||||
block: final_hash,
|
|
||||||
version: dest_version_id,
|
|
||||||
deleted: false.into(),
|
|
||||||
};
|
|
||||||
let (_, _, _, next) = futures::try_join!(
|
|
||||||
// Thing 1: if the block is not exactly a block that existed before,
|
|
||||||
// we need to insert that data as a new block.
|
|
||||||
garage.block_manager.rpc_put_block(
|
|
||||||
final_hash,
|
|
||||||
final_data,
|
|
||||||
dest_encryption.is_encrypted(),
|
|
||||||
None
|
|
||||||
),
|
|
||||||
// Thing 2: we need to insert the block in the version
|
|
||||||
garage.version_table.insert(&dest_version),
|
|
||||||
// Thing 3: we need to add a block reference
|
|
||||||
garage.block_ref_table.insert(&block_ref),
|
|
||||||
// Thing 4: we need to read the next block
|
|
||||||
defragmenter.next(),
|
|
||||||
)?;
|
|
||||||
next
|
|
||||||
} else {
|
|
||||||
blocks_to_dup.blocks.put(version_block_key, version_block);
|
|
||||||
defragmenter.next().await?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (_, _, _, next) = futures::try_join!(
|
||||||
|
// Thing 1: if the block is not exactly a block that existed before,
|
||||||
|
// we need to insert that data as a new block.
|
||||||
|
async {
|
||||||
|
if let Some(final_data) = data_to_upload {
|
||||||
|
garage
|
||||||
|
.block_manager
|
||||||
|
.rpc_put_block(final_hash, final_data, dest_encryption.is_encrypted(), None)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Thing 2: we need to insert the block in the version
|
||||||
|
garage.version_table.insert(&dest_version),
|
||||||
|
// Thing 3: we need to add a block reference
|
||||||
|
garage.block_ref_table.insert(&block_ref),
|
||||||
|
// Thing 4: we need to read the next block
|
||||||
|
defragmenter.next(),
|
||||||
|
)?;
|
||||||
next_block = next;
|
next_block = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(current_offset, source_range.length);
|
assert_eq!(current_offset, source_range.length);
|
||||||
|
|
||||||
// Put the duplicated blocks into the version & block_refs tables
|
|
||||||
let block_refs_to_put = blocks_to_dup
|
|
||||||
.blocks
|
|
||||||
.items()
|
|
||||||
.iter()
|
|
||||||
.map(|b| BlockRef {
|
|
||||||
block: b.1.hash,
|
|
||||||
version: dest_version_id,
|
|
||||||
deleted: false.into(),
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
futures::try_join!(
|
|
||||||
garage.version_table.insert(&blocks_to_dup),
|
|
||||||
garage.block_ref_table.insert_many(&block_refs_to_put[..]),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let checksums = checksummer.finalize();
|
let checksums = checksummer.finalize();
|
||||||
let etag = dest_encryption.etag_from_md5(&checksums.md5);
|
let etag = dest_encryption.etag_from_md5(&checksums.md5);
|
||||||
let checksum = checksums.extract(dest_object_checksum_algorithm);
|
let checksum = checksums.extract(dest_object_checksum_algorithm);
|
||||||
|
|||||||
+2
-30
@@ -19,13 +19,12 @@ use garage_net::stream::ByteStream;
|
|||||||
use garage_rpc::rpc_helper::OrderTag;
|
use garage_rpc::rpc_helper::OrderTag;
|
||||||
use garage_table::EmptyKey;
|
use garage_table::EmptyKey;
|
||||||
use garage_util::data::*;
|
use garage_util::data::*;
|
||||||
use garage_util::error::{Error as UtilError, OkOrMessage};
|
use garage_util::error::OkOrMessage;
|
||||||
|
|
||||||
use garage_model::garage::Garage;
|
use garage_model::garage::Garage;
|
||||||
use garage_model::s3::object_table::*;
|
use garage_model::s3::object_table::*;
|
||||||
use garage_model::s3::version_table::*;
|
use garage_model::s3::version_table::*;
|
||||||
|
|
||||||
use garage_api_common::common_error::CommonError;
|
|
||||||
use garage_api_common::helpers::*;
|
use garage_api_common::helpers::*;
|
||||||
use garage_api_common::signature::checksum::{add_checksum_response_headers, X_AMZ_CHECKSUM_MODE};
|
use garage_api_common::signature::checksum::{add_checksum_response_headers, X_AMZ_CHECKSUM_MODE};
|
||||||
|
|
||||||
@@ -216,7 +215,6 @@ pub async fn handle_head_without_ctx(
|
|||||||
.get(&object_version.uuid, &EmptyKey)
|
.get(&object_version.uuid, &EmptyKey)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(Error::NoSuchKey)?;
|
.ok_or(Error::NoSuchKey)?;
|
||||||
check_version_not_deleted(&version)?;
|
|
||||||
|
|
||||||
let (part_offset, part_end) =
|
let (part_offset, part_end) =
|
||||||
calculate_part_bounds(&version, pn).ok_or(Error::InvalidPart)?;
|
calculate_part_bounds(&version, pn).ok_or(Error::InvalidPart)?;
|
||||||
@@ -367,21 +365,6 @@ pub async fn handle_get_without_ctx(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn check_version_not_deleted(version: &Version) -> Result<(), Error> {
|
|
||||||
if version.deleted.get() {
|
|
||||||
// the version was deleted between when the object_table was consulted
|
|
||||||
// and now, this could mean the object was deleted, or overriden.
|
|
||||||
// Rather than say the key doesn't exist, return a transient error
|
|
||||||
// to signal the client to try again.
|
|
||||||
return Err(CommonError::InternalError(UtilError::Message(
|
|
||||||
"conflict/inconsistency between object and version state, version is deleted"
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_get_full(
|
async fn handle_get_full(
|
||||||
garage: Arc<Garage>,
|
garage: Arc<Garage>,
|
||||||
version: &ObjectVersion,
|
version: &ObjectVersion,
|
||||||
@@ -448,7 +431,6 @@ pub fn full_object_byte_stream(
|
|||||||
.ok_or_message("channel closed")?;
|
.ok_or_message("channel closed")?;
|
||||||
|
|
||||||
let version = version_fut.await.unwrap()?.ok_or(Error::NoSuchKey)?;
|
let version = version_fut.await.unwrap()?.ok_or(Error::NoSuchKey)?;
|
||||||
check_version_not_deleted(&version)?;
|
|
||||||
for (i, (_, vb)) in version.blocks.items().iter().enumerate().skip(1) {
|
for (i, (_, vb)) in version.blocks.items().iter().enumerate().skip(1) {
|
||||||
let stream_block_i = encryption
|
let stream_block_i = encryption
|
||||||
.get_block(&garage, &vb.hash, Some(order_stream.order(i as u64)))
|
.get_block(&garage, &vb.hash, Some(order_stream.order(i as u64)))
|
||||||
@@ -464,14 +446,6 @@ pub fn full_object_byte_stream(
|
|||||||
{
|
{
|
||||||
Ok(()) => (),
|
Ok(()) => (),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// TODO i think this is a bad idea, we should log
|
|
||||||
// an error and stop there. If the error happens to
|
|
||||||
// be exactly the size of what hasn't been streamed
|
|
||||||
// yet, the client will see the request as a
|
|
||||||
// success
|
|
||||||
// instead truncating the output notify the client
|
|
||||||
// something happened with their download, so that
|
|
||||||
// they can retry it
|
|
||||||
let _ = tx.send(error_stream_item(e)).await;
|
let _ = tx.send(error_stream_item(e)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,7 +497,7 @@ async fn handle_get_range(
|
|||||||
.get(&version.uuid, &EmptyKey)
|
.get(&version.uuid, &EmptyKey)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(Error::NoSuchKey)?;
|
.ok_or(Error::NoSuchKey)?;
|
||||||
check_version_not_deleted(&version)?;
|
|
||||||
let body =
|
let body =
|
||||||
body_from_blocks_range(garage, encryption, version.blocks.items(), begin, end);
|
body_from_blocks_range(garage, encryption, version.blocks.items(), begin, end);
|
||||||
Ok(resp_builder.body(body)?)
|
Ok(resp_builder.body(body)?)
|
||||||
@@ -574,8 +548,6 @@ async fn handle_get_part(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(Error::NoSuchKey)?;
|
.ok_or(Error::NoSuchKey)?;
|
||||||
|
|
||||||
check_version_not_deleted(&version)?;
|
|
||||||
|
|
||||||
let (begin, end) =
|
let (begin, end) =
|
||||||
calculate_part_bounds(&version, part_number).ok_or(Error::InvalidPart)?;
|
calculate_part_bounds(&version, part_number).ok_or(Error::InvalidPart)?;
|
||||||
|
|
||||||
|
|||||||
+172
-54
@@ -3,7 +3,7 @@ use quick_xml::de::from_reader;
|
|||||||
use hyper::{header::HeaderName, Request, Response, StatusCode};
|
use hyper::{header::HeaderName, Request, Response, StatusCode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use garage_model::bucket_table::*;
|
use garage_model::bucket_table::{self, *};
|
||||||
|
|
||||||
use garage_api_common::helpers::*;
|
use garage_api_common::helpers::*;
|
||||||
|
|
||||||
@@ -26,7 +26,28 @@ pub async fn handle_get_website(ctx: ReqCtx) -> Result<Response<ResBody>, Error>
|
|||||||
suffix: Value(website.index_document.to_string()),
|
suffix: Value(website.index_document.to_string()),
|
||||||
}),
|
}),
|
||||||
redirect_all_requests_to: None,
|
redirect_all_requests_to: None,
|
||||||
routing_rules: None,
|
routing_rules: RoutingRules {
|
||||||
|
rules: website
|
||||||
|
.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),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
let xml = to_xml_with_header(&wc)?;
|
let xml = to_xml_with_header(&wc)?;
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
@@ -97,18 +118,28 @@ pub struct WebsiteConfiguration {
|
|||||||
pub index_document: Option<Suffix>,
|
pub index_document: Option<Suffix>,
|
||||||
#[serde(rename = "RedirectAllRequestsTo")]
|
#[serde(rename = "RedirectAllRequestsTo")]
|
||||||
pub redirect_all_requests_to: Option<Target>,
|
pub redirect_all_requests_to: Option<Target>,
|
||||||
#[serde(rename = "RoutingRules")]
|
#[serde(
|
||||||
pub routing_rules: Option<Vec<RoutingRule>>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct RoutingRule {
|
pub struct RoutingRule {
|
||||||
#[serde(rename = "RoutingRule")]
|
|
||||||
pub inner: RoutingRuleInner,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct RoutingRuleInner {
|
|
||||||
#[serde(rename = "Condition")]
|
#[serde(rename = "Condition")]
|
||||||
pub condition: Option<Condition>,
|
pub condition: Option<Condition>,
|
||||||
#[serde(rename = "Redirect")]
|
#[serde(rename = "Redirect")]
|
||||||
@@ -162,7 +193,7 @@ impl WebsiteConfiguration {
|
|||||||
if self.redirect_all_requests_to.is_some()
|
if self.redirect_all_requests_to.is_some()
|
||||||
&& (self.error_document.is_some()
|
&& (self.error_document.is_some()
|
||||||
|| self.index_document.is_some()
|
|| self.index_document.is_some()
|
||||||
|| self.routing_rules.is_some())
|
|| !self.routing_rules.is_empty())
|
||||||
{
|
{
|
||||||
return Err(Error::bad_request(
|
return Err(Error::bad_request(
|
||||||
"Bad XML: can't have RedirectAllRequestsTo and other fields",
|
"Bad XML: can't have RedirectAllRequestsTo and other fields",
|
||||||
@@ -177,10 +208,15 @@ impl WebsiteConfiguration {
|
|||||||
if let Some(ref rart) = self.redirect_all_requests_to {
|
if let Some(ref rart) = self.redirect_all_requests_to {
|
||||||
rart.validate()?;
|
rart.validate()?;
|
||||||
}
|
}
|
||||||
if let Some(ref rrs) = self.routing_rules {
|
for rr in &self.routing_rules.rules {
|
||||||
for rr in rrs {
|
rr.validate()?;
|
||||||
rr.inner.validate()?;
|
}
|
||||||
}
|
if self.routing_rules.rules.len() > 1000 {
|
||||||
|
// we will do linear scans, best to avoid overly long configuration. The
|
||||||
|
// limit was choosen arbitrarily
|
||||||
|
return Err(Error::bad_request(
|
||||||
|
"Bad XML: RoutingRules can't have more than 1000 child elements",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -189,11 +225,7 @@ impl WebsiteConfiguration {
|
|||||||
pub fn into_garage_website_config(self) -> Result<WebsiteConfig, Error> {
|
pub fn into_garage_website_config(self) -> Result<WebsiteConfig, Error> {
|
||||||
if self.redirect_all_requests_to.is_some() {
|
if self.redirect_all_requests_to.is_some() {
|
||||||
Err(Error::NotImplemented(
|
Err(Error::NotImplemented(
|
||||||
"S3 website redirects are not currently implemented in Garage.".into(),
|
"RedirectAllRequestsTo is not currently implemented in Garage, however its effect can be emulated using a single inconditional RoutingRule.".into(),
|
||||||
))
|
|
||||||
} else if self.routing_rules.map(|x| !x.is_empty()).unwrap_or(false) {
|
|
||||||
Err(Error::NotImplemented(
|
|
||||||
"S3 routing rules are not currently implemented in Garage.".into(),
|
|
||||||
))
|
))
|
||||||
} else {
|
} else {
|
||||||
Ok(WebsiteConfig {
|
Ok(WebsiteConfig {
|
||||||
@@ -202,6 +234,36 @@ impl WebsiteConfiguration {
|
|||||||
.map(|x| x.suffix.0)
|
.map(|x| x.suffix.0)
|
||||||
.unwrap_or_else(|| "index.html".to_string()),
|
.unwrap_or_else(|| "index.html".to_string()),
|
||||||
error_document: self.error_document.map(|x| x.key.0),
|
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
|
||||||
|
// missconfiguration (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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,37 +304,69 @@ impl Target {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoutingRuleInner {
|
impl RoutingRule {
|
||||||
pub fn validate(&self) -> Result<(), Error> {
|
pub fn validate(&self) -> Result<(), Error> {
|
||||||
let has_prefix = self
|
if let Some(condition) = &self.condition {
|
||||||
.condition
|
condition.validate()?;
|
||||||
.as_ref()
|
}
|
||||||
.and_then(|c| c.prefix.as_ref())
|
self.redirect.validate()
|
||||||
.is_some();
|
}
|
||||||
self.redirect.validate(has_prefix)
|
}
|
||||||
|
|
||||||
|
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 {
|
impl Redirect {
|
||||||
pub fn validate(&self, has_prefix: bool) -> Result<(), Error> {
|
pub fn validate(&self) -> Result<(), Error> {
|
||||||
if self.replace_prefix.is_some() {
|
if self.replace_prefix.is_some() && self.replace_full.is_some() {
|
||||||
if self.replace_full.is_some() {
|
return Err(Error::bad_request(
|
||||||
return Err(Error::bad_request(
|
"Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set",
|
||||||
"Bad XML: both ReplaceKeyPrefixWith and ReplaceKeyWith are set",
|
));
|
||||||
));
|
|
||||||
}
|
|
||||||
if !has_prefix {
|
|
||||||
return Err(Error::bad_request(
|
|
||||||
"Bad XML: ReplaceKeyPrefixWith is set, but KeyPrefixEquals isn't",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(ref protocol) = self.protocol {
|
if let Some(ref protocol) = self.protocol {
|
||||||
if protocol.0 != "http" && protocol.0 != "https" {
|
if protocol.0 != "http" && protocol.0 != "https" {
|
||||||
return Err(Error::bad_request("Bad XML: invalid protocol"));
|
return Err(Error::bad_request("Bad XML: invalid protocol"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO there are probably more invalid cases, but which ones?
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,6 +405,15 @@ mod tests {
|
|||||||
<ReplaceKeyWith>fullkey</ReplaceKeyWith>
|
<ReplaceKeyWith>fullkey</ReplaceKeyWith>
|
||||||
</Redirect>
|
</Redirect>
|
||||||
</RoutingRule>
|
</RoutingRule>
|
||||||
|
<RoutingRule>
|
||||||
|
<Condition>
|
||||||
|
<KeyPrefixEquals></KeyPrefixEquals>
|
||||||
|
</Condition>
|
||||||
|
<Redirect>
|
||||||
|
<HttpRedirectCode>404</HttpRedirectCode>
|
||||||
|
<ReplaceKeyWith>missing</ReplaceKeyWith>
|
||||||
|
</Redirect>
|
||||||
|
</RoutingRule>
|
||||||
</RoutingRules>
|
</RoutingRules>
|
||||||
</WebsiteConfiguration>"#;
|
</WebsiteConfiguration>"#;
|
||||||
let conf: WebsiteConfiguration = from_str(message).unwrap();
|
let conf: WebsiteConfiguration = from_str(message).unwrap();
|
||||||
@@ -326,21 +429,36 @@ mod tests {
|
|||||||
hostname: Value("garage.tld".to_owned()),
|
hostname: Value("garage.tld".to_owned()),
|
||||||
protocol: Some(Value("https".to_owned())),
|
protocol: Some(Value("https".to_owned())),
|
||||||
}),
|
}),
|
||||||
routing_rules: Some(vec![RoutingRule {
|
routing_rules: RoutingRules {
|
||||||
inner: RoutingRuleInner {
|
rules: vec![
|
||||||
condition: Some(Condition {
|
RoutingRule {
|
||||||
http_error_code: Some(IntValue(404)),
|
condition: Some(Condition {
|
||||||
prefix: Some(Value("prefix1".to_owned())),
|
http_error_code: Some(IntValue(404)),
|
||||||
}),
|
prefix: Some(Value("prefix1".to_owned())),
|
||||||
redirect: Redirect {
|
}),
|
||||||
hostname: Some(Value("gara.ge".to_owned())),
|
redirect: Redirect {
|
||||||
protocol: Some(Value("http".to_owned())),
|
hostname: Some(Value("gara.ge".to_owned())),
|
||||||
http_redirect_code: Some(IntValue(303)),
|
protocol: Some(Value("http".to_owned())),
|
||||||
replace_prefix: Some(Value("prefix2".to_owned())),
|
http_redirect_code: Some(IntValue(303)),
|
||||||
replace_full: Some(Value("fullkey".to_owned())),
|
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! {
|
assert_eq! {
|
||||||
ref_value,
|
ref_value,
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ 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/")
|
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)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub struct Value(#[serde(rename = "$value")] pub String);
|
pub struct Value(#[serde(rename = "$value")] pub String);
|
||||||
|
|
||||||
@@ -323,42 +319,6 @@ pub struct PostObject {
|
|||||||
pub etag: Value,
|
pub etag: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
|
||||||
pub struct Grantee {
|
|
||||||
#[serde(rename = "xmlns:xsi", serialize_with = "xmlns_xsi_tag")]
|
|
||||||
pub xmlns_xsi: (),
|
|
||||||
#[serde(rename = "xsi:type")]
|
|
||||||
pub typ: String,
|
|
||||||
#[serde(rename = "DisplayName")]
|
|
||||||
pub display_name: Option<Value>,
|
|
||||||
#[serde(rename = "ID")]
|
|
||||||
pub id: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
|
||||||
pub struct Grant {
|
|
||||||
#[serde(rename = "Grantee")]
|
|
||||||
pub grantee: Grantee,
|
|
||||||
#[serde(rename = "Permission")]
|
|
||||||
pub permission: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
|
||||||
pub struct AccessControlList {
|
|
||||||
#[serde(rename = "Grant")]
|
|
||||||
pub entries: Vec<Grant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
|
||||||
pub struct AccessControlPolicy {
|
|
||||||
#[serde(serialize_with = "xmlns_tag")]
|
|
||||||
pub xmlns: (),
|
|
||||||
#[serde(rename = "Owner")]
|
|
||||||
pub owner: Option<Owner>,
|
|
||||||
#[serde(rename = "AccessControlList")]
|
|
||||||
pub acl: AccessControlList,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -467,43 +427,6 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn get_bucket_acl_result() -> Result<(), ApiError> {
|
|
||||||
let grant = Grant {
|
|
||||||
grantee: Grantee {
|
|
||||||
xmlns_xsi: (),
|
|
||||||
typ: "CanonicalUser".to_string(),
|
|
||||||
display_name: Some(Value("owner_name".to_string())),
|
|
||||||
id: Some(Value("qsdfjklm".to_string())),
|
|
||||||
},
|
|
||||||
permission: Value("FULL_CONTROL".to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let get_bucket_acl = AccessControlPolicy {
|
|
||||||
xmlns: (),
|
|
||||||
owner: None,
|
|
||||||
acl: AccessControlList {
|
|
||||||
entries: vec![grant],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
to_xml_with_header(&get_bucket_acl)?,
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
|
||||||
<AccessControlPolicy xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
|
|
||||||
<AccessControlList>\
|
|
||||||
<Grant>\
|
|
||||||
<Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"CanonicalUser\">\
|
|
||||||
<DisplayName>owner_name</DisplayName>\
|
|
||||||
<ID>qsdfjklm</ID>\
|
|
||||||
</Grantee>\
|
|
||||||
<Permission>FULL_CONTROL</Permission>\
|
|
||||||
</Grant>\
|
|
||||||
</AccessControlList>\
|
|
||||||
</AccessControlPolicy>"
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delete_result() -> Result<(), ApiError> {
|
fn delete_result() -> Result<(), ApiError> {
|
||||||
let delete_result = DeleteResult {
|
let delete_result = DeleteResult {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_block"
|
name = "garage_block"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_db"
|
name = "garage_db"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage"
|
name = "garage"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
@@ -57,7 +57,6 @@ opentelemetry.workspace = true
|
|||||||
opentelemetry-prometheus = { workspace = true, optional = true }
|
opentelemetry-prometheus = { workspace = true, optional = true }
|
||||||
opentelemetry-otlp = { workspace = true, optional = true }
|
opentelemetry-otlp = { workspace = true, optional = true }
|
||||||
syslog-tracing = { workspace = true, optional = true }
|
syslog-tracing = { workspace = true, optional = true }
|
||||||
tracing-journald = { workspace = true, optional = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
garage_api_common.workspace = true
|
garage_api_common.workspace = true
|
||||||
@@ -102,8 +101,6 @@ metrics = [ "garage_api_admin/metrics", "opentelemetry-prometheus" ]
|
|||||||
telemetry-otlp = [ "opentelemetry-otlp" ]
|
telemetry-otlp = [ "opentelemetry-otlp" ]
|
||||||
# Logging to syslog
|
# Logging to syslog
|
||||||
syslog = [ "syslog-tracing" ]
|
syslog = [ "syslog-tracing" ]
|
||||||
# Logging to journald
|
|
||||||
journald = [ "tracing-journald" ]
|
|
||||||
|
|
||||||
# NOTE: bundled-libs and system-libs should be treat as mutually exclusive;
|
# NOTE: bundled-libs and system-libs should be treat as mutually exclusive;
|
||||||
# exactly one of them should be enabled.
|
# exactly one of them should be enabled.
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ impl AdminRpcHandler {
|
|||||||
let mut obj_dels = 0;
|
let mut obj_dels = 0;
|
||||||
let mut mpu_dels = 0;
|
let mut mpu_dels = 0;
|
||||||
let mut ver_dels = 0;
|
let mut ver_dels = 0;
|
||||||
let mut br_dels = 0;
|
|
||||||
|
|
||||||
for hash in blocks {
|
for hash in blocks {
|
||||||
let hash = hex::decode(hash).ok_or_bad_request("invalid hash")?;
|
let hash = hex::decode(hash).ok_or_bad_request("invalid hash")?;
|
||||||
@@ -132,19 +131,12 @@ impl AdminRpcHandler {
|
|||||||
ver_dels += 1;
|
ver_dels += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !br.deleted.get() {
|
|
||||||
let mut br = br;
|
|
||||||
br.deleted.set();
|
|
||||||
self.garage.block_ref_table.insert(&br).await?;
|
|
||||||
br_dels += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(AdminRpc::Ok(format!(
|
Ok(AdminRpc::Ok(format!(
|
||||||
"Purged {} blocks: marked {} block refs, {} versions, {} objects and {} multipart uploads as deleted",
|
"Purged {} blocks, {} versions, {} objects, {} multipart uploads",
|
||||||
blocks.len(),
|
blocks.len(),
|
||||||
br_dels,
|
|
||||||
ver_dels,
|
ver_dels,
|
||||||
obj_dels,
|
obj_dels,
|
||||||
mpu_dels,
|
mpu_dels,
|
||||||
|
|||||||
@@ -390,9 +390,15 @@ impl AdminRpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let website = if query.allow {
|
let website = if query.allow {
|
||||||
|
let (redirect_all, routing_rules) = match bucket_state.website_config.get() {
|
||||||
|
Some(wc) => (wc.redirect_all.clone(), wc.routing_rules.clone()),
|
||||||
|
None => (None, Vec::new()),
|
||||||
|
};
|
||||||
Some(WebsiteConfig {
|
Some(WebsiteConfig {
|
||||||
index_document: query.index_document.clone(),
|
index_document: query.index_document.clone(),
|
||||||
error_document: query.error_document.clone(),
|
error_document: query.error_document.clone(),
|
||||||
|
redirect_all,
|
||||||
|
routing_rules,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -208,43 +208,6 @@ fn init_logging(opt: &Opt) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if std::env::var("GARAGE_LOG_TO_JOURNALD")
|
|
||||||
.map(|x| x == "1" || x == "true")
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
#[cfg(feature = "journald")]
|
|
||||||
{
|
|
||||||
use tracing_journald::{Priority, PriorityMappings};
|
|
||||||
use tracing_subscriber::layer::SubscriberExt;
|
|
||||||
use tracing_subscriber::util::SubscriberInitExt;
|
|
||||||
|
|
||||||
let registry = tracing_subscriber::registry()
|
|
||||||
.with(tracing_subscriber::fmt::layer().with_writer(std::io::sink))
|
|
||||||
.with(env_filter);
|
|
||||||
match tracing_journald::layer() {
|
|
||||||
Ok(layer) => {
|
|
||||||
registry
|
|
||||||
.with(layer.with_priority_mappings(PriorityMappings {
|
|
||||||
info: Priority::Informational,
|
|
||||||
debug: Priority::Debug,
|
|
||||||
..PriorityMappings::new()
|
|
||||||
}))
|
|
||||||
.init();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Couldn't connect to journald: {}.", e);
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "journald"))]
|
|
||||||
{
|
|
||||||
eprintln!("Journald support is not enabled in this build.");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
.with_env_filter(env_filter)
|
.with_env_filter(env_filter)
|
||||||
|
|||||||
+489
-43
@@ -5,7 +5,10 @@ use crate::json_body;
|
|||||||
use assert_json_diff::assert_json_eq;
|
use assert_json_diff::assert_json_eq;
|
||||||
use aws_sdk_s3::{
|
use aws_sdk_s3::{
|
||||||
primitives::ByteStream,
|
primitives::ByteStream,
|
||||||
types::{CorsConfiguration, CorsRule, ErrorDocument, IndexDocument, WebsiteConfiguration},
|
types::{
|
||||||
|
Condition, CorsConfiguration, CorsRule, ErrorDocument, IndexDocument, Protocol, Redirect,
|
||||||
|
RoutingRule, WebsiteConfiguration,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use http::{Request, StatusCode};
|
use http::{Request, StatusCode};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
@@ -534,6 +537,491 @@ async fn test_website_check_domain() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_website_redirect_full_bucket() {
|
||||||
|
const BCKT_NAME: &str = "my-redirect-full";
|
||||||
|
let ctx = common::context();
|
||||||
|
let bucket = ctx.create_bucket(BCKT_NAME);
|
||||||
|
|
||||||
|
let conf = WebsiteConfiguration::builder()
|
||||||
|
.routing_rules(
|
||||||
|
RoutingRule::builder()
|
||||||
|
.condition(Condition::builder().key_prefix_equals("").build())
|
||||||
|
.redirect(
|
||||||
|
Redirect::builder()
|
||||||
|
.protocol(Protocol::Https)
|
||||||
|
.host_name("other.tld")
|
||||||
|
.replace_key_prefix_with("")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ctx.client
|
||||||
|
.put_bucket_website()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.website_configuration(conf)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
{
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri(format!("http://127.0.0.1:{}/my-path", ctx.garage.web_port))
|
||||||
|
.header("Host", format!("{}.web.garage", BCKT_NAME))
|
||||||
|
.body(Body::new(Bytes::new()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let client = Client::builder(TokioExecutor::new()).build_http();
|
||||||
|
let resp = client.request(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(hyper::header::LOCATION)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"https://other.tld/my-path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri(format!("http://127.0.0.1:{}/my-path/", ctx.garage.web_port))
|
||||||
|
.header("Host", format!("{}.web.garage", BCKT_NAME))
|
||||||
|
.body(Body::new(Bytes::new()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let client = Client::builder(TokioExecutor::new()).build_http();
|
||||||
|
let resp = client.request(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(hyper::header::LOCATION)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"https://other.tld/my-path/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let req = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri(format!("http://127.0.0.1:{}/", ctx.garage.web_port))
|
||||||
|
.header("Host", format!("{}.web.garage", BCKT_NAME))
|
||||||
|
.body(Body::new(Bytes::new()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let client = Client::builder(TokioExecutor::new()).build_http();
|
||||||
|
let resp = client.request(req).await.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(hyper::header::LOCATION)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"https://other.tld/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_website_redirect() {
|
||||||
|
const BCKT_NAME: &str = "my-redirect";
|
||||||
|
let ctx = common::context();
|
||||||
|
let bucket = ctx.create_bucket(BCKT_NAME);
|
||||||
|
|
||||||
|
ctx.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key("index.html")
|
||||||
|
.body(ByteStream::from_static(b"index"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ctx.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key("404.html")
|
||||||
|
.body(ByteStream::from_static(b"main 404"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ctx.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key("static-file")
|
||||||
|
.body(ByteStream::from_static(b"static file"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut conf = WebsiteConfiguration::builder()
|
||||||
|
.index_document(
|
||||||
|
IndexDocument::builder()
|
||||||
|
.suffix("home.html")
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.error_document(ErrorDocument::builder().key("404.html").build().unwrap());
|
||||||
|
|
||||||
|
for (prefix, condition) in [("unconditional", false), ("conditional", true)] {
|
||||||
|
let code = condition.then(|| "404".to_string());
|
||||||
|
conf = conf
|
||||||
|
// simple redirect
|
||||||
|
.routing_rules(
|
||||||
|
RoutingRule::builder()
|
||||||
|
.condition(
|
||||||
|
Condition::builder()
|
||||||
|
.set_http_error_code_returned_equals(code.clone())
|
||||||
|
.key_prefix_equals(format!("{prefix}/redirect-prefix/"))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.redirect(
|
||||||
|
Redirect::builder()
|
||||||
|
.http_redirect_code("302")
|
||||||
|
.replace_key_prefix_with("other-prefix/")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.routing_rules(
|
||||||
|
RoutingRule::builder()
|
||||||
|
.condition(
|
||||||
|
Condition::builder()
|
||||||
|
.set_http_error_code_returned_equals(code.clone())
|
||||||
|
.key_prefix_equals(format!("{prefix}/redirect-prefix-307/"))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.redirect(
|
||||||
|
Redirect::builder()
|
||||||
|
.http_redirect_code("307")
|
||||||
|
.replace_key_prefix_with("other-prefix/")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
// simple redirect
|
||||||
|
.routing_rules(
|
||||||
|
RoutingRule::builder()
|
||||||
|
.condition(
|
||||||
|
Condition::builder()
|
||||||
|
.set_http_error_code_returned_equals(code.clone())
|
||||||
|
.key_prefix_equals(format!("{prefix}/redirect-fixed/"))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.redirect(
|
||||||
|
Redirect::builder()
|
||||||
|
.http_redirect_code("302")
|
||||||
|
.replace_key_with("fixed_key")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
// stream other file
|
||||||
|
.routing_rules(
|
||||||
|
RoutingRule::builder()
|
||||||
|
.condition(
|
||||||
|
Condition::builder()
|
||||||
|
.set_http_error_code_returned_equals(code.clone())
|
||||||
|
.key_prefix_equals(format!("{prefix}/stream-fixed/"))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.redirect(
|
||||||
|
Redirect::builder()
|
||||||
|
.http_redirect_code("200")
|
||||||
|
.replace_key_with("static-file")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
// stream other file as error
|
||||||
|
.routing_rules(
|
||||||
|
RoutingRule::builder()
|
||||||
|
.condition(
|
||||||
|
Condition::builder()
|
||||||
|
.set_http_error_code_returned_equals(code.clone())
|
||||||
|
.key_prefix_equals(format!("{prefix}/stream-404/"))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.redirect(
|
||||||
|
Redirect::builder()
|
||||||
|
.http_redirect_code("404")
|
||||||
|
.replace_key_with("static-file")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
// fail to stream other file
|
||||||
|
.routing_rules(
|
||||||
|
RoutingRule::builder()
|
||||||
|
.condition(
|
||||||
|
Condition::builder()
|
||||||
|
.set_http_error_code_returned_equals(code.clone())
|
||||||
|
.key_prefix_equals(format!("{prefix}/stream-missing/"))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.redirect(
|
||||||
|
Redirect::builder()
|
||||||
|
.http_redirect_code("200")
|
||||||
|
.replace_key_with("missing-file")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let conf = conf.build();
|
||||||
|
|
||||||
|
ctx.client
|
||||||
|
.put_bucket_website()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.website_configuration(conf.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let stored_cfg = ctx
|
||||||
|
.client
|
||||||
|
.get_bucket_website()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored_cfg.index_document, conf.index_document);
|
||||||
|
assert_eq!(stored_cfg.error_document, conf.error_document);
|
||||||
|
assert_eq!(stored_cfg.routing_rules, conf.routing_rules);
|
||||||
|
|
||||||
|
let req = |path| {
|
||||||
|
Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri(format!(
|
||||||
|
"http://127.0.0.1:{}/{}/path",
|
||||||
|
ctx.garage.web_port, path
|
||||||
|
))
|
||||||
|
.header("Host", format!("{}.web.garage", BCKT_NAME))
|
||||||
|
.body(Body::new(Bytes::new()))
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
test_redirect_helper("unconditional", true, &req).await;
|
||||||
|
test_redirect_helper("conditional", true, &req).await;
|
||||||
|
for prefix in ["unconditional", "conditional"] {
|
||||||
|
for rule_path in [
|
||||||
|
"redirect-prefix",
|
||||||
|
"redirect-prefix-307",
|
||||||
|
"redirect-fixed",
|
||||||
|
"stream-fixed",
|
||||||
|
"stream-404",
|
||||||
|
"stream-missing",
|
||||||
|
] {
|
||||||
|
ctx.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(format!("{prefix}/{rule_path}/path"))
|
||||||
|
.body(ByteStream::from_static(b"i exist"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
test_redirect_helper("unconditional", true, &req).await;
|
||||||
|
test_redirect_helper("conditional", false, &req).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_redirect_helper(
|
||||||
|
prefix: &str,
|
||||||
|
should_see_redirect: bool,
|
||||||
|
req: impl Fn(String) -> Request<http_body_util::Full<Bytes>>,
|
||||||
|
) {
|
||||||
|
use http::header;
|
||||||
|
let client = Client::builder(TokioExecutor::new()).build_http();
|
||||||
|
let expected_body = b"i exist".as_ref();
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.request(req(format!("{prefix}/redirect-prefix")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if should_see_redirect {
|
||||||
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(header::LOCATION)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"/other-prefix/path"
|
||||||
|
);
|
||||||
|
assert!(resp
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_bytes()
|
||||||
|
.is_empty());
|
||||||
|
} else {
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||||
|
expected_body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.request(req(format!("{prefix}/redirect-prefix-307")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if should_see_redirect {
|
||||||
|
assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(header::LOCATION)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"/other-prefix/path"
|
||||||
|
);
|
||||||
|
assert!(resp
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_bytes()
|
||||||
|
.is_empty());
|
||||||
|
} else {
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||||
|
expected_body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.request(req(format!("{prefix}/redirect-fixed")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if should_see_redirect {
|
||||||
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers()
|
||||||
|
.get(header::LOCATION)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"/fixed_key"
|
||||||
|
);
|
||||||
|
assert!(resp
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_bytes()
|
||||||
|
.is_empty());
|
||||||
|
} else {
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||||
|
expected_body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let resp = client
|
||||||
|
.request(req(format!("{prefix}/stream-fixed")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if should_see_redirect {
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||||
|
b"static file".as_ref(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||||
|
expected_body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let resp = client
|
||||||
|
.request(req(format!("{prefix}/stream-404")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if should_see_redirect {
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||||
|
b"static file".as_ref(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||||
|
expected_body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let resp = client
|
||||||
|
.request(req(format!("{prefix}/stream-404")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if should_see_redirect {
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||||
|
b"static file".as_ref(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||||
|
expected_body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_website_invalid_redirect() {
|
||||||
|
const BCKT_NAME: &str = "my-invalid-redirect";
|
||||||
|
let ctx = common::context();
|
||||||
|
let bucket = ctx.create_bucket(BCKT_NAME);
|
||||||
|
|
||||||
|
let conf = WebsiteConfiguration::builder()
|
||||||
|
.routing_rules(
|
||||||
|
RoutingRule::builder()
|
||||||
|
.condition(Condition::builder().key_prefix_equals("").build())
|
||||||
|
.redirect(
|
||||||
|
Redirect::builder()
|
||||||
|
.protocol(Protocol::Https)
|
||||||
|
.host_name("other.tld")
|
||||||
|
.replace_key_prefix_with("")
|
||||||
|
// we don't allow 200 with hostname
|
||||||
|
.http_redirect_code("200")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
ctx.client
|
||||||
|
.put_bucket_website()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.website_configuration(conf)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_website_puny() {
|
async fn test_website_puny() {
|
||||||
const BCKT_NAME: &str = "xn--pda.eu";
|
const BCKT_NAME: &str = "xn--pda.eu";
|
||||||
@@ -606,45 +1094,3 @@ async fn test_website_puny() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_website_object_not_found() {
|
|
||||||
const BCKT_NAME: &str = "not-found";
|
|
||||||
let ctx = common::context();
|
|
||||||
let _bucket = ctx.create_bucket(BCKT_NAME);
|
|
||||||
|
|
||||||
let client = Client::builder(TokioExecutor::new()).build_http();
|
|
||||||
|
|
||||||
let req = |suffix| {
|
|
||||||
Request::builder()
|
|
||||||
.method("GET")
|
|
||||||
.uri(format!("http://127.0.0.1:{}/", ctx.garage.web_port))
|
|
||||||
.header("Host", format!("{}{}", BCKT_NAME, suffix))
|
|
||||||
.body(Body::new(Bytes::new()))
|
|
||||||
.unwrap()
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.garage
|
|
||||||
.command()
|
|
||||||
.args(["bucket", "website", "--allow", BCKT_NAME])
|
|
||||||
.quiet()
|
|
||||||
.expect_success_status("Could not allow website on bucket");
|
|
||||||
|
|
||||||
let resp = client.request(req("")).await.unwrap();
|
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
||||||
// the error we return by default are *not* xml
|
|
||||||
assert_eq!(
|
|
||||||
resp.headers().get(http::header::CONTENT_TYPE).unwrap(),
|
|
||||||
"text/html; charset=utf-8"
|
|
||||||
);
|
|
||||||
let result = String::from_utf8(
|
|
||||||
resp.into_body()
|
|
||||||
.collect()
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.to_bytes()
|
|
||||||
.to_vec(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(result.contains("not found"));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_model"
|
name = "garage_model"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
+116
-1
@@ -119,7 +119,122 @@ mod v08 {
|
|||||||
impl garage_util::migrate::InitialFormat for Bucket {}
|
impl garage_util::migrate::InitialFormat for Bucket {}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use v08::*;
|
mod v2 {
|
||||||
|
use crate::permission::BucketKeyPerm;
|
||||||
|
use garage_util::crdt;
|
||||||
|
use garage_util::data::Uuid;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::v08;
|
||||||
|
|
||||||
|
pub use v08::{BucketQuotas, CorsRule, LifecycleExpiration, LifecycleFilter, LifecycleRule};
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Bucket {
|
||||||
|
/// ID of the bucket
|
||||||
|
pub id: Uuid,
|
||||||
|
/// State, and configuration if not deleted, of the bucket
|
||||||
|
pub state: crdt::Deletable<BucketParams>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for a bucket
|
||||||
|
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BucketParams {
|
||||||
|
/// Bucket's creation date
|
||||||
|
pub creation_date: u64,
|
||||||
|
/// Map of key with access to the bucket, and what kind of access they give
|
||||||
|
pub authorized_keys: crdt::Map<String, BucketKeyPerm>,
|
||||||
|
|
||||||
|
/// Map of aliases that are or have been given to this bucket
|
||||||
|
/// in the global namespace
|
||||||
|
/// (not authoritative: this is just used as an indication to
|
||||||
|
/// map back to aliases when doing ListBuckets)
|
||||||
|
pub aliases: crdt::LwwMap<String, bool>,
|
||||||
|
/// Map of aliases that are or have been given to this bucket
|
||||||
|
/// in namespaces local to keys
|
||||||
|
/// key = (access key id, alias name)
|
||||||
|
pub local_aliases: crdt::LwwMap<(String, String), bool>,
|
||||||
|
|
||||||
|
/// Whether this bucket is allowed for website access
|
||||||
|
/// (under all of its global alias names),
|
||||||
|
/// and if so, the website configuration XML document
|
||||||
|
pub website_config: crdt::Lww<Option<WebsiteConfig>>,
|
||||||
|
/// CORS rules
|
||||||
|
pub cors_config: crdt::Lww<Option<Vec<CorsRule>>>,
|
||||||
|
/// Lifecycle configuration
|
||||||
|
pub lifecycle_config: crdt::Lww<Option<Vec<LifecycleRule>>>,
|
||||||
|
/// Bucket quotas
|
||||||
|
pub quotas: crdt::Lww<BucketQuotas>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct WebsiteConfig {
|
||||||
|
pub index_document: String,
|
||||||
|
pub error_document: Option<String>,
|
||||||
|
// this field is currently unused, but present so adding it in the future doesn't
|
||||||
|
// need a new migration
|
||||||
|
pub redirect_all: Option<RedirectAll>,
|
||||||
|
pub routing_rules: Vec<RoutingRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RedirectAll {
|
||||||
|
pub hostname: String,
|
||||||
|
pub protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RoutingRule {
|
||||||
|
pub condition: Option<RedirectCondition>,
|
||||||
|
pub redirect: Redirect,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RedirectCondition {
|
||||||
|
pub http_error_code: Option<u16>,
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Redirect {
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
pub http_redirect_code: u16,
|
||||||
|
pub protocol: Option<String>,
|
||||||
|
pub replace_key_prefix: Option<String>,
|
||||||
|
pub replace_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl garage_util::migrate::Migrate for Bucket {
|
||||||
|
const VERSION_MARKER: &'static [u8] = b"G2bkt";
|
||||||
|
|
||||||
|
type Previous = v08::Bucket;
|
||||||
|
|
||||||
|
fn migrate(old: v08::Bucket) -> Bucket {
|
||||||
|
Bucket {
|
||||||
|
id: old.id,
|
||||||
|
state: old.state.map(|x| BucketParams {
|
||||||
|
creation_date: x.creation_date,
|
||||||
|
authorized_keys: x.authorized_keys,
|
||||||
|
aliases: x.aliases,
|
||||||
|
local_aliases: x.local_aliases,
|
||||||
|
website_config: x.website_config.map(|wc_opt| {
|
||||||
|
wc_opt.map(|wc| WebsiteConfig {
|
||||||
|
index_document: wc.index_document,
|
||||||
|
error_document: wc.error_document,
|
||||||
|
redirect_all: None,
|
||||||
|
routing_rules: vec![],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
cors_config: x.cors_config,
|
||||||
|
lifecycle_config: x.lifecycle_config,
|
||||||
|
quotas: x.quotas,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use v2::*;
|
||||||
|
|
||||||
impl AutoCrdt for BucketQuotas {
|
impl AutoCrdt for BucketQuotas {
|
||||||
const WARN_IF_DIFFERENT: bool = true;
|
const WARN_IF_DIFFERENT: bool = true;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_net"
|
name = "garage_net"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_rpc"
|
name = "garage_rpc"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_table"
|
name = "garage_table"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
@@ -43,13 +43,10 @@ impl TableReplication for TableFullReplication {
|
|||||||
}
|
}
|
||||||
fn write_quorum(&self) -> usize {
|
fn write_quorum(&self) -> usize {
|
||||||
let nmembers = self.system.cluster_layout().current().all_nodes().len();
|
let nmembers = self.system.cluster_layout().current().all_nodes().len();
|
||||||
|
if nmembers < 3 {
|
||||||
let max_faults = if nmembers > 1 { 1 } else { 0 };
|
|
||||||
|
|
||||||
if nmembers > max_faults {
|
|
||||||
nmembers - max_faults
|
|
||||||
} else {
|
|
||||||
1
|
1
|
||||||
|
} else {
|
||||||
|
nmembers.div_euclid(2) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_util"
|
name = "garage_util"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ pub enum Deletable<T> {
|
|||||||
Deleted,
|
Deleted,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<T> Deletable<T> {
|
||||||
|
/// Map value, used for migrations
|
||||||
|
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Deletable<U> {
|
||||||
|
match self {
|
||||||
|
Self::Present(x) => Deletable::<U>::Present(f(x)),
|
||||||
|
Self::Deleted => Deletable::<U>::Deleted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<T: Crdt> Deletable<T> {
|
impl<T: Crdt> Deletable<T> {
|
||||||
/// Create a new deletable object that isn't deleted
|
/// Create a new deletable object that isn't deleted
|
||||||
pub fn present(v: T) -> Self {
|
pub fn present(v: T) -> Self {
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ pub struct Lww<T> {
|
|||||||
v: T,
|
v: T,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<T> Lww<T> {
|
||||||
|
/// Map value, used for migrations
|
||||||
|
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Lww<U> {
|
||||||
|
Lww::<U> {
|
||||||
|
ts: self.ts,
|
||||||
|
v: f(self.v),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<T> Lww<T>
|
impl<T> Lww<T>
|
||||||
where
|
where
|
||||||
T: Crdt,
|
T: Crdt,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "garage_web"
|
name = "garage_web"
|
||||||
version = "1.2.0"
|
version = "1.1.0"
|
||||||
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"]
|
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
+304
-94
@@ -25,12 +25,14 @@ use garage_api_common::cors::{
|
|||||||
};
|
};
|
||||||
use garage_api_common::generic_server::{server_loop, UnixListenerOn};
|
use garage_api_common::generic_server::{server_loop, UnixListenerOn};
|
||||||
use garage_api_common::helpers::*;
|
use garage_api_common::helpers::*;
|
||||||
|
use garage_api_s3::api_server::ResBody;
|
||||||
use garage_api_s3::error::{
|
use garage_api_s3::error::{
|
||||||
CommonErrorDerivative, Error as ApiError, OkOrBadRequest, OkOrInternalError,
|
CommonErrorDerivative, Error as ApiError, OkOrBadRequest, OkOrInternalError,
|
||||||
};
|
};
|
||||||
use garage_api_s3::get::{handle_get_without_ctx, handle_head_without_ctx};
|
use garage_api_s3::get::{handle_get_without_ctx, handle_head_without_ctx};
|
||||||
use garage_api_s3::website::X_AMZ_WEBSITE_REDIRECT_LOCATION;
|
use garage_api_s3::website::X_AMZ_WEBSITE_REDIRECT_LOCATION;
|
||||||
|
|
||||||
|
use garage_model::bucket_table::{self, RoutingRule};
|
||||||
use garage_model::garage::Garage;
|
use garage_model::garage::Garage;
|
||||||
|
|
||||||
use garage_table::*;
|
use garage_table::*;
|
||||||
@@ -260,45 +262,71 @@ impl WebServer {
|
|||||||
// Get path
|
// Get path
|
||||||
let path = req.uri().path().to_string();
|
let path = req.uri().path().to_string();
|
||||||
let index = &website_config.index_document;
|
let index = &website_config.index_document;
|
||||||
let (key, may_redirect) = path_to_keys(&path, index)?;
|
let routing_result = path_to_keys(&path, index, &website_config.routing_rules)?;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Selected bucket: \"{}\" {:?}, target key: \"{}\", may redirect to: {:?}",
|
"Selected bucket: \"{}\" {:?}, routing to {:?}",
|
||||||
bucket_name, bucket_id, key, may_redirect
|
bucket_name, bucket_id, routing_result,
|
||||||
);
|
);
|
||||||
|
|
||||||
let ret_doc = match *req.method() {
|
let ret_doc = match (req.method(), routing_result.main_target()) {
|
||||||
Method::OPTIONS => handle_options_for_bucket(req, &bucket_params)
|
(&Method::OPTIONS, _) => handle_options_for_bucket(req, &bucket_params)
|
||||||
.map_err(ApiError::from)
|
.map_err(ApiError::from)
|
||||||
.map(|res| res.map(|_empty_body: EmptyBody| empty_body())),
|
.map(|res| res.map(|_empty_body: EmptyBody| empty_body())),
|
||||||
Method::HEAD => {
|
(_, Err((url, code))) => Ok(Response::builder()
|
||||||
handle_head_without_ctx(self.garage.clone(), req, bucket_id, &key, None).await
|
.status(code)
|
||||||
|
.header("Location", url)
|
||||||
|
.body(empty_body())
|
||||||
|
.unwrap()),
|
||||||
|
(_, Ok((key, code))) => {
|
||||||
|
handle_inner(self.garage.clone(), req, bucket_id, key, code).await
|
||||||
}
|
}
|
||||||
Method::GET => {
|
};
|
||||||
handle_get_without_ctx(
|
|
||||||
|
// Try handling errors if bucket configuration provided fallbacks
|
||||||
|
let ret_doc_with_redir = match (&ret_doc, &routing_result) {
|
||||||
|
(
|
||||||
|
Err(ApiError::NoSuchKey),
|
||||||
|
RoutingResult::LoadOrRedirect {
|
||||||
|
redirect_if_exists,
|
||||||
|
redirect_url,
|
||||||
|
redirect_code,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
let redirect = if let Some(redirect_key) = redirect_if_exists {
|
||||||
|
self.check_key_exists(bucket_id, redirect_key.as_str())
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
if redirect {
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(redirect_code)
|
||||||
|
.header("Location", redirect_url)
|
||||||
|
.body(empty_body())
|
||||||
|
.unwrap())
|
||||||
|
} else {
|
||||||
|
ret_doc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(
|
||||||
|
Err(ApiError::NoSuchKey),
|
||||||
|
RoutingResult::LoadOrAlternativeError {
|
||||||
|
redirect_key,
|
||||||
|
redirect_code,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
handle_inner(
|
||||||
self.garage.clone(),
|
self.garage.clone(),
|
||||||
req,
|
req,
|
||||||
bucket_id,
|
bucket_id,
|
||||||
&key,
|
redirect_key,
|
||||||
None,
|
*redirect_code,
|
||||||
Default::default(),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
_ => Err(ApiError::bad_request("HTTP method not supported")),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try implicit redirect on error
|
|
||||||
let ret_doc_with_redir = match (&ret_doc, may_redirect) {
|
|
||||||
(Err(ApiError::NoSuchKey), ImplicitRedirect::To { key, url })
|
|
||||||
if self.check_key_exists(bucket_id, key.as_str()).await? =>
|
|
||||||
{
|
|
||||||
Ok(Response::builder()
|
|
||||||
.status(StatusCode::FOUND)
|
|
||||||
.header(LOCATION, url)
|
|
||||||
.body(empty_body())
|
|
||||||
.unwrap())
|
|
||||||
}
|
|
||||||
(Ok(ret), _) if ret.headers().contains_key(X_AMZ_WEBSITE_REDIRECT_LOCATION) => {
|
(Ok(ret), _) if ret.headers().contains_key(X_AMZ_WEBSITE_REDIRECT_LOCATION) => {
|
||||||
let redirect_location = ret.headers().get(X_AMZ_WEBSITE_REDIRECT_LOCATION).unwrap();
|
let redirect_location = ret.headers().get(X_AMZ_WEBSITE_REDIRECT_LOCATION).unwrap();
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
@@ -332,17 +360,17 @@ impl WebServer {
|
|||||||
// We want to return the error document
|
// We want to return the error document
|
||||||
// Create a fake HTTP request with path = the error document
|
// Create a fake HTTP request with path = the error document
|
||||||
let req2 = Request::builder()
|
let req2 = Request::builder()
|
||||||
|
.method("GET")
|
||||||
.uri(format!("http://{}/{}", host, &error_document))
|
.uri(format!("http://{}/{}", host, &error_document))
|
||||||
.body(())
|
.body(())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
match handle_get_without_ctx(
|
match handle_inner(
|
||||||
self.garage.clone(),
|
self.garage.clone(),
|
||||||
&req2,
|
&req2,
|
||||||
bucket_id,
|
bucket_id,
|
||||||
&error_document,
|
&error_document,
|
||||||
None,
|
error.http_status_code(),
|
||||||
Default::default(),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -357,8 +385,6 @@ impl WebServer {
|
|||||||
error
|
error
|
||||||
);
|
);
|
||||||
|
|
||||||
*error_doc.status_mut() = error.http_status_code();
|
|
||||||
|
|
||||||
// Preserve error message in a special header
|
// Preserve error message in a special header
|
||||||
for error_line in error.to_string().split('\n') {
|
for error_line in error.to_string().split('\n') {
|
||||||
if let Ok(v) = HeaderValue::from_bytes(error_line.as_bytes()) {
|
if let Ok(v) = HeaderValue::from_bytes(error_line.as_bytes()) {
|
||||||
@@ -389,6 +415,52 @@ impl WebServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_inner(
|
||||||
|
garage: Arc<Garage>,
|
||||||
|
req: &Request<()>,
|
||||||
|
bucket_id: Uuid,
|
||||||
|
key: &str,
|
||||||
|
status_code: StatusCode,
|
||||||
|
) -> Result<Response<ResBody>, ApiError> {
|
||||||
|
if status_code != StatusCode::OK {
|
||||||
|
// If we are returning an error document, discard all headers from
|
||||||
|
// the original request that would have influenced the result:
|
||||||
|
// - Range header, we don't want to return a subrange of the error document
|
||||||
|
// - Caching directives such as If-None-Match, etc, which are not relevant
|
||||||
|
let cleaned_req = Request::builder().uri(req.uri()).body(()).unwrap();
|
||||||
|
|
||||||
|
let mut ret = match req.method() {
|
||||||
|
&Method::HEAD => {
|
||||||
|
handle_head_without_ctx(garage, &cleaned_req, bucket_id, key, None).await?
|
||||||
|
}
|
||||||
|
&Method::GET => {
|
||||||
|
handle_get_without_ctx(
|
||||||
|
garage,
|
||||||
|
&cleaned_req,
|
||||||
|
bucket_id,
|
||||||
|
key,
|
||||||
|
None,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
_ => return Err(ApiError::bad_request("HTTP method not supported")),
|
||||||
|
};
|
||||||
|
|
||||||
|
*ret.status_mut() = status_code;
|
||||||
|
|
||||||
|
Ok(ret)
|
||||||
|
} else {
|
||||||
|
match req.method() {
|
||||||
|
&Method::HEAD => handle_head_without_ctx(garage, req, bucket_id, key, None).await,
|
||||||
|
&Method::GET => {
|
||||||
|
handle_get_without_ctx(garage, req, bucket_id, key, None, Default::default()).await
|
||||||
|
}
|
||||||
|
_ => Err(ApiError::bad_request("HTTP method not supported")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn error_to_res(e: Error) -> Response<BoxBody<Error>> {
|
fn error_to_res(e: Error) -> Response<BoxBody<Error>> {
|
||||||
// If we are here, it is either that:
|
// If we are here, it is either that:
|
||||||
// - there was an error before trying to get the requested URL
|
// - there was an error before trying to get the requested URL
|
||||||
@@ -397,37 +469,52 @@ fn error_to_res(e: Error) -> Response<BoxBody<Error>> {
|
|||||||
// was a HEAD request or we couldn't get the error document)
|
// was a HEAD request or we couldn't get the error document)
|
||||||
// We do NOT enter this code path when returning the bucket's
|
// We do NOT enter this code path when returning the bucket's
|
||||||
// error document (this is handled in serve_file)
|
// error document (this is handled in serve_file)
|
||||||
let mut body_str = format!(
|
let body = string_body(format!("{}\n", e));
|
||||||
r"<title>{http_code} {code_text}</title>
|
let mut http_error = Response::new(body);
|
||||||
<h1>{http_code} {code_text}</h1>",
|
|
||||||
http_code = e.http_status_code().as_u16(),
|
|
||||||
code_text = e.http_status_code().canonical_reason().unwrap_or("Unknown"),
|
|
||||||
);
|
|
||||||
if let Error::ApiError(ref err) = e {
|
|
||||||
body_str.push_str(&format!(
|
|
||||||
r"
|
|
||||||
<ul>
|
|
||||||
<li>Code: {s3_code}</li>
|
|
||||||
<li>Message: {s3_message}.</li>
|
|
||||||
</ul>",
|
|
||||||
s3_code = err.aws_code(),
|
|
||||||
s3_message = err,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut http_error = Response::new(string_body(body_str));
|
|
||||||
*http_error.status_mut() = e.http_status_code();
|
*http_error.status_mut() = e.http_status_code();
|
||||||
e.add_headers(http_error.headers_mut());
|
e.add_headers(http_error.headers_mut());
|
||||||
http_error.headers_mut().insert(
|
|
||||||
http::header::CONTENT_TYPE,
|
|
||||||
"text/html; charset=utf-8".parse().unwrap(),
|
|
||||||
);
|
|
||||||
http_error
|
http_error
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
enum ImplicitRedirect {
|
enum RoutingResult {
|
||||||
No,
|
// Load a key and use `code` as status, or fallback to normal 404 handler if not found
|
||||||
To { key: String, url: String },
|
LoadKey {
|
||||||
|
key: String,
|
||||||
|
code: StatusCode,
|
||||||
|
},
|
||||||
|
// Load a key and use `200` as status, or fallback with a redirection using `redirect_code`
|
||||||
|
// as status
|
||||||
|
LoadOrRedirect {
|
||||||
|
key: String,
|
||||||
|
redirect_if_exists: Option<String>,
|
||||||
|
redirect_url: String,
|
||||||
|
redirect_code: StatusCode,
|
||||||
|
},
|
||||||
|
// Load a key and use `200` as status, or fallback by loading a different key and use
|
||||||
|
// `redirect_code` as status
|
||||||
|
LoadOrAlternativeError {
|
||||||
|
key: String,
|
||||||
|
redirect_key: String,
|
||||||
|
redirect_code: StatusCode,
|
||||||
|
},
|
||||||
|
// Send an http redirect with `code` as status
|
||||||
|
Redirect {
|
||||||
|
url: String,
|
||||||
|
code: StatusCode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoutingResult {
|
||||||
|
// return Ok((key_to_deref, status_code)) or Err((redirect_target, status_code))
|
||||||
|
fn main_target(&self) -> Result<(&str, StatusCode), (&str, StatusCode)> {
|
||||||
|
match self {
|
||||||
|
RoutingResult::LoadKey { key, code } => Ok((key, *code)),
|
||||||
|
RoutingResult::LoadOrRedirect { key, .. } => Ok((key, StatusCode::OK)),
|
||||||
|
RoutingResult::LoadOrAlternativeError { key, .. } => Ok((key, StatusCode::OK)),
|
||||||
|
RoutingResult::Redirect { url, code } => Err((url, *code)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path to key
|
/// Path to key
|
||||||
@@ -437,35 +524,154 @@ enum ImplicitRedirect {
|
|||||||
/// which is also AWS S3 behavior.
|
/// which is also AWS S3 behavior.
|
||||||
///
|
///
|
||||||
/// Check: https://docs.aws.amazon.com/AmazonS3/latest/userguide/IndexDocumentSupport.html
|
/// Check: https://docs.aws.amazon.com/AmazonS3/latest/userguide/IndexDocumentSupport.html
|
||||||
fn path_to_keys<'a>(path: &'a str, index: &str) -> Result<(String, ImplicitRedirect), Error> {
|
fn path_to_keys(
|
||||||
|
path: &str,
|
||||||
|
index: &str,
|
||||||
|
routing_rules: &[RoutingRule],
|
||||||
|
) -> Result<RoutingResult, Error> {
|
||||||
let path_utf8 = percent_encoding::percent_decode_str(path).decode_utf8()?;
|
let path_utf8 = percent_encoding::percent_decode_str(path).decode_utf8()?;
|
||||||
|
|
||||||
let base_key = match path_utf8.strip_prefix("/") {
|
let base_key = match path_utf8.strip_prefix("/") {
|
||||||
Some(bk) => bk,
|
Some(bk) => bk,
|
||||||
None => return Err(Error::BadRequest("Path must start with a / (slash)".into())),
|
None => return Err(Error::BadRequest("Path must start with a / (slash)".into())),
|
||||||
};
|
};
|
||||||
let is_bucket_root = base_key.len() == 0;
|
|
||||||
|
let is_bucket_root = base_key.is_empty();
|
||||||
let is_trailing_slash = path_utf8.ends_with("/");
|
let is_trailing_slash = path_utf8.ends_with("/");
|
||||||
|
|
||||||
match (is_bucket_root, is_trailing_slash) {
|
let key = if is_bucket_root || is_trailing_slash {
|
||||||
// It is not possible to store something at the root of the bucket (ie. empty key),
|
// we can't store anything at the root, so we need to query the index
|
||||||
// the only option is to fetch the index
|
// if the key end with a slash, we always query the index
|
||||||
(true, _) => Ok((index.to_string(), ImplicitRedirect::No)),
|
format!("{base_key}{index}")
|
||||||
|
} else {
|
||||||
|
// if the key doesn't end with `/`, leave it unmodified
|
||||||
|
base_key.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
// "If you create a folder structure in your bucket, you must have an index document at each level. In each folder, the index document must have the same name, for example, index.html. When a user specifies a URL that resembles a folder lookup, the presence or absence of a trailing slash determines the behavior of the website. For example, the following URL, with a trailing slash, returns the photos/index.html index document."
|
let mut routing_rules_iter = routing_rules.iter();
|
||||||
(false, true) => Ok((format!("{base_key}{index}"), ImplicitRedirect::No)),
|
let key = loop {
|
||||||
|
let Some(routing_rule) = routing_rules_iter.next() else {
|
||||||
|
break key;
|
||||||
|
};
|
||||||
|
|
||||||
// "However, if you exclude the trailing slash from the preceding URL, Amazon S3 first looks for an object photos in the bucket. If the photos object is not found, it searches for an index document, photos/index.html. If that document is found, Amazon S3 returns a 302 Found message and points to the photos/ key. For subsequent requests to photos/, Amazon S3 returns photos/index.html. If the index document is not found, Amazon S3 returns an error."
|
let Ok(status_code) = StatusCode::from_u16(routing_rule.redirect.http_redirect_code) else {
|
||||||
(false, false) => Ok((
|
continue;
|
||||||
base_key.to_string(),
|
};
|
||||||
ImplicitRedirect::To {
|
if let Some(condition) = &routing_rule.condition {
|
||||||
key: format!("{base_key}/{index}"),
|
let suffix = if let Some(prefix) = &condition.prefix {
|
||||||
url: format!("{path}/"),
|
let Some(suffix) = base_key.strip_prefix(prefix) else {
|
||||||
},
|
continue;
|
||||||
)),
|
};
|
||||||
|
Some(suffix)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut target = compute_redirect_target(&routing_rule.redirect, suffix);
|
||||||
|
let query_alternative_key =
|
||||||
|
status_code == StatusCode::OK || status_code == StatusCode::NOT_FOUND;
|
||||||
|
let redirect_on_error =
|
||||||
|
condition.http_error_code == Some(StatusCode::NOT_FOUND.as_u16());
|
||||||
|
match (query_alternative_key, redirect_on_error) {
|
||||||
|
(false, false) => {
|
||||||
|
return Ok(RoutingResult::Redirect {
|
||||||
|
url: target,
|
||||||
|
code: status_code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
// we need to remove the leading /
|
||||||
|
target.remove(0);
|
||||||
|
if status_code == StatusCode::OK {
|
||||||
|
break target;
|
||||||
|
} else {
|
||||||
|
return Ok(RoutingResult::LoadKey {
|
||||||
|
key: target,
|
||||||
|
code: status_code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(false, true) => {
|
||||||
|
return Ok(RoutingResult::LoadOrRedirect {
|
||||||
|
key,
|
||||||
|
redirect_if_exists: None,
|
||||||
|
redirect_url: target,
|
||||||
|
redirect_code: status_code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(true, true) => {
|
||||||
|
target.remove(0);
|
||||||
|
return Ok(RoutingResult::LoadOrAlternativeError {
|
||||||
|
key,
|
||||||
|
redirect_key: target,
|
||||||
|
redirect_code: status_code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let target = compute_redirect_target(&routing_rule.redirect, None);
|
||||||
|
return Ok(RoutingResult::Redirect {
|
||||||
|
url: target,
|
||||||
|
code: status_code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_bucket_root || is_trailing_slash {
|
||||||
|
Ok(RoutingResult::LoadKey {
|
||||||
|
key,
|
||||||
|
code: StatusCode::OK,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(RoutingResult::LoadOrRedirect {
|
||||||
|
redirect_if_exists: Some(format!("{key}/{index}")),
|
||||||
|
// we can't use `path` because key might have changed substentially in case of
|
||||||
|
// routing rules
|
||||||
|
redirect_url: percent_encoding::percent_encode(
|
||||||
|
format!("/{key}/").as_bytes(),
|
||||||
|
PATH_ENCODING_SET,
|
||||||
|
)
|
||||||
|
.to_string(),
|
||||||
|
key,
|
||||||
|
redirect_code: StatusCode::FOUND,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// per https://url.spec.whatwg.org/#path-percent-encode-set
|
||||||
|
const PATH_ENCODING_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
|
||||||
|
.add(b' ')
|
||||||
|
.add(b'"')
|
||||||
|
.add(b'#')
|
||||||
|
.add(b'<')
|
||||||
|
.add(b'>')
|
||||||
|
.add(b'?')
|
||||||
|
.add(b'`')
|
||||||
|
.add(b'{')
|
||||||
|
.add(b'}');
|
||||||
|
|
||||||
|
fn compute_redirect_target(redirect: &bucket_table::Redirect, suffix: Option<&str>) -> String {
|
||||||
|
let mut res = String::new();
|
||||||
|
if let Some(hostname) = &redirect.hostname {
|
||||||
|
if let Some(protocol) = &redirect.protocol {
|
||||||
|
res.push_str(protocol);
|
||||||
|
res.push_str("://");
|
||||||
|
} else {
|
||||||
|
res.push_str("//");
|
||||||
|
}
|
||||||
|
res.push_str(hostname);
|
||||||
|
}
|
||||||
|
res.push('/');
|
||||||
|
if let Some(replace_key_prefix) = &redirect.replace_key_prefix {
|
||||||
|
res.push_str(replace_key_prefix);
|
||||||
|
if let Some(suffix) = suffix {
|
||||||
|
res.push_str(suffix)
|
||||||
|
}
|
||||||
|
} else if let Some(replace_key) = &redirect.replace_key {
|
||||||
|
res.push_str(replace_key)
|
||||||
|
}
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -473,35 +679,39 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn path_to_keys_test() -> Result<(), Error> {
|
fn path_to_keys_test() -> Result<(), Error> {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
path_to_keys("/file%20.jpg", "index.html")?,
|
path_to_keys("/file%20.jpg", "index.html", &[])?,
|
||||||
(
|
RoutingResult::LoadOrRedirect {
|
||||||
"file .jpg".to_string(),
|
key: "file .jpg".to_string(),
|
||||||
ImplicitRedirect::To {
|
redirect_url: "/file%20.jpg/".to_string(),
|
||||||
key: "file .jpg/index.html".to_string(),
|
redirect_if_exists: Some("file .jpg/index.html".to_string()),
|
||||||
url: "/file%20.jpg/".to_string()
|
redirect_code: StatusCode::FOUND,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
path_to_keys("/%20t/", "index.html")?,
|
path_to_keys("/%20t/", "index.html", &[])?,
|
||||||
(" t/index.html".to_string(), ImplicitRedirect::No)
|
RoutingResult::LoadKey {
|
||||||
|
key: " t/index.html".to_string(),
|
||||||
|
code: StatusCode::OK
|
||||||
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
path_to_keys("/", "index.html")?,
|
path_to_keys("/", "index.html", &[])?,
|
||||||
("index.html".to_string(), ImplicitRedirect::No)
|
RoutingResult::LoadKey {
|
||||||
|
key: "index.html".to_string(),
|
||||||
|
code: StatusCode::OK
|
||||||
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
path_to_keys("/hello", "index.html")?,
|
path_to_keys("/hello", "index.html", &[])?,
|
||||||
(
|
RoutingResult::LoadOrRedirect {
|
||||||
"hello".to_string(),
|
key: "hello".to_string(),
|
||||||
ImplicitRedirect::To {
|
redirect_url: "/hello/".to_string(),
|
||||||
key: "hello/index.html".to_string(),
|
redirect_if_exists: Some("hello/index.html".to_string()),
|
||||||
url: "/hello/".to_string()
|
redirect_code: StatusCode::FOUND,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
);
|
);
|
||||||
assert!(path_to_keys("", "index.html").is_err());
|
assert!(path_to_keys("", "index.html", &[]).is_err());
|
||||||
assert!(path_to_keys("i/am/relative", "index.html").is_err());
|
assert!(path_to_keys("i/am/relative", "index.html", &[]).is_err());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user