mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-18 12:35:52 +00:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af32985e01 | ||
|
|
c0e1cbe9ed | ||
|
|
729c3bf387 | ||
|
|
ee1d8f9e8c | ||
|
|
2458f6c81c | ||
|
|
e55db58ca9 | ||
|
|
05d31a04b6 | ||
|
|
159cfc97ce | ||
|
|
935fb42e1d | ||
|
|
fd463155e4 | ||
|
|
194dce27bf | ||
|
|
89aab30821 | ||
|
|
8b87ceb0d1 | ||
|
|
5d43f84df7 | ||
|
|
7f254e158e | ||
|
|
85abf3ca88 | ||
|
|
c918660901 | ||
|
|
9cb103cd35 | ||
|
|
b7928637a0 | ||
|
|
c567da7164 | ||
|
|
35e3fe89bc | ||
|
|
ec83a87d68 | ||
|
|
3a192c6c57 | ||
|
|
5c991f38f5 | ||
|
|
8425c42858 | ||
|
|
0f6c6b0524 | ||
|
|
3a76c0b027 | ||
|
|
4f79d8e358 | ||
|
|
5ab3860005 | ||
|
|
7f2b20d577 | ||
|
|
b9bf45cb2e | ||
|
|
4e10669221 | ||
|
|
a1e5eb9dad | ||
|
|
487b93eb49 | ||
|
|
e96190d128 | ||
|
|
7b0b64db65 | ||
|
|
1c0e24f06a | ||
|
|
12f283357f | ||
|
|
9af1b212d3 | ||
|
|
9d6a699b94 | ||
|
|
6483583491 | ||
|
|
bc1d458fe6 | ||
|
|
1d3454ca5c | ||
|
|
6554ab7928 | ||
|
|
f8973b3ed6 | ||
|
|
6141222ab0 | ||
|
|
95560076e6 | ||
|
|
22ebe9feb0 | ||
|
|
4ded97a321 | ||
|
|
d951a8df5a | ||
|
|
2417ba0354 | ||
|
|
a769c938ec | ||
|
|
6d95a5592a | ||
|
|
0a91b57f16 | ||
|
|
e1d5e3899f | ||
|
|
3ee147dc4d | ||
|
|
66d9b89cd2 | ||
|
|
57bb7b2f39 | ||
|
|
6aa353716a | ||
|
|
3efd1e8974 | ||
|
|
39cf3cf719 | ||
|
|
dee1e12bcd | ||
|
|
bfcbd5aa0f | ||
|
|
1de741737d | ||
|
|
90b706984e | ||
|
|
7f1ac8cf1a | ||
|
|
4e22d9533b |
@@ -57,12 +57,12 @@ jobs:
|
||||
echo "FUSE device: $(ls -la /dev/fuse 2>&1 || echo '/dev/fuse not found')"
|
||||
|
||||
- name: Start SeaweedFS
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
# Enable Docker buildkit for better caching
|
||||
export DOCKER_BUILDKIT=1
|
||||
export COMPOSE_DOCKER_CLI_BUILD=1
|
||||
|
||||
|
||||
# Build with retry logic
|
||||
for i in {1..3}; do
|
||||
echo "Build attempt $i/3"
|
||||
@@ -77,10 +77,18 @@ jobs:
|
||||
sleep 30
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# Start services with wait
|
||||
docker compose -f ./compose/e2e-mount.yml up --wait
|
||||
|
||||
- name: Rotate buildx cache
|
||||
if: always()
|
||||
run: |
|
||||
# Without this, --cache-to writes to .buildx-cache-new but actions/cache only
|
||||
# uploads .buildx-cache, so layers (notably the slow apt RUN) never persist.
|
||||
rm -rf /tmp/.buildx-cache
|
||||
if [ -d /tmp/.buildx-cache-new ]; then mv /tmp/.buildx-cache-new /tmp/.buildx-cache; fi
|
||||
|
||||
- name: Run FIO 4k
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
|
||||
@@ -245,6 +245,51 @@ jobs:
|
||||
path: test/s3/retention/weed-test*.log
|
||||
retention-days: 3
|
||||
|
||||
s3-lifecycle-tests:
|
||||
name: S3 Lifecycle Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
id: go
|
||||
|
||||
- name: Install SeaweedFS
|
||||
run: |
|
||||
go install -buildvcs=false
|
||||
|
||||
- name: Run S3 Lifecycle Tests
|
||||
timeout-minutes: 12
|
||||
working-directory: test/s3/lifecycle
|
||||
run: |
|
||||
set -x
|
||||
make test-with-server
|
||||
|
||||
- name: Show server logs on failure
|
||||
if: failure()
|
||||
working-directory: test/s3/lifecycle
|
||||
run: |
|
||||
if [ -f weed-test.log ]; then
|
||||
echo "=== Last 200 lines of server logs ==="
|
||||
tail -200 weed-test.log
|
||||
fi
|
||||
ps aux | grep -E "(weed|test)" || true
|
||||
netstat -tlnp 2>/dev/null | grep -E "(8333|9333|8080|8888)" || true
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: s3-lifecycle-test-logs
|
||||
path: test/s3/lifecycle/weed-test*.log
|
||||
retention-days: 3
|
||||
|
||||
s3-checksum-tests:
|
||||
name: S3 Checksum Tests
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -261,6 +261,77 @@ jobs:
|
||||
path: test/s3tables/catalog_dremio/test-output.log
|
||||
retention-days: 3
|
||||
|
||||
doris-iceberg-catalog-tests:
|
||||
name: Doris Iceberg Catalog Integration Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 35
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
id: go
|
||||
|
||||
- name: Set up Docker
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Pre-pull Doris image
|
||||
run: docker pull apache/doris:doris-all-in-one-2.1.0
|
||||
|
||||
- name: Pre-pull Python image for PyIceberg writer
|
||||
run: docker pull python:3.11-slim
|
||||
|
||||
- name: Run go mod tidy
|
||||
run: go mod tidy
|
||||
|
||||
- name: Install SeaweedFS
|
||||
run: |
|
||||
go install -buildvcs=false ./weed
|
||||
|
||||
- name: Run Doris Iceberg Catalog Integration Tests
|
||||
timeout-minutes: 30
|
||||
working-directory: test/s3tables/catalog_doris
|
||||
run: |
|
||||
set -x
|
||||
set -o pipefail
|
||||
echo "=== System Information ==="
|
||||
uname -a
|
||||
free -h
|
||||
df -h
|
||||
docker info
|
||||
echo "=== Starting Doris Iceberg Catalog Tests ==="
|
||||
|
||||
go test -v -timeout 25m . 2>&1 | tee test-output.log || {
|
||||
echo "Doris Iceberg catalog integration tests failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Show test output on failure
|
||||
if: failure()
|
||||
working-directory: test/s3tables/catalog_doris
|
||||
run: |
|
||||
echo "=== Test Output ==="
|
||||
if [ -f test-output.log ]; then
|
||||
tail -200 test-output.log
|
||||
fi
|
||||
|
||||
echo "=== Process information ==="
|
||||
ps aux | grep -E "(weed|test|docker|doris)" || true
|
||||
echo "=== Doris containers ==="
|
||||
docker ps -a --filter "name=seaweed-doris" || true
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: doris-iceberg-catalog-test-logs
|
||||
path: test/s3tables/catalog_doris/test-output.log
|
||||
retention-days: 3
|
||||
|
||||
polaris-integration-tests:
|
||||
name: Polaris Integration Tests
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -591,6 +662,77 @@ jobs:
|
||||
path: test/s3tables/lakekeeper/test-output.log
|
||||
retention-days: 3
|
||||
|
||||
unity-catalog-integration-tests:
|
||||
name: Unity Catalog Integration Tests
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
id: go
|
||||
|
||||
- name: Set up Docker
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Pre-pull Unity Catalog image
|
||||
run: docker pull unitycatalog/unitycatalog:v0.4.0
|
||||
|
||||
- name: Pre-pull Python image for delta-rs writer
|
||||
run: docker pull python:3.11-slim
|
||||
|
||||
- name: Run go mod tidy
|
||||
run: go mod tidy
|
||||
|
||||
- name: Install SeaweedFS
|
||||
run: |
|
||||
go install -buildvcs=false ./weed
|
||||
|
||||
- name: Run Unity Catalog Integration Tests
|
||||
timeout-minutes: 25
|
||||
working-directory: test/s3tables/unity_catalog
|
||||
run: |
|
||||
set -x
|
||||
set -o pipefail
|
||||
echo "=== System Information ==="
|
||||
uname -a
|
||||
free -h
|
||||
df -h
|
||||
docker info
|
||||
echo "=== Starting Unity Catalog Tests ==="
|
||||
|
||||
go test -v -timeout 20m . 2>&1 | tee test-output.log || {
|
||||
echo "Unity Catalog integration tests failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Show test output on failure
|
||||
if: failure()
|
||||
working-directory: test/s3tables/unity_catalog
|
||||
run: |
|
||||
echo "=== Test Output ==="
|
||||
if [ -f test-output.log ]; then
|
||||
tail -200 test-output.log
|
||||
fi
|
||||
|
||||
echo "=== Process information ==="
|
||||
ps aux | grep -E "(weed|test|docker|unitycatalog)" || true
|
||||
echo "=== Unity Catalog containers ==="
|
||||
docker ps -a --filter "name=seaweed-unity-catalog" || true
|
||||
|
||||
- name: Upload test logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unity-catalog-integration-test-logs
|
||||
path: test/s3tables/unity_catalog/test-output.log
|
||||
retention-days: 3
|
||||
|
||||
s3-tables-build-verification:
|
||||
name: S3 Tables Build Verification
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -132,7 +132,49 @@ jobs:
|
||||
done
|
||||
|
||||
echo "✅ S3 server is responding, starting tests..."
|
||||
|
||||
|
||||
# Spawn the lifecycle worker so test_lifecycle_expiration etc. have
|
||||
# something driving deletions. The s3tests build tag rescales one
|
||||
# day to LifeCycleInterval=10s, so a 1d rule fires within ~10s of
|
||||
# the upload's mtime; -dispatch / -checkpoint defaults are already
|
||||
# tightened under the same build tag.
|
||||
LC_LOG=/tmp/lifecycle-worker.log
|
||||
# -debug routes glog to stderr so the bootstrap walker's progress
|
||||
# shows up in $LC_LOG; without it weed shell silences glog.
|
||||
(echo "s3.lifecycle.run-shard -shards 0-15 -s3 localhost:18000 -events 0 -runtime 1800s -refresh 2s" && echo exit) \
|
||||
| weed shell -debug -master=localhost:9333 \
|
||||
> "$LC_LOG" 2>&1 &
|
||||
lc_pid=$!
|
||||
# Aliveness check: a bad shell command exits in <1s and the suite
|
||||
# would otherwise just timeout the expiration tests with no signal.
|
||||
sleep 2
|
||||
if ! kill -0 "$lc_pid" 2>/dev/null; then
|
||||
echo "lifecycle worker died on startup"
|
||||
tail -50 "$LC_LOG" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "lifecycle worker pid=$lc_pid"
|
||||
|
||||
# bash -e exits the step on the first tox failure, so move teardown
|
||||
# into a trap to guarantee the worker log + data dir reach the runner.
|
||||
cleanup() {
|
||||
status=$?
|
||||
# SIGTERM first so the worker's stdout flushes; SIGKILL is the
|
||||
# bash fallback if it ignores TERM. Reading the log AFTER the
|
||||
# graceful-stop window catches the bootstrap walker's progress.
|
||||
kill -TERM "$lc_pid" 2>/dev/null || true
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "=== lifecycle worker log (tail) ==="
|
||||
tail -200 "$LC_LOG" 2>/dev/null || true
|
||||
fi
|
||||
kill -9 "$lc_pid" 2>/dev/null || true
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
rm -rf "$WEED_DATA_DIR" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
tox -- \
|
||||
s3tests/functional/test_s3.py::test_bucket_list_empty \
|
||||
s3tests/functional/test_s3.py::test_bucket_list_distinct \
|
||||
@@ -312,11 +354,8 @@ jobs:
|
||||
s3tests/functional/test_s3.py::test_lifecycle_get \
|
||||
s3tests/functional/test_s3.py::test_lifecycle_set_filter \
|
||||
s3tests/functional/test_s3.py::test_lifecycle_expiration \
|
||||
s3tests/functional/test_s3.py::test_lifecyclev2_expiration \
|
||||
s3tests/functional/test_s3.py::test_lifecycle_expiration_versioning_enabled
|
||||
kill -9 $pid || true
|
||||
# Clean up data directory
|
||||
rm -rf "$WEED_DATA_DIR" || true
|
||||
s3tests/functional/test_s3.py::test_lifecyclev2_expiration
|
||||
# cleanup() trap handles worker/server kill + data dir wipe.
|
||||
|
||||
versioning-tests:
|
||||
name: S3 Versioning & Object Lock tests
|
||||
@@ -958,6 +997,39 @@ jobs:
|
||||
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Spawn the lifecycle worker (see basic-tests block for context).
|
||||
LC_LOG=/tmp/lifecycle-worker-sql.log
|
||||
(echo "s3.lifecycle.run-shard -shards 0-15 -s3 localhost:18004 -events 0 -runtime 1800s -refresh 2s" && echo exit) \
|
||||
| weed shell -debug -master=localhost:9337 \
|
||||
> "$LC_LOG" 2>&1 &
|
||||
lc_pid=$!
|
||||
sleep 2
|
||||
if ! kill -0 "$lc_pid" 2>/dev/null; then
|
||||
echo "lifecycle worker died on startup"
|
||||
tail -50 "$LC_LOG" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "lifecycle worker pid=$lc_pid"
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
# SIGTERM first so the worker's stdout flushes; SIGKILL is the
|
||||
# bash fallback if it ignores TERM. Reading the log AFTER the
|
||||
# graceful-stop window catches the bootstrap walker's progress.
|
||||
kill -TERM "$lc_pid" 2>/dev/null || true
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "=== lifecycle worker log (tail) ==="
|
||||
tail -200 "$LC_LOG" 2>/dev/null || true
|
||||
fi
|
||||
kill -9 "$lc_pid" 2>/dev/null || true
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
rm -rf "$WEED_DATA_DIR" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
tox -- \
|
||||
s3tests/functional/test_s3.py::test_bucket_list_empty \
|
||||
s3tests/functional/test_s3.py::test_bucket_list_distinct \
|
||||
@@ -1137,10 +1209,7 @@ jobs:
|
||||
s3tests/functional/test_s3.py::test_lifecycle_get \
|
||||
s3tests/functional/test_s3.py::test_lifecycle_set_filter \
|
||||
s3tests/functional/test_s3.py::test_lifecycle_expiration \
|
||||
s3tests/functional/test_s3.py::test_lifecyclev2_expiration \
|
||||
s3tests/functional/test_s3.py::test_lifecycle_expiration_versioning_enabled
|
||||
kill -9 $pid || true
|
||||
# Clean up data directory
|
||||
rm -rf "$WEED_DATA_DIR" || true
|
||||
s3tests/functional/test_s3.py::test_lifecyclev2_expiration
|
||||
# cleanup() trap handles worker/server kill + data dir wipe.
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ Table of Contents
|
||||
* [Quick Start](#quick-start)
|
||||
* [Quick Start with weed mini](#quick-start-with-weed-mini)
|
||||
* [Quick Start for S3 API on Docker](#quick-start-for-s3-api-on-docker)
|
||||
* [Quick Start with Single Binary](#quick-start-with-single-binary)
|
||||
* [Introduction](#introduction)
|
||||
* [Features](#features)
|
||||
* [Additional Features](#additional-features)
|
||||
@@ -81,7 +80,7 @@ Table of Contents
|
||||
|
||||
## Quick Start with weed mini ##
|
||||
|
||||
Download the latest binary from https://github.com/seaweedfs/seaweedfs/releases and unzip the single `weed` (or `weed.exe`) file. Then start a ready-to-use S3 object store with credentials and a pre-created bucket in one command:
|
||||
Download the latest binary from https://github.com/seaweedfs/seaweedfs/releases and unzip the single `weed` (or `weed.exe`) file, or run `go install github.com/seaweedfs/seaweedfs/weed@latest`. Then start a ready-to-use S3 object store with credentials and a pre-created bucket in one command:
|
||||
|
||||
```bash
|
||||
AWS_ACCESS_KEY_ID=admin \
|
||||
@@ -102,18 +101,19 @@ The same command starts everything else too:
|
||||
|
||||
> macOS: if the binary is quarantined, run `xattr -d com.apple.quarantine ./weed` first.
|
||||
|
||||
Perfect for development, testing, learning SeaweedFS, and single-node deployments.
|
||||
Perfect for development, testing, learning SeaweedFS, and single-node deployments. To scale out, add more volume servers by running `weed volume -dir="/some/data/dir2" -master="<master_host>:9333" -port=8081` locally, on another machine, or on thousands of machines.
|
||||
|
||||
## Quick Start for S3 API on Docker ##
|
||||
|
||||
`docker run -p 8333:8333 chrislusf/seaweedfs server -s3`
|
||||
```bash
|
||||
docker run -p 8333:8333 \
|
||||
-e AWS_ACCESS_KEY_ID=admin \
|
||||
-e AWS_SECRET_ACCESS_KEY=secret \
|
||||
-e S3_BUCKET=my-bucket \
|
||||
chrislusf/seaweedfs
|
||||
```
|
||||
|
||||
## Quick Start with Single Binary ##
|
||||
* Download the latest binary from https://github.com/seaweedfs/seaweedfs/releases and unzip a single binary file `weed` or `weed.exe`. Or run `go install github.com/seaweedfs/seaweedfs/weed@latest`.
|
||||
* `export AWS_ACCESS_KEY_ID=admin ; export AWS_SECRET_ACCESS_KEY=key` as the admin credentials to access the object store.
|
||||
* Run `weed server -dir=/some/data/dir -s3` to start one master, one volume server, one filer, and one S3 gateway. The difference with `weed mini` is that `weed mini` can auto configure based on the single host environment, while `weed server` requires manual configuration and are designed for production use.
|
||||
|
||||
Also, to increase capacity, just add more volume servers by running `weed volume -dir="/some/data/dir2" -master="<master_host>:9333" -port=8081` locally, or on a different machine, or on thousands of machines. That is it!
|
||||
Same behavior as the `weed mini` command above — the S3 endpoint is at http://localhost:8333 with `my-bucket` pre-created. Drop the env vars to run anonymously for development.
|
||||
|
||||
# Introduction #
|
||||
|
||||
@@ -146,6 +146,11 @@ SeaweedFS can achieve both fast local access time and elastic cloud storage capa
|
||||
What's more, the cloud storage access API cost is minimized.
|
||||
Faster and cheaper than direct cloud storage!
|
||||
|
||||
SeaweedFS also ships a built-in **Iceberg REST Catalog**, turning the same cluster into a self-contained lakehouse.
|
||||
Spark, Trino, Dremio, DuckDB, and RisingWave can query Iceberg tables directly — no Hive Metastore, Glue, or
|
||||
external catalog service required. Storage and table metadata live in one system, simplifying on-prem and
|
||||
small-team analytics stacks.
|
||||
|
||||
[Back to TOC](#table-of-contents)
|
||||
|
||||
# Features #
|
||||
@@ -182,6 +187,13 @@ Faster and cheaper than direct cloud storage!
|
||||
* [Cloud Drive][CloudDrive] mounts cloud storage to local cluster, cached for fast read and write with asynchronous write back.
|
||||
* [Gateway to Remote Object Store][GatewayToRemoteObjectStore] mirrors bucket operations to remote object storage, in addition to [Cloud Drive][CloudDrive]
|
||||
|
||||
## Data Lakehouse Features ##
|
||||
* [S3 Table Buckets][S3TableBucket] expose a dedicated namespace for Iceberg tables with strict layout validation.
|
||||
* Built-in [Iceberg REST Catalog][IcebergCatalog] runs alongside the S3 endpoint — no external metastore needed.
|
||||
* Native integrations with [Apache Spark][SparkIceberg], [Trino][TrinoIceberg], [Dremio][DremioIceberg], [DuckDB][DuckDBIceberg], and [RisingWave][RisingWaveIceberg].
|
||||
* [Automated table maintenance][IcebergMaintenance]: compaction, snapshot expiration, orphan removal, manifest rewriting.
|
||||
* Granular IAM at the bucket, namespace, and table level via standard S3 bucket policies.
|
||||
|
||||
## Kubernetes ##
|
||||
* [Kubernetes CSI Driver][SeaweedFsCsiDriver] A Container Storage Interface (CSI) Driver. [](https://hub.docker.com/r/chrislusf/seaweedfs-csi-driver/)
|
||||
* [SeaweedFS Operator](https://github.com/seaweedfs/seaweedfs-operator)
|
||||
@@ -205,6 +217,14 @@ Faster and cheaper than direct cloud storage!
|
||||
[KeyLargeValueStore]: https://github.com/seaweedfs/seaweedfs/wiki/Filer-as-a-Key-Large-Value-Store
|
||||
[CloudDrive]: https://github.com/seaweedfs/seaweedfs/wiki/Cloud-Drive-Architecture
|
||||
[GatewayToRemoteObjectStore]: https://github.com/seaweedfs/seaweedfs/wiki/Gateway-to-Remote-Object-Storage
|
||||
[S3TableBucket]: https://github.com/seaweedfs/seaweedfs/wiki/S3-Table-Bucket
|
||||
[IcebergCatalog]: https://github.com/seaweedfs/seaweedfs/wiki/SeaweedFS-Iceberg-Catalog
|
||||
[IcebergMaintenance]: https://github.com/seaweedfs/seaweedfs/wiki/Iceberg-Table-Maintenance
|
||||
[SparkIceberg]: https://github.com/seaweedfs/seaweedfs/wiki/Spark-Iceberg-Integration
|
||||
[TrinoIceberg]: https://github.com/seaweedfs/seaweedfs/wiki/Trino-Iceberg-Integration
|
||||
[DremioIceberg]: https://github.com/seaweedfs/seaweedfs/wiki/Dremio-Iceberg-Integration
|
||||
[DuckDBIceberg]: https://github.com/seaweedfs/seaweedfs/wiki/DuckDB-Iceberg-Integration
|
||||
[RisingWaveIceberg]: https://github.com/seaweedfs/seaweedfs/wiki/RisingWave-Iceberg-Integration
|
||||
|
||||
|
||||
[Back to TOC](#table-of-contents)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,15 @@ FROM ubuntu:22.04
|
||||
|
||||
LABEL author="Chris Lu"
|
||||
|
||||
# Use faster mirrors and optimize package installation
|
||||
# Use Azure's Ubuntu mirror — much faster than archive.ubuntu.com from GitHub-hosted runners,
|
||||
# which have been hanging long enough on Ign:/retry to trip the 10-min step timeout.
|
||||
# Note: This e2e test image intentionally runs as root for simplicity and compatibility.
|
||||
# Production images (Dockerfile.go_build) use proper user isolation with su-exec.
|
||||
# For testing purposes, running as root avoids permission complexities and dependency
|
||||
# on Alpine-specific tools like su-exec (not available in Ubuntu repos).
|
||||
RUN apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 install -y \
|
||||
RUN sed -i 's|http://archive.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g; s|http://security.ubuntu.com/ubuntu|http://azure.archive.ubuntu.com/ubuntu|g' /etc/apt/sources.list && \
|
||||
apt-get -o Acquire::http::Timeout=15 update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::http::Timeout=15 install -y \
|
||||
--no-install-recommends \
|
||||
--no-install-suggests \
|
||||
curl \
|
||||
|
||||
@@ -5,7 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/pubsub v1.50.2
|
||||
cloud.google.com/go/storage v1.60.0
|
||||
cloud.google.com/go/storage v1.62.1
|
||||
github.com/Shopify/sarama v1.38.1
|
||||
github.com/aws/aws-sdk-go v1.55.8
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -34,7 +34,7 @@ require (
|
||||
github.com/google/btree v1.1.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.7.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
@@ -47,7 +47,7 @@ require (
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/karlseguin/ccache/v2 v2.0.8
|
||||
github.com/klauspost/compress v1.18.5
|
||||
github.com/klauspost/reedsolomon v1.13.3
|
||||
github.com/klauspost/reedsolomon v1.14.0
|
||||
github.com/kurin/blazer v0.5.3
|
||||
github.com/linxGnu/grocksdb v1.10.7
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
@@ -102,8 +102,8 @@ require (
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
google.golang.org/api v0.274.0
|
||||
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
modernc.org/b v1.0.0 // indirect
|
||||
@@ -113,11 +113,11 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/kms v1.26.0
|
||||
cloud.google.com/go/kms v1.30.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0
|
||||
github.com/Jille/raft-grpc-transport v1.6.1
|
||||
github.com/ThreeDotsLabs/watermill v1.5.1
|
||||
github.com/a-h/templ v0.3.977
|
||||
github.com/a-h/templ v0.3.1001
|
||||
github.com/apache/cassandra-gocql-driver/v2 v2.1.0
|
||||
github.com/apache/iceberg-go v0.5.0
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3
|
||||
@@ -171,7 +171,7 @@ require (
|
||||
atomicgo.dev/cursor v0.2.0 // indirect
|
||||
atomicgo.dev/keyboard v0.2.9 // indirect
|
||||
atomicgo.dev/schedule v0.1.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.8.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.9.0 // indirect
|
||||
cloud.google.com/go/pubsub/v2 v2.4.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect
|
||||
@@ -181,7 +181,7 @@ require (
|
||||
github.com/anchore/go-lzo v0.1.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.5.2-0.20260220015023-a886a5722b87 // indirect
|
||||
github.com/apache/thrift v0.22.0 // indirect
|
||||
github.com/apache/thrift v0.23.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/bazelbuild/rules_go v0.46.0 // indirect
|
||||
@@ -292,10 +292,10 @@ require (
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth v0.19.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/iam v1.7.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
|
||||
@@ -306,7 +306,7 @@ require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
|
||||
github.com/IBM/go-sdk-core/v5 v5.21.0 // indirect
|
||||
@@ -340,7 +340,7 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10
|
||||
github.com/aws/smithy-go v1.25.0
|
||||
github.com/aws/smithy-go v1.25.1
|
||||
github.com/boltdb/bolt v1.3.1 // indirect
|
||||
github.com/bradenaw/juniper v0.15.3 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
@@ -501,8 +501,8 @@ require (
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
golang.org/x/term v0.42.0
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/validator.v2 v2.0.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
|
||||
@@ -94,8 +94,8 @@ cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVo
|
||||
cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo=
|
||||
cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0=
|
||||
cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
|
||||
cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0=
|
||||
@@ -283,8 +283,8 @@ cloud.google.com/go/iam v0.7.0/go.mod h1:H5Br8wRaDGNc8XP3keLc4unfUUZeyH3Sfl9XpQE
|
||||
cloud.google.com/go/iam v0.8.0/go.mod h1:lga0/y3iH6CX7sYqypWJ33hf7kkfXJag67naqGESjkE=
|
||||
cloud.google.com/go/iam v0.11.0/go.mod h1:9PiLDanza5D+oWFZiH1uG+RnRCfEGKoyl6yo4cgWZGY=
|
||||
cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U=
|
||||
cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY=
|
||||
cloud.google.com/go/iap v1.4.0/go.mod h1:RGFwRJdihTINIe4wZ2iCP0zF/qu18ZwyKxrhMhygBEc=
|
||||
cloud.google.com/go/iap v1.5.0/go.mod h1:UH/CGgKd4KyohZL5Pt0jSKE4m3FR51qg6FKQ/z/Ix9A=
|
||||
cloud.google.com/go/iap v1.6.0/go.mod h1:NSuvI9C/j7UdjGjIde7t7HBz+QTwBcapPE07+sSRcLk=
|
||||
@@ -298,8 +298,8 @@ cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxs
|
||||
cloud.google.com/go/kms v1.5.0/go.mod h1:QJS2YY0eJGBg3mnDfuaCyLauWwBJiHRboYxJ++1xJNg=
|
||||
cloud.google.com/go/kms v1.6.0/go.mod h1:Jjy850yySiasBUDi6KFUwUv2n1+o7QZFyuUJg6OgjA0=
|
||||
cloud.google.com/go/kms v1.9.0/go.mod h1:qb1tPTgfF9RQP8e1wq4cLFErVuTJv7UsSC915J8dh3w=
|
||||
cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU=
|
||||
cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58=
|
||||
cloud.google.com/go/kms v1.30.0 h1:TSEZopy/OXojbVCQS9Rrx0jFqdKWvO31+N8ncXbxG/s=
|
||||
cloud.google.com/go/kms v1.30.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
|
||||
cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic=
|
||||
cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI=
|
||||
cloud.google.com/go/language v1.7.0/go.mod h1:DJ6dYN/W+SQOjF8e1hLQXMF21AkH2w9wiPzPCJa2MIE=
|
||||
@@ -315,8 +315,8 @@ cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZC
|
||||
cloud.google.com/go/longrunning v0.1.1/go.mod h1:UUFxuDWkv22EuY93jjmDMFT5GPQKeFVJBIF6QlTqdsE=
|
||||
cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc=
|
||||
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
|
||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY=
|
||||
cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E=
|
||||
cloud.google.com/go/managedidentities v1.3.0/go.mod h1:UzlW3cBOiPrzucO5qWkNkh0w33KFtBJU281hacNvsdE=
|
||||
cloud.google.com/go/managedidentities v1.4.0/go.mod h1:NWSBYbEMgqmbZsLIyKvxrYbtqOsxY1ZrGM+9RgDqInM=
|
||||
cloud.google.com/go/managedidentities v1.5.0/go.mod h1:+dWcZ0JlUmpuxpIDfyP5pP5y0bLdRwOS4Lp7gMni/LA=
|
||||
@@ -485,8 +485,8 @@ cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq
|
||||
cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc=
|
||||
cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s=
|
||||
cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
|
||||
cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8=
|
||||
cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0=
|
||||
cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8=
|
||||
cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
|
||||
cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w=
|
||||
cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I=
|
||||
cloud.google.com/go/storagetransfer v1.7.0/go.mod h1:8Giuj1QNb1kfLAiWM1bN6dHzfdlDAVC9rv9abHot2W4=
|
||||
@@ -597,8 +597,8 @@ github.com/FilenCloudDienste/filen-sdk-go v0.0.38 h1:YCyHs3wUXEe2BEWn40vcoyaQ2ru
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.38/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw=
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
|
||||
@@ -652,8 +652,8 @@ github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfih
|
||||
github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4=
|
||||
github.com/TomiHiltunen/geohash-golang v0.0.0-20150112065804-b3e4e625abfb h1:wumPkzt4zaxO4rHPBrjDK8iZMR41C1qs7njNqlacwQg=
|
||||
github.com/TomiHiltunen/geohash-golang v0.0.0-20150112065804-b3e4e625abfb/go.mod h1:QiYsIBRQEO+Z4Rz7GoI+dsHVneZNONvhczuA+llOZNM=
|
||||
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
|
||||
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 h1:iLDOF0rdGTrol/q8OfPIIs5kLD8XvA2q75o6Uq/tgak=
|
||||
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:DrEWcQJjz7t5iF2duaiyhg4jyoF0kxOD6LtECNGkZ/Q=
|
||||
github.com/aalpar/deheap v0.0.0-20210914013432-0cc84d79dec3 h1:hhdWprfSpFbN7lz3W1gM40vOgvSh1WCSMxYD6gGB4Hs=
|
||||
@@ -697,8 +697,8 @@ github.com/apache/cassandra-gocql-driver/v2 v2.1.0/go.mod h1:QH/asJjB3mHvY6Dot6Z
|
||||
github.com/apache/iceberg-go v0.5.0 h1:wQj4CK5YiXZcB+tj19gWG+Jf1I6MiORQ/StSL/E5gGQ=
|
||||
github.com/apache/iceberg-go v0.5.0/go.mod h1:F/rdP1yZmnO4mQ0Qew2HTGdc+ZV57cRfxbbq/uJm1eM=
|
||||
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
|
||||
github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc=
|
||||
github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g=
|
||||
github.com/apache/thrift v0.23.0 h1:wKR6YnefQSEnxpEfmgTPuJibNG4bF0p2TK34tHLWi3s=
|
||||
github.com/apache/thrift v0.23.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs=
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
||||
github.com/apple/foundationdb/bindings/go v0.0.0-20250911184653-27f7192f47c3 h1:WZaTKNHCfcw7fWSR6/RKnCldVzvYZC+Y20Su4lffEIg=
|
||||
@@ -756,8 +756,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6f
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
|
||||
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/bazelbuild/rules_go v0.46.0 h1:CTefzjN/D3Cdn3rkrM6qMWuQj59OBcuOjyIp3m4hZ7s=
|
||||
@@ -1296,8 +1296,8 @@ github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK
|
||||
github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo=
|
||||
github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY=
|
||||
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
|
||||
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
|
||||
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
|
||||
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
|
||||
@@ -1482,8 +1482,8 @@ github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuOb
|
||||
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/reedsolomon v1.13.3 h1:01GwnO2xoCSaM0ShP4qwl+FsHg3csFShC6Tu/RS1ji0=
|
||||
github.com/klauspost/reedsolomon v1.13.3/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
|
||||
github.com/klauspost/reedsolomon v1.14.0 h1:5YSZeclzSYg5nl349+GDG/agDtQ6MZiwUYXvVKN1Jx0=
|
||||
github.com/klauspost/reedsolomon v1.14.0/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 h1:CjEMN21Xkr9+zwPmZPaJJw+apzVbjGL5uK/6g9Q2jGU=
|
||||
@@ -2146,8 +2146,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4D
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.36.0 h1:s0n95ya5tOG03exJ5JySOdJFtwGo4ZQ+KeY7Zro4CLI=
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.36.0/go.mod h1:m9wRxtKA2MZ1HcnNC4BKI+9aYe434qRZTCvI7QGUN7Y=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
@@ -2844,12 +2844,12 @@ google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ
|
||||
google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA=
|
||||
google.golang.org/genproto v0.0.0-20230222225845-10f96fb3dbec/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw=
|
||||
google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s=
|
||||
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE=
|
||||
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
@@ -2890,8 +2890,8 @@ google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsA
|
||||
google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY=
|
||||
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
|
||||
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6 h1:ExN12ndbJ608cboPYflpTny6mXSzPrDLh0iTaVrRrds=
|
||||
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
|
||||
|
||||
@@ -247,6 +247,9 @@ message VolumeUnmountResponse {
|
||||
message VolumeDeleteRequest {
|
||||
uint32 volume_id = 1;
|
||||
bool only_empty = 2;
|
||||
// when true, do not remove the cloud-tier object backing the volume.
|
||||
// used for moves where another server is taking over the same .vif.
|
||||
bool keep_remote_data = 3;
|
||||
}
|
||||
message VolumeDeleteResponse {
|
||||
}
|
||||
@@ -637,6 +640,7 @@ message FetchAndWriteNeedleRequest {
|
||||
}
|
||||
repeated Replica replicas = 6;
|
||||
string auth = 7;
|
||||
int32 download_concurrency = 8; // multipart download concurrency if supported by the remote storage client; for S3, 0 = default (5)
|
||||
// remote conf
|
||||
remote_pb.RemoteConf remote_conf = 15;
|
||||
remote_pb.RemoteStorageLocation remote_location = 16;
|
||||
|
||||
@@ -145,6 +145,37 @@ pub struct VolumeGrpcService {
|
||||
}
|
||||
|
||||
impl VolumeGrpcService {
|
||||
/// Verifies the gRPC caller is allowed to invoke a destructive admin
|
||||
/// operation. Mirrors the Go side's checkGrpcAdminAuth: an empty
|
||||
/// whitelist accepts everyone (insecure-by-default for tests and
|
||||
/// upgrades), a populated whitelist accepts only matching peer IPs.
|
||||
///
|
||||
/// `remote_addr()` on a real gRPC connection always yields the peer's
|
||||
/// SocketAddr; if it is somehow None we deny, matching "if we don't
|
||||
/// know who the caller is, refuse."
|
||||
fn check_grpc_admin_auth<T>(&self, request: &Request<T>) -> Result<(), Status> {
|
||||
let remote = match request.remote_addr() {
|
||||
Some(addr) => addr,
|
||||
None => {
|
||||
tracing::warn!("gRPC admin auth failed: no peer info");
|
||||
return Err(Status::permission_denied("no peer info"));
|
||||
}
|
||||
};
|
||||
let host = remote.ip().to_string();
|
||||
let guard = self.state.guard.read().unwrap();
|
||||
if !guard.check_whitelist(&host) {
|
||||
tracing::warn!(
|
||||
"gRPC admin auth failed: {} is not whitelisted (remote: {})",
|
||||
host,
|
||||
remote,
|
||||
);
|
||||
return Err(Status::permission_denied(format!(
|
||||
"not authorized: {host}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn notify_master_volume_readonly(
|
||||
&self,
|
||||
info: &MasterVolumeInfo,
|
||||
@@ -459,6 +490,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::VacuumVolumeCompactRequest>,
|
||||
) -> Result<Response<Self::VacuumVolumeCompactStream>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
self.state.check_maintenance()?;
|
||||
let req = request.into_inner();
|
||||
let vid = VolumeId(req.volume_id);
|
||||
@@ -518,6 +550,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::VacuumVolumeCommitRequest>,
|
||||
) -> Result<Response<volume_server_pb::VacuumVolumeCommitResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
self.state.check_maintenance()?;
|
||||
let vid = VolumeId(request.into_inner().volume_id);
|
||||
|
||||
@@ -553,6 +586,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::VacuumVolumeCleanupRequest>,
|
||||
) -> Result<Response<volume_server_pb::VacuumVolumeCleanupResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
self.state.check_maintenance()?;
|
||||
let vid = VolumeId(request.into_inner().volume_id);
|
||||
let mut store = self.state.store.write().unwrap();
|
||||
@@ -568,6 +602,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::DeleteCollectionRequest>,
|
||||
) -> Result<Response<volume_server_pb::DeleteCollectionResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
let collection = &request.into_inner().collection;
|
||||
let mut store = self.state.store.write().unwrap();
|
||||
store
|
||||
@@ -580,6 +615,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::AllocateVolumeRequest>,
|
||||
) -> Result<Response<volume_server_pb::AllocateVolumeResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
self.state.check_maintenance()?;
|
||||
let req = request.into_inner();
|
||||
let vid = VolumeId(req.volume_id);
|
||||
@@ -728,6 +764,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::VolumeMountRequest>,
|
||||
) -> Result<Response<volume_server_pb::VolumeMountResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
let vid = VolumeId(req.volume_id);
|
||||
|
||||
@@ -744,6 +781,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::VolumeUnmountRequest>,
|
||||
) -> Result<Response<volume_server_pb::VolumeUnmountResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
let vid = VolumeId(request.into_inner().volume_id);
|
||||
let mut store = self.state.store.write().unwrap();
|
||||
// Go returns nil when volume is not found (idempotent unmount)
|
||||
@@ -757,6 +795,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::VolumeDeleteRequest>,
|
||||
) -> Result<Response<volume_server_pb::VolumeDeleteResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
self.state.check_maintenance()?;
|
||||
let req = request.into_inner();
|
||||
let vid = VolumeId(req.volume_id);
|
||||
@@ -770,7 +809,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
}
|
||||
}
|
||||
store
|
||||
.delete_volume(vid, req.only_empty)
|
||||
.delete_volume(vid, req.only_empty, req.keep_remote_data)
|
||||
.map_err(|e| Status::internal(e.to_string()))?;
|
||||
self.state.volume_state_notify.notify_one();
|
||||
Ok(Response::new(volume_server_pb::VolumeDeleteResponse {}))
|
||||
@@ -780,6 +819,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::VolumeMarkReadonlyRequest>,
|
||||
) -> Result<Response<volume_server_pb::VolumeMarkReadonlyResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
let vid = VolumeId(req.volume_id);
|
||||
// Go: volume lookup (L239-241) happens before maintenance check (L166 in makeVolumeReadonly)
|
||||
@@ -799,6 +839,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::VolumeMarkWritableRequest>,
|
||||
) -> Result<Response<volume_server_pb::VolumeMarkWritableResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
let vid = VolumeId(req.volume_id);
|
||||
let info = {
|
||||
@@ -848,6 +889,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::VolumeConfigureRequest>,
|
||||
) -> Result<Response<volume_server_pb::VolumeConfigureResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
self.state.check_maintenance()?;
|
||||
let req = request.into_inner();
|
||||
let vid = VolumeId(req.volume_id);
|
||||
@@ -1001,7 +1043,10 @@ impl VolumeServer for VolumeGrpcService {
|
||||
if store.find_volume(vid).is_some() {
|
||||
drop(store);
|
||||
let mut store = self.state.store.write().unwrap();
|
||||
store.delete_volume(vid, false).map_err(|e| {
|
||||
// keep remote data: the inbound copy carries a .vif that may
|
||||
// point at the same cloud-tier object the existing volume
|
||||
// references.
|
||||
store.delete_volume(vid, false, true).map_err(|e| {
|
||||
Status::internal(format!("failed to delete existing volume {}: {}", vid, e))
|
||||
})?;
|
||||
self.state.volume_state_notify.notify_one();
|
||||
@@ -2542,6 +2587,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::VolumeEcShardsDeleteRequest>,
|
||||
) -> Result<Response<volume_server_pb::VolumeEcShardsDeleteResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
self.state.check_maintenance()?;
|
||||
let req = request.into_inner();
|
||||
let vid = VolumeId(req.volume_id);
|
||||
@@ -3240,8 +3286,9 @@ impl VolumeServer for VolumeGrpcService {
|
||||
|
||||
async fn volume_server_leave(
|
||||
&self,
|
||||
_request: Request<volume_server_pb::VolumeServerLeaveRequest>,
|
||||
request: Request<volume_server_pb::VolumeServerLeaveRequest>,
|
||||
) -> Result<Response<volume_server_pb::VolumeServerLeaveResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
*self.state.is_stopping.write().unwrap() = true;
|
||||
self.state.is_heartbeating.store(false, Ordering::Relaxed);
|
||||
// Wake heartbeat loop to send deregistration.
|
||||
@@ -3391,6 +3438,7 @@ impl VolumeServer for VolumeGrpcService {
|
||||
&self,
|
||||
request: Request<volume_server_pb::ScrubVolumeRequest>,
|
||||
) -> Result<Response<volume_server_pb::ScrubVolumeResponse>, Status> {
|
||||
self.check_grpc_admin_auth(&request)?;
|
||||
let req = request.into_inner();
|
||||
|
||||
// Validate mode
|
||||
|
||||
@@ -860,7 +860,7 @@ fn build_heartbeat_with_ec_status(
|
||||
}
|
||||
|
||||
for vid in delete_vids {
|
||||
let _ = loc.delete_volume(vid, false);
|
||||
let _ = loc.delete_volume(vid, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -386,13 +386,20 @@ impl DiskLocation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove, close, and delete all files for a volume.
|
||||
pub fn delete_volume(&mut self, vid: VolumeId, only_empty: bool) -> Result<(), VolumeError> {
|
||||
/// Remove, close, and delete all files for a volume. When keep_remote_data
|
||||
/// is true the cloud-tier object backing the volume is left intact — used
|
||||
/// by moves where another server is taking over the same .vif.
|
||||
pub fn delete_volume(
|
||||
&mut self,
|
||||
vid: VolumeId,
|
||||
only_empty: bool,
|
||||
keep_remote_data: bool,
|
||||
) -> Result<(), VolumeError> {
|
||||
if let Some(mut v) = self.volumes.remove(&vid) {
|
||||
crate::metrics::VOLUME_GAUGE
|
||||
.with_label_values(&[&v.collection, "volume"])
|
||||
.dec();
|
||||
v.destroy(only_empty)?;
|
||||
v.destroy(only_empty, keep_remote_data)?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(VolumeError::NotFound)
|
||||
@@ -413,7 +420,7 @@ impl DiskLocation {
|
||||
crate::metrics::VOLUME_GAUGE
|
||||
.with_label_values(&[&v.collection, "volume"])
|
||||
.dec();
|
||||
if let Err(e) = v.destroy(false) {
|
||||
if let Err(e) = v.destroy(false, false) {
|
||||
warn!(volume_id = vid.0, error = %e, "delete collection: failed to destroy volume");
|
||||
}
|
||||
}
|
||||
@@ -1157,7 +1164,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(loc.volumes_len(), 2);
|
||||
|
||||
loc.delete_volume(VolumeId(1), false).unwrap();
|
||||
loc.delete_volume(VolumeId(1), false, false).unwrap();
|
||||
assert_eq!(loc.volumes_len(), 1);
|
||||
assert!(loc.find_volume(VolumeId(1)).is_none());
|
||||
}
|
||||
|
||||
@@ -307,11 +307,18 @@ impl Store {
|
||||
)
|
||||
}
|
||||
|
||||
/// Delete a volume from any location.
|
||||
pub fn delete_volume(&mut self, vid: VolumeId, only_empty: bool) -> Result<(), VolumeError> {
|
||||
/// Delete a volume from any location. When keep_remote_data is true the
|
||||
/// cloud-tier object backing the volume is left intact — used by moves
|
||||
/// where another server is taking over the same .vif.
|
||||
pub fn delete_volume(
|
||||
&mut self,
|
||||
vid: VolumeId,
|
||||
only_empty: bool,
|
||||
keep_remote_data: bool,
|
||||
) -> Result<(), VolumeError> {
|
||||
for loc in &mut self.locations {
|
||||
if loc.find_volume(vid).is_some() {
|
||||
return loc.delete_volume(vid, only_empty);
|
||||
return loc.delete_volume(vid, only_empty, keep_remote_data);
|
||||
}
|
||||
}
|
||||
Err(VolumeError::NotFound)
|
||||
|
||||
@@ -773,6 +773,13 @@ impl Volume {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nm_or_not_found(&self) -> Result<&NeedleMap, VolumeError> {
|
||||
self.nm.as_ref().ok_or_else(|| {
|
||||
tracing::warn!(volume_id = self.id.0, "needle map not loaded");
|
||||
VolumeError::NotFound
|
||||
})
|
||||
}
|
||||
|
||||
fn load_index(&mut self) -> Result<(), VolumeError> {
|
||||
let use_redb = matches!(
|
||||
self.needle_map_kind,
|
||||
@@ -1058,7 +1065,7 @@ impl Volume {
|
||||
read_option: &mut ReadOption,
|
||||
) -> Result<i32, VolumeError> {
|
||||
let _guard = self.data_file_access_control.read_lock();
|
||||
let nm = self.nm.as_ref().ok_or(VolumeError::NotFound)?;
|
||||
let nm = self.nm_or_not_found()?;
|
||||
let nv = nm.get(n.id).ok_or(VolumeError::NotFound)?;
|
||||
|
||||
if nv.offset.is_zero() {
|
||||
@@ -1300,7 +1307,7 @@ impl Volume {
|
||||
read_deleted: bool,
|
||||
) -> Result<NeedleStreamInfo, VolumeError> {
|
||||
let _guard = self.data_file_access_control.read_lock();
|
||||
let nm = self.nm.as_ref().ok_or(VolumeError::NotFound)?;
|
||||
let nm = self.nm_or_not_found()?;
|
||||
let nv = nm.get(n.id).ok_or(VolumeError::NotFound)?;
|
||||
|
||||
if nv.offset.is_zero() {
|
||||
@@ -1402,7 +1409,7 @@ impl Volume {
|
||||
&self,
|
||||
needle_id: NeedleId,
|
||||
) -> Result<(u64, u16), VolumeError> {
|
||||
let nm = self.nm.as_ref().ok_or(VolumeError::NotFound)?;
|
||||
let nm = self.nm_or_not_found()?;
|
||||
let nv = nm.get(needle_id).ok_or(VolumeError::NotFound)?;
|
||||
if nv.offset.is_zero() {
|
||||
return Err(VolumeError::NotFound);
|
||||
@@ -1719,7 +1726,7 @@ impl Volume {
|
||||
/// Read all live needles from the volume (for ReadAllNeedles streaming RPC).
|
||||
pub fn read_all_needles(&self) -> Result<Vec<Needle>, VolumeError> {
|
||||
let _guard = self.data_file_access_control.read_lock();
|
||||
let nm = self.nm.as_ref().ok_or(VolumeError::NotFound)?;
|
||||
let nm = self.nm_or_not_found()?;
|
||||
let version = self.version();
|
||||
let dat_size = self.current_dat_file_size()? as i64;
|
||||
let mut needles = Vec::new();
|
||||
@@ -1908,7 +1915,7 @@ impl Volume {
|
||||
if self.dat_file.is_none() && self.remote_dat_file.is_none() {
|
||||
return Err(VolumeError::NotFound);
|
||||
}
|
||||
let nm = self.nm.as_ref().ok_or(VolumeError::NotFound)?;
|
||||
let nm = self.nm_or_not_found()?;
|
||||
let dat_size = self.dat_file_size().map_err(VolumeError::Io)?;
|
||||
|
||||
let mut files_checked: u64 = 0;
|
||||
@@ -1968,7 +1975,7 @@ impl Volume {
|
||||
if self.dat_file.is_none() && self.remote_dat_file.is_none() {
|
||||
return Err(VolumeError::NotFound);
|
||||
}
|
||||
let nm = self.nm.as_ref().ok_or(VolumeError::NotFound)?;
|
||||
let nm = self.nm_or_not_found()?;
|
||||
|
||||
let dat_size = self.dat_file_size().map_err(|e| VolumeError::Io(e))?;
|
||||
let version = self.version();
|
||||
@@ -3081,7 +3088,10 @@ impl Volume {
|
||||
}
|
||||
|
||||
/// Remove all volume files from disk.
|
||||
pub fn destroy(&mut self, only_empty: bool) -> Result<(), VolumeError> {
|
||||
/// Destroy removes everything related to this volume. When keep_remote_data
|
||||
/// is true the cloud-tier object backing the volume is left intact — used
|
||||
/// by moves where another server is taking over the same .vif.
|
||||
pub fn destroy(&mut self, only_empty: bool, keep_remote_data: bool) -> Result<(), VolumeError> {
|
||||
if only_empty && self.file_count() > 0 {
|
||||
return Err(VolumeError::NotEmpty);
|
||||
}
|
||||
@@ -3093,7 +3103,11 @@ impl Volume {
|
||||
}
|
||||
|
||||
let (storage_name, storage_key) = self.remote_storage_name_key();
|
||||
if self.has_remote_file && !storage_name.is_empty() && !storage_key.is_empty() {
|
||||
if !keep_remote_data
|
||||
&& self.has_remote_file
|
||||
&& !storage_name.is_empty()
|
||||
&& !storage_key.is_empty()
|
||||
{
|
||||
let backend = crate::remote_storage::s3_tier::global_s3_tier_registry()
|
||||
.read()
|
||||
.unwrap()
|
||||
@@ -3659,7 +3673,7 @@ mod tests {
|
||||
dat_path = v.file_name(".dat");
|
||||
idx_path = v.file_name(".idx");
|
||||
assert!(Path::new(&dat_path).exists());
|
||||
v.destroy(false).unwrap();
|
||||
v.destroy(false, false).unwrap();
|
||||
}
|
||||
|
||||
assert!(!Path::new(&dat_path).exists());
|
||||
@@ -4424,7 +4438,7 @@ mod tests {
|
||||
assert!(std::path::Path::new(&idx_path).exists());
|
||||
|
||||
// Destroy the volume
|
||||
v.destroy(false).unwrap();
|
||||
v.destroy(false, false).unwrap();
|
||||
|
||||
// .dat and .idx should be gone
|
||||
assert!(
|
||||
@@ -4481,7 +4495,7 @@ mod tests {
|
||||
assert!(std::path::Path::new(&dat_path).exists());
|
||||
assert!(std::path::Path::new(&idx_path).exists());
|
||||
|
||||
v.destroy(false).unwrap();
|
||||
v.destroy(false, false).unwrap();
|
||||
|
||||
assert!(
|
||||
!std::path::Path::new(&dat_path).exists(),
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<junit.version>4.13.2</junit.version>
|
||||
<seaweedfs.hadoop3.client.version>4.00</seaweedfs.hadoop3.client.version>
|
||||
<jackson.version>2.18.6</jackson.version> <!-- Upgraded from 2.15.3 -->
|
||||
<netty.version>4.1.132.Final</netty.version> <!-- Upgraded to 4.1.125.Final for security fixes (CVE in netty-codec < 4.1.125.Final, netty-codec-http2 <= 4.1.123.Final) -->
|
||||
<netty.version>4.2.13.Final</netty.version> <!-- Upgraded to 4.1.125.Final for security fixes (CVE in netty-codec < 4.1.125.Final, netty-codec-http2 <= 4.1.123.Final) -->
|
||||
<parquet.version>1.15.2</parquet.version> <!-- Upgraded to 1.15.2 for security fix -->
|
||||
<parquet.format.version>2.12.0</parquet.format.version>
|
||||
<surefire.jvm.args>
|
||||
|
||||
@@ -101,11 +101,21 @@ func testConsumerGroupResumption(t *testing.T, gateway *testutil.GatewayTestServ
|
||||
|
||||
// Simulate consumer restart by consuming remaining messages with same group ID
|
||||
t.Logf("=== Phase 3: Second consumer (simulated restart) - consuming remaining messages with same group %s ===", groupID)
|
||||
consumed2, err := client.ConsumeWithGroupDebug(topic, groupID, 2, func(info testutil.ConsumeGroupRetryDebug) {
|
||||
t.Logf("Consumer restart attempt %d/%d for group %s failed before receiving any messages: %v",
|
||||
info.Attempt, info.MaxAttempts, info.GroupID, info.Err)
|
||||
gateway.LogConsumerGroupSnapshot(groupID)
|
||||
})
|
||||
consumed2, err := client.ConsumeWithGroupDebug(topic, groupID, 2,
|
||||
func(info testutil.ConsumeGroupRetryDebug) {
|
||||
t.Logf("Consumer restart attempt %d/%d for group %s failed before receiving any messages: %v",
|
||||
info.Attempt, info.MaxAttempts, info.GroupID, info.Err)
|
||||
gateway.LogConsumerGroupSnapshot(groupID)
|
||||
},
|
||||
func() {
|
||||
// Live snapshot taken while the reader is still joined — this is
|
||||
// the only place we get to see the group's transient state
|
||||
// (PreparingRebalance, leader, members) during a stuck attempt.
|
||||
// The onRetry snapshot above runs after reader.Close() and only
|
||||
// ever shows the post-LeaveGroup Empty state.
|
||||
gateway.LogConsumerGroupSnapshot(groupID)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
gateway.LogConsumerGroupSnapshot(groupID)
|
||||
}
|
||||
|
||||
@@ -151,20 +151,26 @@ func (k *KafkaGoClient) ConsumeMessages(topicName string, expectedCount int) ([]
|
||||
// member's LeaveGroup / session cleanup and can surface as an i/o timeout on
|
||||
// the first FetchMessage.
|
||||
func (k *KafkaGoClient) ConsumeWithGroup(topicName, groupID string, expectedCount int) ([]kafka.Message, error) {
|
||||
return k.consumeWithGroup(topicName, groupID, expectedCount, nil)
|
||||
return k.consumeWithGroup(topicName, groupID, expectedCount, nil, nil)
|
||||
}
|
||||
|
||||
func (k *KafkaGoClient) ConsumeWithGroupDebug(topicName, groupID string, expectedCount int, onRetry func(ConsumeGroupRetryDebug)) ([]kafka.Message, error) {
|
||||
return k.consumeWithGroup(topicName, groupID, expectedCount, onRetry)
|
||||
// ConsumeWithGroupDebug runs ConsumeWithGroup with diagnostic hooks.
|
||||
// - onRetry fires after each failed attempt; the snapshot is post-LeaveGroup
|
||||
// because reader.Close() has already run by then (group will look Empty).
|
||||
// - onTick fires every ~1.5s while an attempt is in flight, so the caller
|
||||
// can observe live group state during the join/sync/fetch cycle — the
|
||||
// only place we get to see PreparingRebalance / CompletingRebalance churn.
|
||||
func (k *KafkaGoClient) ConsumeWithGroupDebug(topicName, groupID string, expectedCount int, onRetry func(ConsumeGroupRetryDebug), onTick func()) ([]kafka.Message, error) {
|
||||
return k.consumeWithGroup(topicName, groupID, expectedCount, onRetry, onTick)
|
||||
}
|
||||
|
||||
func (k *KafkaGoClient) consumeWithGroup(topicName, groupID string, expectedCount int, onRetry func(ConsumeGroupRetryDebug)) ([]kafka.Message, error) {
|
||||
func (k *KafkaGoClient) consumeWithGroup(topicName, groupID string, expectedCount int, onRetry func(ConsumeGroupRetryDebug), onTick func()) ([]kafka.Message, error) {
|
||||
k.t.Helper()
|
||||
|
||||
const maxJoinAttempts = 5
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxJoinAttempts; attempt++ {
|
||||
messages, err, progressed := k.consumeWithGroupOnce(topicName, groupID, expectedCount)
|
||||
messages, err, progressed := k.consumeWithGroupOnce(topicName, groupID, expectedCount, onTick)
|
||||
if err == nil {
|
||||
return messages, nil
|
||||
}
|
||||
@@ -198,7 +204,7 @@ func (k *KafkaGoClient) consumeWithGroup(topicName, groupID string, expectedCoun
|
||||
// consumeWithGroupOnce runs a single consume attempt. Returns the messages
|
||||
// fetched, any error, and whether any message was received (used to decide
|
||||
// whether a retry is safe).
|
||||
func (k *KafkaGoClient) consumeWithGroupOnce(topicName, groupID string, expectedCount int) ([]kafka.Message, error, bool) {
|
||||
func (k *KafkaGoClient) consumeWithGroupOnce(topicName, groupID string, expectedCount int, onTick func()) ([]kafka.Message, error, bool) {
|
||||
// Give each reader its own ClientID so restarts do not get mistaken for the
|
||||
// still-shutting-down reader they are replacing.
|
||||
dialer := &kafka.Dialer{
|
||||
@@ -229,6 +235,32 @@ func (k *KafkaGoClient) consumeWithGroupOnce(topicName, groupID string, expected
|
||||
ctx, cancel := context.WithTimeout(context.Background(), consumerGroupAttemptTimeout)
|
||||
defer cancel()
|
||||
|
||||
// While the reader is still alive (i.e. before defer reader.Close() runs),
|
||||
// periodically invoke onTick so the caller can snapshot the gateway's view
|
||||
// of the group during the join/sync/fetch cycle. Stop it before reader.Close
|
||||
// fires — defers run LIFO, so this defer comes after the reader.Close defer.
|
||||
if onTick != nil {
|
||||
tickStop := make(chan struct{})
|
||||
tickDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(tickDone)
|
||||
ticker := time.NewTicker(1500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-tickStop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
onTick()
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
close(tickStop)
|
||||
<-tickDone
|
||||
}()
|
||||
}
|
||||
|
||||
var messages []kafka.Message
|
||||
for i := 0; i < expectedCount; i++ {
|
||||
msg, err := reader.FetchMessage(ctx)
|
||||
|
||||
@@ -74,25 +74,33 @@ func TestAssumeRoleWithWebIdentityValidation(t *testing.T) {
|
||||
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
||||
})
|
||||
|
||||
t.Run("missing_role_arn", func(t *testing.T) {
|
||||
t.Run("missing_role_arn_invalid_jwt_still_rejected", func(t *testing.T) {
|
||||
// Missing RoleArn is no longer a fast-fail at the HTTP layer:
|
||||
// claim-based policy mode (Phase 3b) advertises RoleArn as
|
||||
// optional so the STS service can derive the assumed-role ARN
|
||||
// from the configured policy claim. Validation now happens at
|
||||
// the STS layer once the JWT is parsed. With a bogus token the
|
||||
// JWT parse fails first, so the request is still rejected —
|
||||
// just with the JWT-parse error code instead of MissingParameter.
|
||||
resp, err := callSTSAPI(t, url.Values{
|
||||
"Action": {"AssumeRoleWithWebIdentity"},
|
||||
"WebIdentityToken": {"fake-jwt-token"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
// RoleArn is missing
|
||||
// RoleArn omitted on purpose.
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode,
|
||||
"Should fail without RoleArn")
|
||||
"Should still fail when RoleArn is missing and the JWT is invalid")
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
var errResp STSErrorTestResponse
|
||||
err = xml.Unmarshal(body, &errResp)
|
||||
require.NoError(t, err, "Failed to parse error response: %s", string(body))
|
||||
assert.Equal(t, "MissingParameter", errResp.Error.Code)
|
||||
assert.NotEqual(t, "MissingParameter", errResp.Error.Code,
|
||||
"missing RoleArn alone must no longer surface as MissingParameter")
|
||||
})
|
||||
|
||||
t.Run("missing_role_session_name", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# S3 Lifecycle Test Makefile
|
||||
# End-to-end test of the event-driven lifecycle worker, driven by
|
||||
# the s3.lifecycle.run-shard shell command.
|
||||
|
||||
.PHONY: help build-weed start-server stop-server test test-with-server clean health-check
|
||||
|
||||
WEED_BINARY := ../../../weed/weed_binary
|
||||
S3_PORT := 8333
|
||||
MASTER_PORT := 9333
|
||||
VOLUME_PORT := 8080
|
||||
FILER_PORT := 8888
|
||||
ACCESS_KEY ?= some_access_key1
|
||||
SECRET_KEY ?= some_secret_key1
|
||||
TEST_TIMEOUT := 10m
|
||||
TEST_PATTERN := TestLifecycle
|
||||
SERVER_DIR := ./test-volume-data/server-data
|
||||
|
||||
help:
|
||||
@echo "S3 Lifecycle Test Makefile"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " build-weed - build the SeaweedFS binary"
|
||||
@echo " start-server - start a local 'weed mini' cluster"
|
||||
@echo " stop-server - stop the cluster"
|
||||
@echo " test - run tests against an already-running cluster"
|
||||
@echo " test-with-server - start, run tests, stop"
|
||||
@echo " health-check - is the S3 endpoint up?"
|
||||
@echo " clean - remove test artifacts"
|
||||
|
||||
build-weed:
|
||||
@echo "Building SeaweedFS binary..."
|
||||
@cd ../../../weed && go build -o weed_binary .
|
||||
@chmod +x $(WEED_BINARY)
|
||||
|
||||
start-server: build-weed
|
||||
@echo "Starting weed mini..."
|
||||
@rm -f weed-server.pid
|
||||
@mkdir -p $(SERVER_DIR)
|
||||
@AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) $(WEED_BINARY) mini \
|
||||
-dir=$(SERVER_DIR) \
|
||||
-s3.port=$(S3_PORT) \
|
||||
> weed-test.log 2>&1 & \
|
||||
echo $$! > weed-server.pid
|
||||
@for i in $$(seq 1 90); do \
|
||||
if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \
|
||||
echo "server up after $$i s"; exit 0; \
|
||||
fi; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
echo "server did not start within 90s"; \
|
||||
tail -50 weed-test.log; \
|
||||
exit 1
|
||||
|
||||
stop-server:
|
||||
@if [ -f weed-server.pid ]; then \
|
||||
PID=$$(cat weed-server.pid); \
|
||||
kill -TERM $$PID 2>/dev/null || true; \
|
||||
sleep 1; \
|
||||
kill -KILL $$PID 2>/dev/null || true; \
|
||||
rm -f weed-server.pid; \
|
||||
fi
|
||||
|
||||
health-check:
|
||||
@curl -s http://localhost:$(S3_PORT) >/dev/null && echo "S3 up on $(S3_PORT)" || (echo "S3 not reachable"; exit 1)
|
||||
|
||||
test:
|
||||
@WEED_BINARY=$$(cd ../../../weed && pwd)/weed_binary \
|
||||
S3_ENDPOINT=http://localhost:$(S3_PORT) \
|
||||
S3_GRPC_ENDPOINT=localhost:$$(($(S3_PORT) + 10000)) \
|
||||
MASTER_ENDPOINT=http://localhost:$(MASTER_PORT) \
|
||||
FILER_GRPC_ADDRESS=localhost:$$(($(FILER_PORT) + 10000)) \
|
||||
go test -v -timeout $(TEST_TIMEOUT) -run $(TEST_PATTERN)
|
||||
|
||||
test-with-server: start-server
|
||||
@$(MAKE) test || (RC=$$?; $(MAKE) stop-server; exit $$RC)
|
||||
@$(MAKE) stop-server
|
||||
|
||||
clean: stop-server
|
||||
@rm -rf $(SERVER_DIR) test-volume-data
|
||||
@rm -f weed-test.log weed-server.pid
|
||||
@@ -0,0 +1,38 @@
|
||||
# S3 Lifecycle Integration Tests
|
||||
|
||||
End-to-end test of the event-driven S3 lifecycle worker, exercised through
|
||||
the `s3.lifecycle.run-shard` shell command.
|
||||
|
||||
## Why backdate mtimes?
|
||||
|
||||
The S3 API rejects `Expiration.Days < 1`, so a literal "wait one day"
|
||||
integration test isn't workable. Each test sets up a 1-day expiration rule,
|
||||
puts the target object, then rewrites its filer entry's `Mtime` to ~30 days
|
||||
ago via `filer_pb.UpdateEntry`. From the engine's perspective the object is
|
||||
past its expiration window the moment the shell command starts.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
# build the binary, start a local mini cluster, run tests, stop it
|
||||
make test-with-server
|
||||
|
||||
# or, if a cluster is already running on the default ports
|
||||
make test
|
||||
```
|
||||
|
||||
The test runs the shell command once with `-shards 0-15` (one filer
|
||||
subscription covering all 16 shards) rather than computing the target
|
||||
object's shard up front. This keeps the test independent of the
|
||||
`ShardID(bucket, key)` hash function — only that some shard reaches the
|
||||
deletion within the polling window.
|
||||
|
||||
## Environment
|
||||
|
||||
| variable | default | description |
|
||||
|----------------------|--------------------------|----------------------------|
|
||||
| `WEED_BINARY` | _required_ | path to `weed_binary` |
|
||||
| `S3_ENDPOINT` | `http://localhost:8333` | S3 API URL |
|
||||
| `S3_GRPC_ENDPOINT` | `localhost:18333` | S3 gRPC for lifecycle dispatch |
|
||||
| `MASTER_ENDPOINT` | `http://localhost:9333` | master HTTP |
|
||||
| `FILER_GRPC_ADDRESS` | `localhost:18888` | filer gRPC for `UpdateEntry` |
|
||||
@@ -0,0 +1,233 @@
|
||||
// Package lifecycle is the end-to-end test for the event-driven S3
|
||||
// lifecycle worker, driven by the s3.lifecycle.run-shard shell command.
|
||||
//
|
||||
// The S3 API rejects Expiration.Days < 1, so a literal "wait one day"
|
||||
// integration test is unworkable. Instead, the test backdates the target
|
||||
// object's mtime via the filer's UpdateEntry RPC: from the engine's
|
||||
// perspective the object is past its expiration window the moment the
|
||||
// shell command starts, and the dispatcher fires immediately.
|
||||
//
|
||||
// Variables (set by the Makefile):
|
||||
//
|
||||
// WEED_BINARY - path to the built `weed` binary
|
||||
// S3_ENDPOINT - http://host:port for the S3 API
|
||||
// FILER_GRPC_ADDRESS - host:port of the filer's gRPC listener
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultS3Endpoint = "http://localhost:8333"
|
||||
defaultS3GrpcEndpoint = "localhost:18333"
|
||||
defaultMasterEndpt = "http://localhost:9333"
|
||||
defaultFilerGRPC = "localhost:18888"
|
||||
bucketLifecycleXMLKey = "s3-bucket-lifecycle-configuration-xml"
|
||||
bucketsPath = "/buckets"
|
||||
accessKey = "some_access_key1"
|
||||
secretKey = "some_secret_key1"
|
||||
region = "us-east-1"
|
||||
)
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func s3Client(t *testing.T) *s3.Client {
|
||||
t.Helper()
|
||||
endpoint := envOr("S3_ENDPOINT", defaultS3Endpoint)
|
||||
cfg, err := config.LoadDefaultConfig(context.Background(),
|
||||
config.WithRegion(region),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
||||
config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
|
||||
func(service, region string, _ ...interface{}) (aws.Endpoint, error) {
|
||||
return aws.Endpoint{URL: endpoint, SigningRegion: region, HostnameImmutable: true}, nil
|
||||
})),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
return s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })
|
||||
}
|
||||
|
||||
func filerClient(t *testing.T) (filer_pb.SeaweedFilerClient, func()) {
|
||||
t.Helper()
|
||||
addr := envOr("FILER_GRPC_ADDRESS", defaultFilerGRPC)
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
require.NoError(t, err)
|
||||
return filer_pb.NewSeaweedFilerClient(conn), func() { conn.Close() }
|
||||
}
|
||||
|
||||
// uniqueBucket returns a fresh bucket name; the shell command picks up all
|
||||
// buckets with lifecycle config, so two parallel-running tests would conflict
|
||||
// on cursor state. Each test gets its own bucket.
|
||||
func uniqueBucket(prefix string) string {
|
||||
return fmt.Sprintf("lc-%s-%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func mustCreateBucket(t *testing.T, c *s3.Client, name string) {
|
||||
t.Helper()
|
||||
_, err := c.CreateBucket(context.Background(), &s3.CreateBucketInput{Bucket: aws.String(name)})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
// Best effort: empty + delete.
|
||||
listOut, _ := c.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{Bucket: aws.String(name)})
|
||||
if listOut != nil {
|
||||
for _, o := range listOut.Contents {
|
||||
c.DeleteObject(context.Background(), &s3.DeleteObjectInput{Bucket: aws.String(name), Key: o.Key})
|
||||
}
|
||||
}
|
||||
c.DeleteBucket(context.Background(), &s3.DeleteBucketInput{Bucket: aws.String(name)})
|
||||
})
|
||||
}
|
||||
|
||||
func putExpirationLifecycle(t *testing.T, c *s3.Client, bucket, prefix string, days int32) {
|
||||
t.Helper()
|
||||
_, err := c.PutBucketLifecycleConfiguration(context.Background(), &s3.PutBucketLifecycleConfigurationInput{
|
||||
Bucket: aws.String(bucket),
|
||||
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
|
||||
Rules: []types.LifecycleRule{
|
||||
{
|
||||
ID: aws.String("expire-prefix"),
|
||||
Status: types.ExpirationStatusEnabled,
|
||||
Filter: &types.LifecycleRuleFilter{Prefix: aws.String(prefix)},
|
||||
Expiration: &types.LifecycleExpiration{
|
||||
Days: aws.Int32(days),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func putObject(t *testing.T, c *s3.Client, bucket, key, body string) {
|
||||
t.Helper()
|
||||
_, err := c.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Body: strings.NewReader(body),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// backdateMtime rewrites the object's filer entry attributes so its Mtime
|
||||
// is daysOld days in the past. This sidesteps the AWS-spec minimum of one
|
||||
// day for Expiration.Days, letting the lifecycle dispatcher fire on demand.
|
||||
func backdateMtime(t *testing.T, fc filer_pb.SeaweedFilerClient, bucket, key string, daysOld int) {
|
||||
t.Helper()
|
||||
dir, name := splitBucketKey(bucket, key)
|
||||
resp, err := fc.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir,
|
||||
Name: name,
|
||||
})
|
||||
require.NoError(t, err, "lookup %s/%s", dir, name)
|
||||
require.NotNil(t, resp.Entry)
|
||||
require.NotNil(t, resp.Entry.Attributes)
|
||||
|
||||
resp.Entry.Attributes.Mtime = time.Now().Add(-time.Duration(daysOld) * 24 * time.Hour).Unix()
|
||||
resp.Entry.Attributes.MtimeNs = 0
|
||||
_, err = fc.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: resp.Entry,
|
||||
})
|
||||
require.NoError(t, err, "update %s/%s", dir, name)
|
||||
}
|
||||
|
||||
func splitBucketKey(bucket, key string) (dir, name string) {
|
||||
full := bucketsPath + "/" + bucket + "/" + key
|
||||
if i := strings.LastIndex(full, "/"); i >= 0 {
|
||||
return full[:i], full[i+1:]
|
||||
}
|
||||
return full, ""
|
||||
}
|
||||
|
||||
// runShellCommand invokes `weed shell` with a one-shot s3.lifecycle.run-shard
|
||||
// command piped via stdin and returns the combined stdout+stderr.
|
||||
func runShellCommand(t *testing.T, command string) string {
|
||||
t.Helper()
|
||||
binary := envOr("WEED_BINARY", "")
|
||||
require.NotEmpty(t, binary, "WEED_BINARY must be set")
|
||||
masterEndpoint := envOr("MASTER_ENDPOINT", defaultMasterEndpt)
|
||||
master := strings.TrimPrefix(masterEndpoint, "http://")
|
||||
if u, err := url.Parse(masterEndpoint); err == nil && u.Host != "" {
|
||||
master = u.Host
|
||||
}
|
||||
cmd := exec.Command(binary, "shell", "-master="+master)
|
||||
cmd.Stdin = strings.NewReader(command + "\nexit\n")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
err := cmd.Run()
|
||||
output := out.String()
|
||||
if err != nil {
|
||||
t.Logf("shell command output:\n%s", output)
|
||||
t.Fatalf("shell exec failed: %v", err)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
// TestLifecycleExpirationFiresOnBackdatedObject is the end-to-end
|
||||
// validation: a 1-day expiration rule plus an object whose mtime has been
|
||||
// backdated to 30 days ago must result in deletion when the shell command
|
||||
// runs the matching shard.
|
||||
func TestLifecycleExpirationFiresOnBackdatedObject(t *testing.T) {
|
||||
c := s3Client(t)
|
||||
fc, fcClose := filerClient(t)
|
||||
defer fcClose()
|
||||
|
||||
bucket := uniqueBucket("expire")
|
||||
mustCreateBucket(t, c, bucket)
|
||||
putExpirationLifecycle(t, c, bucket, "expire/", 1)
|
||||
|
||||
const oldKey = "expire/old.txt"
|
||||
const freshKey = "keep/fresh.txt"
|
||||
putObject(t, c, bucket, oldKey, "old")
|
||||
putObject(t, c, bucket, freshKey, "fresh")
|
||||
backdateMtime(t, fc, bucket, oldKey, 30)
|
||||
|
||||
// One subscription handles every shard via -shards 0-15; this stays
|
||||
// independent of which (bucket, key) hash lands the target on.
|
||||
// -runtime caps the run by wall-clock so the subprocess exits even
|
||||
// when the in-shard event count never reaches -events.
|
||||
out := runShellCommand(t, fmt.Sprintf(
|
||||
"s3.lifecycle.run-shard -shards 0-15 -s3 %s -events 0 -dispatch 200ms -checkpoint 5s -runtime 10s",
|
||||
envOr("S3_GRPC_ENDPOINT", defaultS3GrpcEndpoint),
|
||||
))
|
||||
t.Logf("shell command output:\n%s", out)
|
||||
require.NotContains(t, out, "FATAL", "shell output:\n%s", out)
|
||||
|
||||
// Allow the dispatched delete to land in the filer + propagate to S3.
|
||||
require.Eventuallyf(t, func() bool {
|
||||
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucket), Key: aws.String(oldKey),
|
||||
})
|
||||
return err != nil
|
||||
}, 30*time.Second, 500*time.Millisecond, "expected %s/%s to be deleted", bucket, oldKey)
|
||||
|
||||
// The fresh, in-prefix-but-recent object must remain (1d rule, mtime
|
||||
// is now). Best-evidence check after waiting for the same poll window.
|
||||
_, err := c.HeadObject(context.Background(), &s3.HeadObjectInput{
|
||||
Bucket: aws.String(bucket), Key: aws.String(freshKey),
|
||||
})
|
||||
require.NoError(t, err, "%s should still exist", freshKey)
|
||||
}
|
||||
@@ -704,13 +704,27 @@ func uniqueName(prefix string) string {
|
||||
// --- Test setup helpers ---
|
||||
|
||||
func startMiniCluster(t *testing.T) (*TestCluster, error) {
|
||||
ports := testutil.MustAllocatePorts(t, 8)
|
||||
// Allocate two extra ports for admin HTTP + gRPC. Without explicit
|
||||
// values mini falls back to 23646/33646, which collide across the
|
||||
// sequential subtests in this package: each subtest spins up a fresh
|
||||
// mini in a goroutine, but the previous mini's admin goroutine is
|
||||
// still binding 23646 — so the next test's admin can never become
|
||||
// ready and mini.go fatals on its readiness check.
|
||||
ports := testutil.MustAllocatePorts(t, 10)
|
||||
masterPort, masterGrpcPort := ports[0], ports[1]
|
||||
volumePort, volumeGrpcPort := ports[2], ports[3]
|
||||
filerPort, filerGrpcPort := ports[4], ports[5]
|
||||
s3Port, s3GrpcPort := ports[6], ports[7]
|
||||
adminPort, adminGrpcPort := ports[8], ports[9]
|
||||
|
||||
testDir := t.TempDir()
|
||||
// Manually-managed temp dir (not t.TempDir()) so we control removal order:
|
||||
// the dir is removed inside Stop() AFTER the mini goroutine has fully
|
||||
// exited. Otherwise t.TempDir()'s RemoveAll cleanup races the admin
|
||||
// plugin worker, which keeps creating files under admin/plugin/job_types/
|
||||
// for ~1s after subtests finish, producing flaky "directory not empty"
|
||||
// failures (see seaweedfs CI run 25352039081).
|
||||
testDir, err := os.MkdirTemp("", "seaweed-policy-mini-")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s3Endpoint := fmt.Sprintf("http://127.0.0.1:%d", s3Port)
|
||||
@@ -729,7 +743,7 @@ func startMiniCluster(t *testing.T) (*TestCluster, error) {
|
||||
|
||||
// Disable authentication for tests
|
||||
securityToml := filepath.Join(testDir, "security.toml")
|
||||
err := os.WriteFile(securityToml, []byte("# Empty security config\n"), 0644)
|
||||
err = os.WriteFile(securityToml, []byte("# Empty security config\n"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Configure credential store for IAM tests.
|
||||
@@ -772,6 +786,8 @@ enabled = true
|
||||
"-filer.port.grpc=" + strconv.Itoa(filerGrpcPort),
|
||||
"-s3.port=" + strconv.Itoa(s3Port),
|
||||
"-s3.port.grpc=" + strconv.Itoa(s3GrpcPort),
|
||||
"-admin.port=" + strconv.Itoa(adminPort),
|
||||
"-admin.port.grpc=" + strconv.Itoa(adminGrpcPort),
|
||||
"-webdav.port=0",
|
||||
"-admin.ui=false",
|
||||
"-master.volumeSizeLimitMB=32",
|
||||
@@ -783,7 +799,12 @@ enabled = true
|
||||
for _, cmd := range command.Commands {
|
||||
if cmd.Name() == "mini" && cmd.Run != nil {
|
||||
cmd.Flag.Parse(os.Args[1:])
|
||||
// MiniClusterCtx makes runMini observe our cancel: master/
|
||||
// volume/filer get this as their shutdownCtx, and the clients
|
||||
// state (admin/s3/webdav/plugin worker) chains from it.
|
||||
command.MiniClusterCtx = ctx
|
||||
cmd.Run(cmd, cmd.Flag.Args())
|
||||
command.MiniClusterCtx = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -792,6 +813,8 @@ enabled = true
|
||||
// Wait for S3
|
||||
if !testutil.WaitForService(cluster.s3Endpoint, 60*time.Second) {
|
||||
cancel()
|
||||
cluster.wg.Wait()
|
||||
os.RemoveAll(testDir)
|
||||
return nil, fmt.Errorf("timeout waiting for S3 at %s", cluster.s3Endpoint)
|
||||
}
|
||||
|
||||
@@ -799,6 +822,8 @@ enabled = true
|
||||
if os.Getenv("VOLUME_SERVER_IMPL") == "rust" {
|
||||
if err := cluster.startRustVolumeServer(t); err != nil {
|
||||
cancel()
|
||||
cluster.wg.Wait()
|
||||
os.RemoveAll(testDir)
|
||||
return nil, fmt.Errorf("failed to start Rust volume server: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -882,10 +907,25 @@ func (c *TestCluster) Stop() {
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
if c.isRunning {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Wait for the mini goroutine to fully return before removing the data
|
||||
// dir. runMini observes MiniClusterCtx and returns once cancel fires; the
|
||||
// admin/s3/webdav/plugin-worker shutdown is gated by the same ctx via
|
||||
// resetMiniClients. The deadline is generous because admin shutdown can
|
||||
// take several seconds when graceful-stops are draining.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
fmt.Println("Warning: TestCluster.Stop timed out waiting for mini goroutine")
|
||||
}
|
||||
// Simplified stop
|
||||
|
||||
// Reset all mini flags so a subsequent in-process startMiniCluster sees
|
||||
// fresh state.
|
||||
for _, cmd := range command.Commands {
|
||||
if cmd.Name() == "mini" {
|
||||
cmd.Flag.VisitAll(func(f *flag.Flag) {
|
||||
@@ -894,6 +934,10 @@ func (c *TestCluster) Stop() {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if c.dataDir != "" {
|
||||
os.RemoveAll(c.dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Iceberg writer used by the Doris integration test to populate a table with
|
||||
# data files via PyIceberg, so the downstream Doris SELECT verifies the read
|
||||
# path against non-empty results rather than just COUNT(*) = 0.
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir "pyiceberg[s3fs]" pyarrow
|
||||
|
||||
COPY append_rows.py /app/
|
||||
|
||||
ENTRYPOINT ["python3", "/app/append_rows.py"]
|
||||
@@ -0,0 +1,80 @@
|
||||
# Apache Doris Iceberg Catalog Integration Test
|
||||
|
||||
This directory contains an Apache Doris integration smoke test for SeaweedFS's
|
||||
Iceberg REST Catalog implementation.
|
||||
|
||||
## What It Tests
|
||||
|
||||
`TestDorisIcebergCatalog` verifies the Doris path end to end:
|
||||
|
||||
1. Starts a local SeaweedFS mini cluster with S3 Tables and Iceberg REST enabled.
|
||||
2. Creates a SeaweedFS table bucket.
|
||||
3. Creates an Iceberg namespace and an empty table through the SeaweedFS REST
|
||||
catalog OAuth flow.
|
||||
4. Creates a second table and populates it with three rows by running a
|
||||
PyIceberg writer container (`Dockerfile.writer` + `append_rows.py`) before
|
||||
Doris bootstraps, so the snapshot is part of Doris's first scan.
|
||||
5. Starts `apache/doris:doris-all-in-one-2.1.0` and waits for the FE MySQL
|
||||
query port and at least one alive BE.
|
||||
6. Connects to Doris over the MySQL protocol and registers a Doris
|
||||
`EXTERNAL CATALOG` of `type=iceberg` and `iceberg.catalog.type=rest` that
|
||||
points at the SeaweedFS REST endpoint, using `credential = key:secret` for
|
||||
the OAuth2 client-credentials flow.
|
||||
7. Runs subtests against the SeaweedFS-backed Iceberg tables:
|
||||
- `BasicSelect`: Doris is alive and answering SQL.
|
||||
- `CatalogVisible`: `SHOW CATALOGS` lists the SeaweedFS-backed catalog.
|
||||
- `DatabaseVisible`: the seeded namespace is exposed as a Doris database.
|
||||
- `TableVisible`: the seeded table appears under `SHOW TABLES FROM ...`.
|
||||
- `CountEmptyTable`: catalog-to-table resolution and a scan of an empty table.
|
||||
- `ColumnProjection`: `SELECT id, label` succeeds and the response columns
|
||||
are `id, label`. Failure here means Doris could not parse the schema
|
||||
returned by the SeaweedFS catalog.
|
||||
- `ReadWrittenDataCount` and `ReadWrittenDataValues`: Doris reads back the
|
||||
three PyIceberg-appended rows and the values match. This exercises the
|
||||
actual data path (parquet reads via S3), not just metadata.
|
||||
|
||||
The PyIceberg writer image is built on demand via Docker layer caching. The
|
||||
first build pulls `python:3.11-slim` and pip-installs PyIceberg + PyArrow
|
||||
(~1-2 min in CI); subsequent invocations are cheap.
|
||||
|
||||
## Running Locally
|
||||
|
||||
Build or install `weed`, then run:
|
||||
|
||||
```bash
|
||||
cd test/s3tables/catalog_doris
|
||||
go test -v -timeout 25m .
|
||||
```
|
||||
|
||||
The test requires Docker. The GitHub Actions job runs on `ubuntu-22.04` and
|
||||
executes the test for pull requests.
|
||||
|
||||
## Configuration
|
||||
|
||||
The test uses these fixed credentials for the local SeaweedFS IAM config:
|
||||
|
||||
- S3 access key: `AKIAIOSFODNN7EXAMPLE`
|
||||
- S3 secret key: `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`
|
||||
- Region: `us-west-2`
|
||||
- Warehouse bucket: `iceberg-tables`
|
||||
|
||||
Doris ports:
|
||||
|
||||
- The all-in-one container exposes `9030` (FE MySQL/SQL) and `8030` (FE HTTP).
|
||||
Only `9030` is mapped to a host port (allocated dynamically) so the Go
|
||||
MySQL client can connect from the test process.
|
||||
- The Iceberg REST endpoint and the S3 endpoint are reached from inside the
|
||||
Doris container via `host.docker.internal`, matching the Trino and Dremio
|
||||
test paths.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Ensure Docker is running: `docker version`
|
||||
- Ensure `weed` is built or available on `PATH`
|
||||
- The Doris all-in-one image is large (~1 GB) and the FE+BE need
|
||||
~30-60 seconds to register before queries succeed; the test waits up to
|
||||
4 minutes for the query port and 60 seconds for at least one alive BE.
|
||||
- If queries hang on metadata, run `REFRESH CATALOG iceberg_catalog` from a
|
||||
Doris MySQL client and retry.
|
||||
- Container logs are printed in the failure message; you can also check
|
||||
`docker logs <seaweed-doris-...>` while the test is running.
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Append rows to an existing Iceberg table via the SeaweedFS REST catalog.
|
||||
|
||||
Used by the Doris integration test to materialize data files so a downstream
|
||||
SELECT from Doris verifies the read path against non-empty results.
|
||||
|
||||
Usage:
|
||||
python3 append_rows.py \\
|
||||
--catalog-url http://localhost:8181 \\
|
||||
--warehouse s3://my-bucket \\
|
||||
--prefix my-bucket \\
|
||||
--s3-endpoint http://localhost:8333 \\
|
||||
--access-key AKIA... --secret-key wJalr... \\
|
||||
--namespace foo --namespace bar \\
|
||||
--table events
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import pyarrow as pa
|
||||
from pyiceberg.catalog import load_catalog
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--catalog-url", required=True)
|
||||
p.add_argument("--warehouse", required=True, help="s3://<bucket-name>")
|
||||
p.add_argument("--prefix", required=True, help="REST catalog prefix (table bucket name)")
|
||||
p.add_argument("--s3-endpoint", required=True, help="http://host:port")
|
||||
p.add_argument("--access-key", required=True)
|
||||
p.add_argument("--secret-key", required=True)
|
||||
p.add_argument("--region", default="us-east-1")
|
||||
p.add_argument(
|
||||
"--namespace",
|
||||
action="append",
|
||||
required=True,
|
||||
help="One per level (e.g. --namespace foo --namespace bar for foo.bar).",
|
||||
)
|
||||
p.add_argument("--table", required=True)
|
||||
args = p.parse_args()
|
||||
|
||||
# `credential` triggers OAuth2 client_credentials against
|
||||
# <catalog_uri>/v1/oauth/tokens, matching the helper Go test uses for
|
||||
# REST-API table creation. The s3.* keys are needed for parquet writes.
|
||||
catalog = load_catalog(
|
||||
"rest",
|
||||
**{
|
||||
"type": "rest",
|
||||
"uri": args.catalog_url,
|
||||
"warehouse": args.warehouse,
|
||||
"prefix": args.prefix,
|
||||
"credential": f"{args.access_key}:{args.secret_key}",
|
||||
"s3.access-key-id": args.access_key,
|
||||
"s3.secret-access-key": args.secret_key,
|
||||
"s3.endpoint": args.s3_endpoint,
|
||||
"s3.region": args.region,
|
||||
"s3.path-style-access": "true",
|
||||
},
|
||||
)
|
||||
|
||||
table_id = tuple(args.namespace) + (args.table,)
|
||||
table = catalog.load_table(table_id)
|
||||
|
||||
# Match the Iceberg table schema: id is `required long`, label is
|
||||
# `optional string`. Default pyarrow columns are nullable, which fails
|
||||
# PyIceberg's required-field compatibility check.
|
||||
arrow_schema = pa.schema(
|
||||
[
|
||||
pa.field("id", pa.int64(), nullable=False),
|
||||
pa.field("label", pa.string(), nullable=True),
|
||||
]
|
||||
)
|
||||
arrow_table = pa.Table.from_pydict(
|
||||
{
|
||||
"id": [1, 2, 3],
|
||||
"label": ["one", "two", "three"],
|
||||
},
|
||||
schema=arrow_schema,
|
||||
)
|
||||
table.append(arrow_table)
|
||||
print(f"appended {arrow_table.num_rows} rows to {'.'.join(table_id)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,943 @@
|
||||
// Package catalog_doris provides integration tests for Apache Doris with the
|
||||
// SeaweedFS Iceberg REST Catalog.
|
||||
package catalog_doris
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
)
|
||||
|
||||
const (
|
||||
dorisImage = "apache/doris:doris-all-in-one-2.1.0"
|
||||
dorisCatalogName = "iceberg_catalog"
|
||||
dorisQueryPort = 9030
|
||||
dorisStartTimeout = 4 * time.Minute
|
||||
)
|
||||
|
||||
type TestEnvironment struct {
|
||||
seaweedDir string
|
||||
weedBinary string
|
||||
dataDir string
|
||||
bindIP string
|
||||
s3Port int
|
||||
s3GrpcPort int
|
||||
icebergPort int
|
||||
masterPort int
|
||||
masterGrpcPort int
|
||||
filerPort int
|
||||
filerGrpcPort int
|
||||
volumePort int
|
||||
volumeGrpcPort int
|
||||
weedProcess *exec.Cmd
|
||||
weedCancel context.CancelFunc
|
||||
dorisContainer string
|
||||
dorisHostQueryPort int
|
||||
accessKey string
|
||||
secretKey string
|
||||
}
|
||||
|
||||
// TestDorisIcebergCatalog brings up SeaweedFS + Doris and validates that
|
||||
// Doris can discover catalog metadata served by SeaweedFS's Iceberg REST API
|
||||
// and read both empty and populated tables through the standard data path.
|
||||
//
|
||||
// Subtests:
|
||||
// - BasicSelect: Doris is alive and answering SQL.
|
||||
// - CatalogVisible: SHOW CATALOGS lists the SeaweedFS-backed catalog.
|
||||
// - DatabaseVisible: the seeded namespace is visible as a database.
|
||||
// - TableVisible: the seeded table is listed under the namespace.
|
||||
// - CountEmptyTable: Doris resolves the table and scans an empty Iceberg snapshot.
|
||||
// - ColumnProjection: Doris parsed the schema and accepts column-name projection
|
||||
// (a column-not-found here means the schema returned by SeaweedFS was rejected).
|
||||
// - ReadWrittenDataCount / ReadWrittenDataValues: a separate table is populated
|
||||
// by a PyIceberg writer container before Doris connects; Doris then reads the
|
||||
// three rows back, exercising the actual data path (not just metadata).
|
||||
func TestDorisIcebergCatalog(t *testing.T) {
|
||||
requireDorisRuntime(t)
|
||||
|
||||
env := NewTestEnvironment(t)
|
||||
defer env.Cleanup(t)
|
||||
|
||||
fmt.Printf(">>> Starting SeaweedFS...\n")
|
||||
env.StartSeaweedFS(t)
|
||||
fmt.Printf(">>> SeaweedFS started.\n")
|
||||
|
||||
tableBucket := "iceberg-tables"
|
||||
fmt.Printf(">>> Creating table bucket: %s\n", tableBucket)
|
||||
createTableBucket(t, env, tableBucket)
|
||||
fmt.Printf(">>> Table bucket created.\n")
|
||||
|
||||
testIcebergRestAPI(t, env)
|
||||
|
||||
namespace := "doris_" + randomString(6)
|
||||
tableName := "smoke_" + randomString(6)
|
||||
icebergToken := requestIcebergOAuthToken(t, env)
|
||||
createIcebergNamespace(t, env, icebergToken, tableBucket, namespace)
|
||||
createIcebergTable(t, env, icebergToken, tableBucket, namespace, tableName)
|
||||
|
||||
// Seed a populated table by creating an empty one through the REST API
|
||||
// and then appending three rows via PyIceberg. Done before Doris bootstrap
|
||||
// so the snapshot is part of the catalog's first scan.
|
||||
populatedTable := "populated_" + randomString(6)
|
||||
createIcebergTable(t, env, icebergToken, tableBucket, namespace, populatedTable)
|
||||
buildDorisWriterImage(t)
|
||||
writeIcebergRows(t, env, tableBucket, []string{namespace}, populatedTable)
|
||||
|
||||
env.startDorisContainer(t)
|
||||
env.waitForDoris(t, dorisStartTimeout)
|
||||
db := env.connectDoris(t)
|
||||
defer db.Close()
|
||||
|
||||
env.createDorisIcebergCatalog(t, db, tableBucket)
|
||||
|
||||
t.Run("BasicSelect", func(t *testing.T) {
|
||||
var v int
|
||||
if err := db.QueryRow("SELECT 1").Scan(&v); err != nil {
|
||||
t.Fatalf("SELECT 1: %v", err)
|
||||
}
|
||||
if v != 1 {
|
||||
t.Fatalf("SELECT 1 = %d, want 1", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CatalogVisible", func(t *testing.T) {
|
||||
// Doris's SHOW CATALOGS columns vary between versions. The catalog
|
||||
// name appears in the first column for all releases that ship the
|
||||
// Iceberg REST connector, so a per-row substring check is enough.
|
||||
if !rowsContain(t, db, "SHOW CATALOGS", dorisCatalogName) {
|
||||
t.Fatalf("SHOW CATALOGS did not list %s", dorisCatalogName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DatabaseVisible", func(t *testing.T) {
|
||||
query := fmt.Sprintf("SHOW DATABASES FROM %s", quoteDorisIdent(dorisCatalogName))
|
||||
if !rowsContain(t, db, query, namespace) {
|
||||
t.Fatalf("SHOW DATABASES from %s did not list namespace %s", dorisCatalogName, namespace)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TableVisible", func(t *testing.T) {
|
||||
query := fmt.Sprintf("SHOW TABLES FROM %s.%s",
|
||||
quoteDorisIdent(dorisCatalogName), quoteDorisIdent(namespace))
|
||||
if !rowsContain(t, db, query, tableName) {
|
||||
t.Fatalf("SHOW TABLES from %s.%s did not list %s", dorisCatalogName, namespace, tableName)
|
||||
}
|
||||
})
|
||||
|
||||
tableRef := dorisObjectName(dorisCatalogName, namespace, tableName)
|
||||
|
||||
t.Run("CountEmptyTable", func(t *testing.T) {
|
||||
var count int64
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableRef)
|
||||
if err := db.QueryRow(query).Scan(&count); err != nil {
|
||||
t.Fatalf("%s: %v", query, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("count(%s) = %d, want 0", tableRef, count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ColumnProjection", func(t *testing.T) {
|
||||
// SELECT COUNT(*) doesn't exercise the schema. A projection by column
|
||||
// name fails fast with "column not found" if the catalog's schema
|
||||
// response wasn't parsed by Doris.
|
||||
query := fmt.Sprintf("SELECT id, label FROM %s", tableRef)
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", query, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
t.Fatalf("Columns(): %v", err)
|
||||
}
|
||||
if !equalStringSlicesIgnoreCase(cols, []string{"id", "label"}) {
|
||||
t.Fatalf("projected columns = %v, want [id label]", cols)
|
||||
}
|
||||
if rows.Next() {
|
||||
t.Fatalf("expected empty result set, got at least one row")
|
||||
}
|
||||
})
|
||||
|
||||
populatedRef := dorisObjectName(dorisCatalogName, namespace, populatedTable)
|
||||
|
||||
t.Run("ReadWrittenDataCount", func(t *testing.T) {
|
||||
var count int64
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", populatedRef)
|
||||
if err := db.QueryRow(query).Scan(&count); err != nil {
|
||||
t.Fatalf("%s: %v", query, err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("count(%s) = %d, want 3", populatedRef, count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ReadWrittenDataValues", func(t *testing.T) {
|
||||
query := fmt.Sprintf("SELECT id, label FROM %s ORDER BY id", populatedRef)
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", query, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
expected := [][2]string{{"1", "one"}, {"2", "two"}, {"3", "three"}}
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var label sql.NullString
|
||||
if err := rows.Scan(&id, &label); err != nil {
|
||||
t.Fatalf("Scan row %d: %v", i, err)
|
||||
}
|
||||
if i >= len(expected) {
|
||||
t.Fatalf("got more than %d rows from %s", len(expected), populatedRef)
|
||||
}
|
||||
gotID := fmt.Sprintf("%d", id)
|
||||
gotLabel := label.String
|
||||
if gotID != expected[i][0] || gotLabel != expected[i][1] {
|
||||
t.Errorf("row %d = (%s, %s), want (%s, %s)",
|
||||
i, gotID, gotLabel, expected[i][0], expected[i][1])
|
||||
}
|
||||
i++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows.Err: %v", err)
|
||||
}
|
||||
if i != len(expected) {
|
||||
t.Fatalf("expected %d rows, got %d", len(expected), i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// NewTestEnvironment allocates ports and returns an environment for the test.
|
||||
func NewTestEnvironment(t *testing.T) *TestEnvironment {
|
||||
t.Helper()
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
seaweedDir := wd
|
||||
for i := 0; i < 6; i++ {
|
||||
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
seaweedDir = filepath.Dir(seaweedDir)
|
||||
}
|
||||
|
||||
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
|
||||
info, err := os.Stat(weedBinary)
|
||||
if err != nil || info.IsDir() {
|
||||
weedBinary = filepath.Join(seaweedDir, "weed", "weed", "weed")
|
||||
info, err = os.Stat(weedBinary)
|
||||
if err != nil || info.IsDir() {
|
||||
weedBinary = "weed"
|
||||
if _, err := exec.LookPath(weedBinary); err != nil {
|
||||
t.Skip("weed binary not found, skipping integration test")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dataDir, err := os.MkdirTemp("", "seaweed-doris-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
bindIP := testutil.FindBindIP()
|
||||
// 9 ports for the seaweed mini cluster, plus one for the Doris MySQL
|
||||
// query port mapped on the host.
|
||||
ports := testutil.MustAllocatePorts(t, 10)
|
||||
|
||||
env := &TestEnvironment{
|
||||
seaweedDir: seaweedDir,
|
||||
weedBinary: weedBinary,
|
||||
dataDir: dataDir,
|
||||
bindIP: bindIP,
|
||||
masterPort: ports[0],
|
||||
masterGrpcPort: ports[1],
|
||||
volumePort: ports[2],
|
||||
volumeGrpcPort: ports[3],
|
||||
filerPort: ports[4],
|
||||
filerGrpcPort: ports[5],
|
||||
s3Port: ports[6],
|
||||
s3GrpcPort: ports[7],
|
||||
icebergPort: ports[8],
|
||||
dorisHostQueryPort: ports[9],
|
||||
}
|
||||
|
||||
env.accessKey = "AKIAIOSFODNN7EXAMPLE"
|
||||
env.secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
// StartSeaweedFS starts a SeaweedFS mini instance with the Iceberg REST API.
|
||||
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
iamConfigPath, err := testutil.WriteIAMConfig(env.dataDir, env.accessKey, env.secretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create IAM config: %v", err)
|
||||
}
|
||||
|
||||
securityToml := filepath.Join(env.dataDir, "security.toml")
|
||||
if err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create security.toml: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
env.weedCancel = cancel
|
||||
|
||||
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
|
||||
"-master.port", fmt.Sprintf("%d", env.masterPort),
|
||||
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
|
||||
"-volume.port", fmt.Sprintf("%d", env.volumePort),
|
||||
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
|
||||
"-filer.port", fmt.Sprintf("%d", env.filerPort),
|
||||
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
|
||||
"-s3.port", fmt.Sprintf("%d", env.s3Port),
|
||||
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
|
||||
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergPort),
|
||||
"-s3.config", iamConfigPath,
|
||||
"-ip", env.bindIP,
|
||||
"-ip.bind", "0.0.0.0",
|
||||
"-dir", env.dataDir,
|
||||
)
|
||||
cmd.Dir = env.dataDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
cmd.Env = append(os.Environ(),
|
||||
"AWS_ACCESS_KEY_ID="+env.accessKey,
|
||||
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
|
||||
"ICEBERG_WAREHOUSE=s3://iceberg-tables",
|
||||
"S3TABLES_DEFAULT_BUCKET=iceberg-tables",
|
||||
)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("Failed to start SeaweedFS: %v", err)
|
||||
}
|
||||
env.weedProcess = cmd
|
||||
|
||||
icebergURL := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
|
||||
if !env.waitForService(icebergURL, 30*time.Second) {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(icebergURL)
|
||||
if err != nil {
|
||||
t.Logf("WARNING: Could not connect to Iceberg service at %s: %v", icebergURL, err)
|
||||
} else {
|
||||
t.Logf("WARNING: Iceberg service returned status %d at %s", resp.StatusCode, icebergURL)
|
||||
resp.Body.Close()
|
||||
}
|
||||
t.Fatalf("Iceberg REST API did not become ready")
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup stops Doris, SeaweedFS, and removes temporary state.
|
||||
func (env *TestEnvironment) Cleanup(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if env.dorisContainer != "" {
|
||||
_ = exec.Command("docker", "rm", "-f", env.dorisContainer).Run()
|
||||
}
|
||||
|
||||
if env.weedCancel != nil {
|
||||
env.weedCancel()
|
||||
}
|
||||
|
||||
if env.weedProcess != nil {
|
||||
time.Sleep(2 * time.Second)
|
||||
_ = env.weedProcess.Wait()
|
||||
}
|
||||
|
||||
if env.dataDir != "" {
|
||||
_ = os.RemoveAll(env.dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForService polls a URL until it returns a 2xx/4xx status or timeout.
|
||||
func (env *TestEnvironment) waitForService(url string, timeout time.Duration) bool {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
statusCode := resp.StatusCode
|
||||
resp.Body.Close()
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
return true
|
||||
}
|
||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||
return true
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// testIcebergRestAPI verifies the Iceberg REST endpoint is reachable.
|
||||
func testIcebergRestAPI(t *testing.T, env *TestEnvironment) {
|
||||
t.Helper()
|
||||
fmt.Printf(">>> Testing Iceberg REST API directly...\n")
|
||||
|
||||
addr := net.JoinHostPort(env.bindIP, fmt.Sprintf("%d", env.icebergPort))
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatalf("Cannot connect to Iceberg service at %s: %v", addr, err)
|
||||
}
|
||||
conn.Close()
|
||||
t.Logf("Successfully connected to Iceberg service at %s", addr)
|
||||
|
||||
url := fmt.Sprintf("http://%s:%d/v1/config", env.bindIP, env.icebergPort)
|
||||
t.Logf("Testing Iceberg REST API at %s", url)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect to Iceberg REST API at %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("Expected 200 OK from /v1/config, got %d, body: %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// startDorisContainer launches the Apache Doris all-in-one image and exposes
|
||||
// only the FE MySQL/SQL port to the host. The Iceberg REST and S3 endpoints
|
||||
// are reached via host.docker.internal, matching the Dremio/Trino paths.
|
||||
func (env *TestEnvironment) startDorisContainer(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
containerName := "seaweed-doris-" + randomString(8)
|
||||
env.dorisContainer = containerName
|
||||
|
||||
cmd := exec.Command("docker", "run", "-d",
|
||||
"--name", containerName,
|
||||
"--add-host", "host.docker.internal:host-gateway",
|
||||
"-p", fmt.Sprintf("%d:%d", env.dorisHostQueryPort, dorisQueryPort),
|
||||
"-e", "AWS_ACCESS_KEY_ID="+env.accessKey,
|
||||
"-e", "AWS_SECRET_ACCESS_KEY="+env.secretKey,
|
||||
"-e", "AWS_REGION=us-west-2",
|
||||
dorisImage,
|
||||
)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("Failed to start Doris container: %v\n%s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
// dorisContainerLogs returns the tail of Doris's container logs for diagnostics.
|
||||
func dorisContainerLogs(containerName string) string {
|
||||
cmd := exec.Command("docker", "logs", "--tail", "200", containerName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("(failed to fetch docker logs: %v)\n%s", err, string(output))
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
// waitForDoris polls Doris's MySQL query port until it accepts SELECT 1.
|
||||
// The all-in-one image takes ~30-60s to boot the FE and BE; we poll long
|
||||
// enough to absorb cold-start variance in CI.
|
||||
func (env *TestEnvironment) waitForDoris(t *testing.T, timeout time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf("root:@tcp(127.0.0.1:%d)/", env.dorisHostQueryPort)
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
if !containerRunning(env.dorisContainer) {
|
||||
t.Fatalf("Doris container exited before becoming ready\nContainer logs:\n%s",
|
||||
dorisContainerLogs(env.dorisContainer))
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", dsn+"?timeout=2s&readTimeout=5s&writeTimeout=5s")
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
var v int
|
||||
err = db.QueryRowContext(ctx, "SELECT 1").Scan(&v)
|
||||
cancel()
|
||||
db.Close()
|
||||
if err == nil && v == 1 {
|
||||
// Doris reports the FE is up before the BE has registered. A
|
||||
// catalog SHOW CATALOGS still works without BEs, but a REFRESH
|
||||
// CATALOG against an external source needs at least one live
|
||||
// BE. Wait for one to appear so subsequent subtests don't race.
|
||||
if env.waitForDorisBackend(t, 60*time.Second) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
t.Fatalf("Timed out waiting for Doris to be ready\nLast error: %v\nContainer logs:\n%s",
|
||||
lastErr, dorisContainerLogs(env.dorisContainer))
|
||||
}
|
||||
|
||||
// waitForDorisBackend polls SHOW BACKENDS until at least one BE reports Alive=true.
|
||||
func (env *TestEnvironment) waitForDorisBackend(t *testing.T, timeout time.Duration) bool {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf("root:@tcp(127.0.0.1:%d)/?timeout=2s&readTimeout=5s&writeTimeout=5s",
|
||||
env.dorisHostQueryPort)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
alive, err := dorisHasAliveBackend(db)
|
||||
if err == nil && alive {
|
||||
return true
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dorisHasAliveBackend returns true if any row in SHOW BACKENDS has Alive="true".
|
||||
func dorisHasAliveBackend(db *sql.DB) (bool, error) {
|
||||
rows, err := db.Query("SHOW BACKENDS")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
aliveIdx := -1
|
||||
for i, c := range cols {
|
||||
if strings.EqualFold(c, "Alive") {
|
||||
aliveIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if aliveIdx < 0 {
|
||||
return false, fmt.Errorf("SHOW BACKENDS has no Alive column: %v", cols)
|
||||
}
|
||||
|
||||
values := make([]sql.NullString, len(cols))
|
||||
scanArgs := make([]interface{}, len(cols))
|
||||
for i := range values {
|
||||
scanArgs[i] = &values[i]
|
||||
}
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(scanArgs...); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if values[aliveIdx].Valid && strings.EqualFold(values[aliveIdx].String, "true") {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
// containerRunning returns true if the named container is in `running` state.
|
||||
func containerRunning(containerName string) bool {
|
||||
cmd := exec.Command("docker", "inspect", "--format", "{{.State.Running}}", containerName)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(out)) == "true"
|
||||
}
|
||||
|
||||
// connectDoris opens a long-lived MySQL-protocol connection to Doris.
|
||||
func (env *TestEnvironment) connectDoris(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf("root:@tcp(127.0.0.1:%d)/?parseTime=true&timeout=10s&readTimeout=60s&writeTimeout=60s",
|
||||
env.dorisHostQueryPort)
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open(mysql): %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(2)
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
t.Fatalf("Doris ping failed: %v\nContainer logs:\n%s", err, dorisContainerLogs(env.dorisContainer))
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// createDorisIcebergCatalog registers a Doris EXTERNAL CATALOG of type=iceberg
|
||||
// pointing at the SeaweedFS REST endpoint. Doris reuses Iceberg's standard
|
||||
// REST client, so OAuth2 client credentials are passed via "credential".
|
||||
func (env *TestEnvironment) createDorisIcebergCatalog(t *testing.T, db *sql.DB, warehouseBucket string) {
|
||||
t.Helper()
|
||||
|
||||
icebergURI := fmt.Sprintf("http://host.docker.internal:%d", env.icebergPort)
|
||||
s3Endpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
|
||||
credential := env.accessKey + ":" + env.secretKey
|
||||
|
||||
// Drop first so a stale state from a previous run does not poison
|
||||
// metadata caching. Doris versions before 2.1 use IF EXISTS at the end.
|
||||
if _, err := db.Exec(fmt.Sprintf("DROP CATALOG IF EXISTS %s", quoteDorisIdent(dorisCatalogName))); err != nil {
|
||||
t.Logf("DROP CATALOG IF EXISTS %s: %v (continuing)", dorisCatalogName, err)
|
||||
}
|
||||
|
||||
createSQL := fmt.Sprintf(`CREATE CATALOG %s PROPERTIES (
|
||||
"type" = "iceberg",
|
||||
"iceberg.catalog.type" = "rest",
|
||||
"uri" = %q,
|
||||
"warehouse" = %q,
|
||||
"credential" = %q,
|
||||
"s3.endpoint" = %q,
|
||||
"s3.access_key" = %q,
|
||||
"s3.secret_key" = %q,
|
||||
"s3.region" = "us-west-2",
|
||||
"use_path_style" = "true"
|
||||
)`,
|
||||
quoteDorisIdent(dorisCatalogName),
|
||||
icebergURI,
|
||||
"s3://"+warehouseBucket,
|
||||
credential,
|
||||
s3Endpoint,
|
||||
env.accessKey,
|
||||
env.secretKey,
|
||||
)
|
||||
if _, err := db.Exec(createSQL); err != nil {
|
||||
t.Fatalf("CREATE CATALOG %s failed: %v\nContainer logs:\n%s",
|
||||
dorisCatalogName, err, dorisContainerLogs(env.dorisContainer))
|
||||
}
|
||||
|
||||
// Refresh so namespaces/tables seeded via the REST API right before this
|
||||
// step are visible to the first SHOW DATABASES query.
|
||||
if _, err := db.Exec(fmt.Sprintf("REFRESH CATALOG %s", quoteDorisIdent(dorisCatalogName))); err != nil {
|
||||
t.Logf("REFRESH CATALOG %s: %v (continuing)", dorisCatalogName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// rowsContain runs query and returns true if any value of any column equals
|
||||
// (case-insensitively) the wanted token. Doris's SHOW family returns
|
||||
// version-dependent column counts, so we don't pin to a specific column.
|
||||
func rowsContain(t *testing.T, db *sql.DB, query, want string) bool {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", query, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
t.Fatalf("Columns(): %v", err)
|
||||
}
|
||||
values := make([]sql.NullString, len(cols))
|
||||
scanArgs := make([]interface{}, len(cols))
|
||||
for i := range values {
|
||||
scanArgs[i] = &values[i]
|
||||
}
|
||||
wantLower := strings.ToLower(want)
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(scanArgs...); err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
for _, v := range values {
|
||||
if v.Valid && strings.Contains(strings.ToLower(v.String), wantLower) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows.Err: %v", err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// equalStringSlicesIgnoreCase compares two []string for equality, ignoring case.
|
||||
func equalStringSlicesIgnoreCase(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range got {
|
||||
if !strings.EqualFold(got[i], want[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// quoteDorisIdent wraps an identifier in backticks (Doris uses MySQL syntax),
|
||||
// escaping any embedded backticks.
|
||||
func quoteDorisIdent(name string) string {
|
||||
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||
}
|
||||
|
||||
// dorisObjectName produces a backtick-quoted dotted reference like
|
||||
// `catalog`.`namespace`.`table` so identifiers with hyphens or random
|
||||
// suffixes parse without quoting issues.
|
||||
func dorisObjectName(parts ...string) string {
|
||||
quoted := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
quoted = append(quoted, quoteDorisIdent(part))
|
||||
}
|
||||
return strings.Join(quoted, ".")
|
||||
}
|
||||
|
||||
// requestIcebergOAuthToken requests an OAuth2 client_credentials token from
|
||||
// the SeaweedFS Iceberg REST catalog. Used to seed the catalog with a
|
||||
// namespace and table directly through the REST API before Doris connects.
|
||||
func requestIcebergOAuthToken(t *testing.T, env *TestEnvironment) string {
|
||||
t.Helper()
|
||||
|
||||
resp, err := http.PostForm(fmt.Sprintf("http://%s:%d/v1/oauth/tokens", env.bindIP, env.icebergPort), url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {env.accessKey},
|
||||
"client_secret": {env.secretKey},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("POST /v1/oauth/tokens: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("OAuth token request failed: status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
t.Fatalf("decode token response: %v", err)
|
||||
}
|
||||
if tokenResp.AccessToken == "" {
|
||||
t.Fatal("got empty access_token")
|
||||
}
|
||||
return tokenResp.AccessToken
|
||||
}
|
||||
|
||||
// createIcebergNamespace creates a single-level Iceberg namespace through
|
||||
// the REST catalog. Wrapper around createIcebergNamespaceLevels for the
|
||||
// common single-level case.
|
||||
func createIcebergNamespace(t *testing.T, env *TestEnvironment, token, bucketName, namespace string) {
|
||||
t.Helper()
|
||||
createIcebergNamespaceLevels(t, env, token, bucketName, []string{namespace})
|
||||
}
|
||||
|
||||
// createIcebergTable creates a table inside a single-level namespace through
|
||||
// the REST catalog. The table is created with the canonical
|
||||
// (id long not null, label string nullable) schema used by all subtests.
|
||||
func createIcebergTable(t *testing.T, env *TestEnvironment, token, bucketName, namespace, tableName string) {
|
||||
t.Helper()
|
||||
createIcebergTableInLevels(t, env, token, bucketName, []string{namespace}, tableName)
|
||||
}
|
||||
|
||||
// createIcebergNamespaceLevels creates a (possibly multi-level) Iceberg
|
||||
// namespace via the REST catalog.
|
||||
func createIcebergNamespaceLevels(t *testing.T, env *TestEnvironment, token, bucketName string, levels []string) {
|
||||
t.Helper()
|
||||
|
||||
doIcebergJSONRequest(t, env, token, http.MethodPost, fmt.Sprintf("/v1/%s/namespaces", url.PathEscape(bucketName)), map[string]any{
|
||||
"namespace": levels,
|
||||
}, http.StatusOK, http.StatusConflict)
|
||||
}
|
||||
|
||||
// createIcebergTableInLevels creates a table inside the given namespace levels.
|
||||
// The namespace path component is encoded with the unit-separator (0x1F)
|
||||
// convention used by the SeaweedFS Iceberg REST API.
|
||||
func createIcebergTableInLevels(t *testing.T, env *TestEnvironment, token, bucketName string, levels []string, tableName string) {
|
||||
t.Helper()
|
||||
|
||||
encodedNs := strings.Join(levels, "\x1F")
|
||||
doIcebergJSONRequest(t, env, token, http.MethodPost,
|
||||
fmt.Sprintf("/v1/%s/namespaces/%s/tables", url.PathEscape(bucketName), url.PathEscape(encodedNs)),
|
||||
map[string]any{
|
||||
"name": tableName,
|
||||
"schema": map[string]any{
|
||||
"type": "struct",
|
||||
"schema-id": 0,
|
||||
"fields": []map[string]any{
|
||||
{"id": 1, "name": "id", "required": true, "type": "long"},
|
||||
{"id": 2, "name": "label", "required": false, "type": "string"},
|
||||
},
|
||||
},
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
const dorisWriterImage = "seaweedfs-doris-writer"
|
||||
|
||||
// buildDorisWriterImage builds the local PyIceberg writer image. Layer caching
|
||||
// makes repeat invocations cheap; the first build pulls python:3.11-slim and
|
||||
// pip-installs pyiceberg+pyarrow (~1-2 min in CI).
|
||||
func buildDorisWriterImage(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("docker", "build",
|
||||
"-t", dorisWriterImage,
|
||||
"-f", filepath.Join(wd, "Dockerfile.writer"),
|
||||
wd,
|
||||
)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("Failed to build %s image: %v\n%s", dorisWriterImage, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// writeIcebergRows runs the PyIceberg writer container, which loads the
|
||||
// already-created table and appends three rows.
|
||||
func writeIcebergRows(t *testing.T, env *TestEnvironment, bucketName string, namespace []string, tableName string) {
|
||||
t.Helper()
|
||||
|
||||
args := []string{
|
||||
"run", "--rm",
|
||||
"--add-host", "host.docker.internal:host-gateway",
|
||||
dorisWriterImage,
|
||||
"--catalog-url", fmt.Sprintf("http://host.docker.internal:%d", env.icebergPort),
|
||||
"--warehouse", "s3://" + bucketName,
|
||||
"--prefix", bucketName,
|
||||
"--s3-endpoint", fmt.Sprintf("http://host.docker.internal:%d", env.s3Port),
|
||||
"--access-key", env.accessKey,
|
||||
"--secret-key", env.secretKey,
|
||||
"--region", "us-west-2",
|
||||
"--table", tableName,
|
||||
}
|
||||
for _, level := range namespace {
|
||||
args = append(args, "--namespace", level)
|
||||
}
|
||||
|
||||
cmd := exec.Command("docker", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("PyIceberg writer failed: %v\n%s", err, out)
|
||||
}
|
||||
t.Logf("PyIceberg writer output: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// doIcebergJSONRequest issues an authenticated JSON request to the Iceberg
|
||||
// REST endpoint and returns the response body. It fails the test unless the
|
||||
// response status matches one of expectedStatuses. Used by namespace and
|
||||
// table seeding so the tests can fail fast with a useful error if the REST
|
||||
// API rejects a setup call.
|
||||
func doIcebergJSONRequest(t *testing.T, env *TestEnvironment, token, method, path string, payload any, expectedStatuses ...int) string {
|
||||
t.Helper()
|
||||
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal Iceberg request: %v", err)
|
||||
}
|
||||
body = bytes.NewReader(payloadBytes)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, fmt.Sprintf("http://%s:%d%s", env.bindIP, env.icebergPort, path), body)
|
||||
if err != nil {
|
||||
t.Fatalf("create Iceberg request: %v", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Iceberg request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
for _, expectedStatus := range expectedStatuses {
|
||||
if resp.StatusCode == expectedStatus {
|
||||
return string(respBody)
|
||||
}
|
||||
}
|
||||
t.Fatalf("Iceberg request returned unexpected status %d, want %v\nPath: %s\nBody: %s",
|
||||
resp.StatusCode, expectedStatuses, path, respBody)
|
||||
return ""
|
||||
}
|
||||
|
||||
// createTableBucket creates an S3 table bucket using `weed shell`, which
|
||||
// talks to the master over gRPC and bypasses the S3 SigV4 path so the test
|
||||
// works whether IAM is enabled or not. The `-master` flag uses SeaweedFS's
|
||||
// canonical `host:port.grpcPort` ServerAddress format produced by
|
||||
// pb.NewServerAddress (see weed/pb/server_address.go) — the dot is the
|
||||
// separator between the HTTP port and the gRPC port and is required, not
|
||||
// a typo. Replacing it with a colon would make the parser treat the gRPC
|
||||
// port as the HTTP port and synthesize the wrong gRPC port.
|
||||
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, env.weedBinary, "shell",
|
||||
fmt.Sprintf("-master=%s:%d.%d", env.bindIP, env.masterPort, env.masterGrpcPort),
|
||||
)
|
||||
cmd.Stdin = strings.NewReader(fmt.Sprintf("s3tables.bucket -create -name %s -account 000000000000\nexit\n", bucketName))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create table bucket %s via weed shell: %v\nOutput: %s", bucketName, err, string(output))
|
||||
}
|
||||
t.Logf("Created table bucket: %s", bucketName)
|
||||
}
|
||||
|
||||
// requireDorisRuntime skips the test in `-short` mode or when Docker isn't
|
||||
// available, since the test cannot run without the Doris container.
|
||||
func requireDorisRuntime(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
if !hasDocker() {
|
||||
t.Skip("Docker not available, skipping Doris integration test")
|
||||
}
|
||||
}
|
||||
|
||||
// hasDocker reports whether `docker version` can run, which we treat as a
|
||||
// sufficient signal that a Docker daemon is reachable from this process.
|
||||
func hasDocker() bool {
|
||||
cmd := exec.Command("docker", "version")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// randomString returns a lowercase alphanumeric string of the given length.
|
||||
func randomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("failed to generate random string: " + err.Error())
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
"deltalake==0.21.0" \
|
||||
"pyarrow==17.0.0" \
|
||||
"requests==2.32.3"
|
||||
|
||||
WORKDIR /app
|
||||
COPY delta_rs_writer.py /app/delta_rs_writer.py
|
||||
|
||||
ENTRYPOINT ["python3", "/app/delta_rs_writer.py"]
|
||||
@@ -0,0 +1,104 @@
|
||||
# Unity Catalog OSS integration tests
|
||||
|
||||
These tests run Unity Catalog OSS in Docker against an embedded SeaweedFS
|
||||
S3 endpoint. The `server.properties` mirrors the upstream playground at
|
||||
[`mds-in-a-box/unitycatalog-playground`](https://github.com/data-engineering-helpers/mds-in-a-box/tree/main/unitycatalog-playground).
|
||||
|
||||
| Test | Variant | Status |
|
||||
| --- | --- | --- |
|
||||
| `TestUnityCatalogDeltaIntegration` | static keys, `aws.masterRoleArn=` empty | passes; covers catalog/schema/EXTERNAL Delta CRUD against SeaweedFS-backed warehouse and asserts that UC's `/temporary-table-credentials` *cannot* vend usable creds with this configuration -- exactly the gap the playground reports. |
|
||||
| `TestUnityCatalogMasterRoleIntegration` | `aws.masterRoleArn=arn:aws:iam::000000000000:role/UnityCatalogVendedRole` | passes; proves SeaweedFS' STS endpoint accepts `sts:AssumeRole` for the role UC would use (Go SDK round-trip), and that UC starts and accepts CRUD when wired with the master-role config. UC's own StsClient still talks to real AWS regardless of `aws.endpoint` / `AWS_ENDPOINT_URL_STS` (UC bug, see below); that hop is logged via `t.Logf` rather than asserted. |
|
||||
| `TestUnityCatalogDeltaRsRoundTrip` | static keys + `delta-rs` Python client | passes; resolves table metadata through UC and writes/reads a real Delta table at the registered `storage_location` using `python:3.11-slim + deltalake` with the SeaweedFS test credentials. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker available locally (the tests call `docker run` / `docker build` directly).
|
||||
- A `weed` binary at the repo root (`weed/weed`) or on `$PATH`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
go test -timeout 15m \
|
||||
-run 'TestUnityCatalog' \
|
||||
./test/s3tables/unity_catalog/...
|
||||
```
|
||||
|
||||
Pin a specific Unity Catalog image (defaults to
|
||||
`unitycatalog/unitycatalog:v0.4.0`):
|
||||
|
||||
```bash
|
||||
UC_IMAGE=unitycatalog/unitycatalog:main \
|
||||
go test -timeout 15m -run TestUnityCatalogDeltaIntegration \
|
||||
./test/s3tables/unity_catalog/...
|
||||
```
|
||||
|
||||
The tests self-skip when Docker is unavailable or no `weed` binary is on
|
||||
the path; running under `-short` also skips them.
|
||||
|
||||
## Why the static-key path can't vend usable creds
|
||||
|
||||
UC OSS' `AwsCredentialVendor.createPerBucketCredentialGenerator`:
|
||||
|
||||
```java
|
||||
if (config.getSessionToken() != null && !config.getSessionToken().isEmpty()) {
|
||||
return new AwsCredentialGenerator.StaticAwsCredentialGenerator(config);
|
||||
}
|
||||
return createStsCredentialGenerator(config);
|
||||
```
|
||||
|
||||
With `aws.masterRoleArn=` empty and `s3.sessionToken.0=` empty (this
|
||||
test's configuration), `/temporary-table-credentials` short-circuits with
|
||||
`"S3 bucket configuration not found."` before UC fires any STS call.
|
||||
Setting a stub `s3.sessionToken.0` switches UC to
|
||||
`StaticAwsCredentialGenerator` and the endpoint returns the static keys,
|
||||
but the response carries that stub session token -- SeaweedFS won't
|
||||
recognize it on the next S3 call, so the vended creds aren't usable for
|
||||
table I/O. Clients have to fall back to the static keys directly.
|
||||
|
||||
With `aws.masterRoleArn` set, UC's `AwsCredentialGenerator.StsAwsCredentialGenerator`
|
||||
builds the StsClient with only `.region(...)` and `.credentialsProvider(...)` --
|
||||
no `.endpointOverride()`. The SDK's generic env-var resolution doesn't kick in
|
||||
for that builder shape, so even with `AWS_ENDPOINT_URL_STS=...` (or the
|
||||
matching `aws.endpointUrlSts` Java property, or the catch-all
|
||||
`AWS_ENDPOINT_URL=...`) the StsClient still targets real AWS and gets back
|
||||
`InvalidClientTokenId`. Verified by pointing the env var at port 1: UC reports
|
||||
the same AWS-issued 403 that it reports against SeaweedFS, and a sniffer in
|
||||
front of SeaweedFS' STS port records zero traffic. SeaweedFS' STS handler
|
||||
itself works -- the Go SDK round-trip in `assumeRoleViaSeaweedFS` proves that
|
||||
against the same SeaweedFS instance.
|
||||
|
||||
UC's own AWS credential-vending tests don't catch this because they mock
|
||||
`StsClient` away entirely -- `BaseCRUDTestWithMockCredentials` injects a
|
||||
custom `stsClientBuilderSupplier` returning an `EchoAwsStsClient` that
|
||||
synthesizes credentials in-process, and `CloudCredentialVendorTest` uses
|
||||
`Mockito.mockStatic(StsClient.class)`. No test ever exercises the wire
|
||||
path between UC's Java SDK and a real STS endpoint, so the missing
|
||||
`endpointOverride` slipped through.
|
||||
|
||||
Fix is upstream in
|
||||
[unitycatalog/unitycatalog#1532](https://github.com/unitycatalog/unitycatalog/pull/1532),
|
||||
which adds an `aws.endpoint` property and applies it to both the StsClient
|
||||
and the S3Client builders. Until that lands, the master-role test logs
|
||||
the failure but does not assert it.
|
||||
|
||||
## What the tests actually validate today
|
||||
|
||||
- Unity Catalog accepts a SeaweedFS-backed `server.properties` and starts.
|
||||
- Catalog / schema / EXTERNAL Delta table CRUD all work against the
|
||||
SeaweedFS warehouse via the UC REST API.
|
||||
- SeaweedFS' STS endpoint correctly issues `sts:AssumeRole` credentials
|
||||
for the `UnityCatalogVendedRole` and those credentials are accepted on
|
||||
S3 round-trips (Go AWS SDK).
|
||||
- Delta-RS resolves a UC table's `storage_location` and can write/read Delta
|
||||
data through the SeaweedFS S3 endpoint with the test credentials.
|
||||
|
||||
## What is still pending
|
||||
|
||||
Nothing on the SeaweedFS side. The remaining gap (UC's StsClient ignoring
|
||||
endpoint config) needs a UC OSS patch upstream.
|
||||
|
||||
## MANAGED tables
|
||||
|
||||
Not exercised. UC OSS gates them behind `server.managed-table.enabled=true`
|
||||
and a two-step staging flow (`POST /staging-tables` then `POST /tables`);
|
||||
EXTERNAL Delta is the simpler path and what these tests cover.
|
||||
@@ -0,0 +1,184 @@
|
||||
package unity_catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ucClient struct {
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newUCClient(base string) *ucClient {
|
||||
return &ucClient{base: base + ucAPIBase, http: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
type ucCreateCatalog struct {
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
StorageRoot string `json:"storage_root,omitempty"`
|
||||
}
|
||||
|
||||
type ucCatalogInfo struct {
|
||||
Name string `json:"name"`
|
||||
StorageRoot string `json:"storage_root"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type ucCreateSchema struct {
|
||||
Name string `json:"name"`
|
||||
CatalogName string `json:"catalog_name"`
|
||||
}
|
||||
|
||||
type ucSchemaInfo struct {
|
||||
Name string `json:"name"`
|
||||
CatalogName string `json:"catalog_name"`
|
||||
FullName string `json:"full_name"`
|
||||
SchemaID string `json:"schema_id"`
|
||||
}
|
||||
|
||||
type ucColumn struct {
|
||||
Name string `json:"name"`
|
||||
TypeText string `json:"type_text"`
|
||||
TypeJSON string `json:"type_json,omitempty"`
|
||||
TypeName string `json:"type_name"`
|
||||
Position int `json:"position"`
|
||||
Nullable bool `json:"nullable"`
|
||||
}
|
||||
|
||||
type ucCreateTable struct {
|
||||
Name string `json:"name"`
|
||||
CatalogName string `json:"catalog_name"`
|
||||
SchemaName string `json:"schema_name"`
|
||||
TableType string `json:"table_type"`
|
||||
DataSourceFormat string `json:"data_source_format"`
|
||||
Columns []ucColumn `json:"columns"`
|
||||
StorageLocation string `json:"storage_location,omitempty"`
|
||||
}
|
||||
|
||||
type ucTableInfo struct {
|
||||
Name string `json:"name"`
|
||||
CatalogName string `json:"catalog_name"`
|
||||
SchemaName string `json:"schema_name"`
|
||||
TableType string `json:"table_type"`
|
||||
DataSourceFormat string `json:"data_source_format"`
|
||||
Columns []ucColumn `json:"columns"`
|
||||
StorageLocation string `json:"storage_location"`
|
||||
TableID string `json:"table_id"`
|
||||
}
|
||||
|
||||
type ucListTablesResp struct {
|
||||
Tables []ucTableInfo `json:"tables"`
|
||||
}
|
||||
|
||||
type ucAwsCreds struct {
|
||||
AccessKeyID string `json:"access_key_id"`
|
||||
SecretAccessKey string `json:"secret_access_key"`
|
||||
SessionToken string `json:"session_token"`
|
||||
}
|
||||
|
||||
type ucTempCreds struct {
|
||||
AwsTempCredentials *ucAwsCreds `json:"aws_temp_credentials"`
|
||||
ExpirationTime int64 `json:"expiration_time"`
|
||||
}
|
||||
|
||||
func (c *ucClient) createCatalog(ctx context.Context, in ucCreateCatalog) (*ucCatalogInfo, error) {
|
||||
var out ucCatalogInfo
|
||||
return &out, c.do(ctx, http.MethodPost, "/catalogs", in, &out)
|
||||
}
|
||||
|
||||
func (c *ucClient) deleteCatalog(ctx context.Context, name string) error {
|
||||
q := url.Values{"force": []string{"true"}}
|
||||
return c.do(ctx, http.MethodDelete, "/catalogs/"+url.PathEscape(name)+"?"+q.Encode(), nil, nil)
|
||||
}
|
||||
|
||||
func (c *ucClient) createSchema(ctx context.Context, in ucCreateSchema) (*ucSchemaInfo, error) {
|
||||
var out ucSchemaInfo
|
||||
return &out, c.do(ctx, http.MethodPost, "/schemas", in, &out)
|
||||
}
|
||||
|
||||
func (c *ucClient) deleteSchema(ctx context.Context, fullName string) error {
|
||||
q := url.Values{"force": []string{"true"}}
|
||||
return c.do(ctx, http.MethodDelete, "/schemas/"+url.PathEscape(fullName)+"?"+q.Encode(), nil, nil)
|
||||
}
|
||||
|
||||
func (c *ucClient) createTable(ctx context.Context, in ucCreateTable) (*ucTableInfo, error) {
|
||||
var out ucTableInfo
|
||||
return &out, c.do(ctx, http.MethodPost, "/tables", in, &out)
|
||||
}
|
||||
|
||||
func (c *ucClient) getTable(ctx context.Context, fullName string) (*ucTableInfo, error) {
|
||||
var out ucTableInfo
|
||||
return &out, c.do(ctx, http.MethodGet, "/tables/"+url.PathEscape(fullName), nil, &out)
|
||||
}
|
||||
|
||||
func (c *ucClient) listTables(ctx context.Context, catalog, schema string) ([]ucTableInfo, error) {
|
||||
var out ucListTablesResp
|
||||
q := url.Values{
|
||||
"catalog_name": []string{catalog},
|
||||
"schema_name": []string{schema},
|
||||
}
|
||||
path := "/tables?" + q.Encode()
|
||||
if err := c.do(ctx, http.MethodGet, path, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Tables, nil
|
||||
}
|
||||
|
||||
func (c *ucClient) deleteTable(ctx context.Context, fullName string) error {
|
||||
return c.do(ctx, http.MethodDelete, "/tables/"+url.PathEscape(fullName), nil, nil)
|
||||
}
|
||||
|
||||
func (c *ucClient) generateTemporaryTableCredentials(ctx context.Context, tableID, op string) (*ucTempCreds, error) {
|
||||
body := map[string]string{"table_id": tableID, "operation": op}
|
||||
var out ucTempCreds
|
||||
return &out, c.do(ctx, http.MethodPost, "/temporary-table-credentials", body, &out)
|
||||
}
|
||||
|
||||
func (c *ucClient) do(ctx context.Context, method, path string, in any, out any) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
buf, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s %s: %w", method, path, err)
|
||||
}
|
||||
body = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// 10 MiB cap is well above any reasonable Unity Catalog response and
|
||||
// keeps a runaway server from OOMing the test runner.
|
||||
respBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s %s: %w", method, path, err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(respBytes)))
|
||||
}
|
||||
if out == nil || len(respBytes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(respBytes, out); err != nil {
|
||||
return fmt.Errorf("decode %s %s: %w (body=%s)", method, path, err, string(respBytes))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package unity_catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
)
|
||||
|
||||
// TestUnityCatalogDeltaRsRoundTrip writes and reads a real Delta table at
|
||||
// the registered storage_location using the delta-rs Python library inside
|
||||
// a Docker container. The script resolves table metadata from UC and uses the
|
||||
// test SeaweedFS credentials as delta-rs storage_options.
|
||||
func TestUnityCatalogDeltaRsRoundTrip(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
if !testutil.HasDocker() {
|
||||
t.Skip("docker not available")
|
||||
}
|
||||
|
||||
env := newTestEnv(t)
|
||||
defer env.cleanup(t)
|
||||
|
||||
t.Log(">>> starting SeaweedFS for delta-rs test...")
|
||||
env.startSeaweedFS(t, "")
|
||||
t.Log(">>> SeaweedFS ready")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
s3c := env.newHostS3Client(t, ctx)
|
||||
if _, err := s3c.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(ucWarehouse)}); err != nil {
|
||||
t.Fatalf("create warehouse bucket: %v", err)
|
||||
}
|
||||
|
||||
t.Log(">>> starting Unity Catalog server...")
|
||||
env.startUnityCatalog(t, ctx, ucServerOpts{})
|
||||
t.Log(">>> Unity Catalog ready")
|
||||
|
||||
uc := newUCClient(fmt.Sprintf("http://127.0.0.1:%d", env.ucHostPort))
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
catalogName := fmt.Sprintf("seaweed_uc_delta_rs_%d", suffix)
|
||||
schemaName := fmt.Sprintf("ns_%d", suffix)
|
||||
tableName := fmt.Sprintf("delta_rs_%d", suffix)
|
||||
tableLocation := fmt.Sprintf("s3://%s/%s/%s/%s", ucWarehouse, ucWarehouseKey, schemaName, tableName)
|
||||
|
||||
defer func() {
|
||||
_ = uc.deleteTable(context.Background(), catalogName+"."+schemaName+"."+tableName)
|
||||
_ = uc.deleteSchema(context.Background(), catalogName+"."+schemaName)
|
||||
_ = uc.deleteCatalog(context.Background(), catalogName)
|
||||
}()
|
||||
|
||||
if _, err := uc.createCatalog(ctx, ucCreateCatalog{
|
||||
Name: catalogName,
|
||||
StorageRoot: fmt.Sprintf("s3://%s/%s", ucWarehouse, ucWarehouseKey),
|
||||
}); err != nil {
|
||||
t.Fatalf("create catalog: %v", err)
|
||||
}
|
||||
if _, err := uc.createSchema(ctx, ucCreateSchema{Name: schemaName, CatalogName: catalogName}); err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
created, err := uc.createTable(ctx, ucCreateTable{
|
||||
Name: tableName,
|
||||
CatalogName: catalogName,
|
||||
SchemaName: schemaName,
|
||||
TableType: "EXTERNAL",
|
||||
DataSourceFormat: "DELTA",
|
||||
Columns: []ucColumn{
|
||||
{Name: "id", TypeText: "long", TypeName: "LONG", TypeJSON: `{"name":"id","type":"long","nullable":true,"metadata":{}}`, Position: 0, Nullable: true},
|
||||
{Name: "value", TypeText: "string", TypeName: "STRING", TypeJSON: `{"name":"value","type":"string","nullable":true,"metadata":{}}`, Position: 1, Nullable: true},
|
||||
},
|
||||
StorageLocation: tableLocation,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
|
||||
testDir, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatalf("abs: %v", err)
|
||||
}
|
||||
imageTag := fmt.Sprintf("seaweed-uc-delta-rs:%d", time.Now().UnixNano())
|
||||
|
||||
build := exec.CommandContext(ctx, "docker", "build", "-t", imageTag, "-f", "Dockerfile.delta-rs", ".")
|
||||
build.Dir = testDir
|
||||
if out, err := build.CombinedOutput(); err != nil {
|
||||
t.Fatalf("docker build delta-rs image: %v\n%s", err, out)
|
||||
}
|
||||
defer func() { _ = exec.Command("docker", "rmi", "-f", imageTag).Run() }()
|
||||
|
||||
ucURLForContainer := fmt.Sprintf("http://host.docker.internal:%d", env.ucHostPort)
|
||||
s3EndpointForContainer := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
|
||||
"--add-host", "host.docker.internal:host-gateway",
|
||||
"-e", "UC_URL="+ucURLForContainer,
|
||||
"-e", "S3_ENDPOINT="+s3EndpointForContainer,
|
||||
"-e", "UC_CATALOG="+catalogName,
|
||||
"-e", "UC_SCHEMA="+schemaName,
|
||||
"-e", "UC_TABLE="+tableName,
|
||||
"-e", "UC_TABLE_ID="+created.TableID,
|
||||
"-e", "AWS_ACCESS_KEY_ID="+env.accessKey,
|
||||
"-e", "AWS_SECRET_ACCESS_KEY="+env.secretKey,
|
||||
imageTag,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("delta-rs container failed: %v\noutput:\n%s", err, out)
|
||||
}
|
||||
got := string(out)
|
||||
if !strings.Contains(got, "DELTA_RS_OK rows=3") {
|
||||
t.Fatalf("expected DELTA_RS_OK rows=3 in output; got:\n%s", got)
|
||||
}
|
||||
t.Logf("delta-rs output: %s", strings.TrimSpace(got))
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Round-trip a Delta table through Unity Catalog using delta-rs.
|
||||
|
||||
Reads connection settings from environment variables:
|
||||
|
||||
UC_URL Unity Catalog REST base, e.g. http://host.docker.internal:8080
|
||||
S3_ENDPOINT SeaweedFS S3 endpoint reachable from the container
|
||||
UC_CATALOG Catalog name to operate against
|
||||
UC_SCHEMA Schema name
|
||||
UC_TABLE Table name
|
||||
UC_TABLE_ID Optional: pre-fetched table_id; falls back to GET /tables/{full_name}
|
||||
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
|
||||
Optional: direct S3 credentials. If omitted, the script
|
||||
asks UC for temporary table credentials.
|
||||
|
||||
Steps:
|
||||
|
||||
1. GET the table info from UC.
|
||||
2. Resolve S3 credentials from env or UC temporary-table-credentials.
|
||||
3. write_deltalake() a small pyarrow Table to storage_location.
|
||||
4. Read it back with DeltaTable() and assert row count.
|
||||
|
||||
Prints `DELTA_RS_OK rows=<n>` on success and exits 0; any failure exits 1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
import pyarrow as pa
|
||||
import requests
|
||||
from deltalake import DeltaTable, write_deltalake
|
||||
|
||||
|
||||
def must_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
print(f"ERROR: required env var {name} is not set", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
uc_url = must_env("UC_URL").rstrip("/")
|
||||
s3_endpoint = must_env("S3_ENDPOINT")
|
||||
catalog = must_env("UC_CATALOG")
|
||||
schema = must_env("UC_SCHEMA")
|
||||
table = must_env("UC_TABLE")
|
||||
table_id = os.environ.get("UC_TABLE_ID", "").strip()
|
||||
|
||||
full_name = f"{catalog}.{schema}.{table}"
|
||||
api = f"{uc_url}/api/2.1/unity-catalog"
|
||||
|
||||
encoded_full_name = urllib.parse.quote(full_name, safe="")
|
||||
r = requests.get(f"{api}/tables/{encoded_full_name}", timeout=10)
|
||||
r.raise_for_status()
|
||||
info = r.json()
|
||||
if not table_id:
|
||||
table_id = info.get("table_id") or ""
|
||||
storage_location = info.get("storage_location") or ""
|
||||
|
||||
if not table_id:
|
||||
print("ERROR: table_id is empty in UC response", file=sys.stderr)
|
||||
return 1
|
||||
if not storage_location:
|
||||
print("ERROR: storage_location is empty in UC response", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
access_key = os.environ.get("AWS_ACCESS_KEY_ID", "").strip()
|
||||
secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY", "").strip()
|
||||
session_token = os.environ.get("AWS_SESSION_TOKEN", "").strip()
|
||||
if bool(access_key) != bool(secret_key):
|
||||
print(
|
||||
"ERROR: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set together",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if not access_key:
|
||||
creds_resp = requests.post(
|
||||
f"{api}/temporary-table-credentials",
|
||||
json={"table_id": table_id, "operation": "READ_WRITE"},
|
||||
timeout=15,
|
||||
)
|
||||
creds_resp.raise_for_status()
|
||||
creds = creds_resp.json().get("aws_temp_credentials") or {}
|
||||
access_key = creds.get("access_key_id") or ""
|
||||
secret_key = creds.get("secret_access_key") or ""
|
||||
session_token = creds.get("session_token") or ""
|
||||
if not access_key or not secret_key:
|
||||
print(
|
||||
"ERROR: missing aws_temp_credentials; "
|
||||
f"response status {creds_resp.status_code}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
storage_options = {
|
||||
"AWS_ACCESS_KEY_ID": access_key,
|
||||
"AWS_SECRET_ACCESS_KEY": secret_key,
|
||||
"AWS_REGION": "us-east-1",
|
||||
"AWS_ENDPOINT_URL": s3_endpoint,
|
||||
"AWS_ALLOW_HTTP": "true",
|
||||
# delta-rs requires this for non-S3 backends that lack atomic rename.
|
||||
"AWS_S3_ALLOW_UNSAFE_RENAME": "true",
|
||||
}
|
||||
if session_token:
|
||||
storage_options["AWS_SESSION_TOKEN"] = session_token
|
||||
|
||||
arrow_table = pa.table(
|
||||
{
|
||||
"id": pa.array([1, 2, 3], type=pa.int64()),
|
||||
"value": pa.array(["alpha", "beta", "gamma"], type=pa.string()),
|
||||
}
|
||||
)
|
||||
|
||||
write_deltalake(
|
||||
storage_location,
|
||||
arrow_table,
|
||||
storage_options=storage_options,
|
||||
mode="overwrite",
|
||||
)
|
||||
|
||||
dt = DeltaTable(storage_location, storage_options=storage_options)
|
||||
read_back = dt.to_pyarrow_table()
|
||||
rows = read_back.num_rows
|
||||
print(f"DELTA_RS_OK rows={rows} version={dt.version()}")
|
||||
return 0 if rows == arrow_table.num_rows else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = main()
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
# deltalake/pyarrow can abort during native teardown after a successful
|
||||
# round-trip in the short-lived test container. Preserve main's result.
|
||||
os._exit(exit_code)
|
||||
@@ -0,0 +1,201 @@
|
||||
package unity_catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sts"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
)
|
||||
|
||||
// TestUnityCatalogMasterRoleIntegration covers the master-role configuration
|
||||
// the upstream playground notes as not-yet-working
|
||||
// (https://github.com/data-engineering-helpers/mds-in-a-box/blob/main/unitycatalog-playground/etc/conf/server.properties#L45).
|
||||
//
|
||||
// It verifies two things end-to-end:
|
||||
//
|
||||
// 1. SeaweedFS' STS endpoint accepts sts:AssumeRole for the
|
||||
// UnityCatalogVendedRole, returning real STS-vended credentials. This
|
||||
// mirrors what the lakekeeper / polaris tests already exercise via the Go
|
||||
// AWS SDK and is the SeaweedFS-side prerequisite for UC's master-role
|
||||
// flow.
|
||||
//
|
||||
// 2. Unity Catalog OSS starts and accepts catalog/schema/EXTERNAL Delta
|
||||
// table CRUD when configured with `aws.masterRoleArn` set to that role
|
||||
// and AWS_ENDPOINT_URL_STS pointed at SeaweedFS.
|
||||
//
|
||||
// What it does NOT verify is the third hop -- UC's Java StsClient
|
||||
// reaching SeaweedFS' STS handler. UC's
|
||||
// AwsCredentialGenerator.StsAwsCredentialGenerator builds the StsClient
|
||||
// without an endpointOverride and doesn't propagate aws.endpoint to it, so
|
||||
// the SDK's generic AWS_ENDPOINT_URL_STS resolution doesn't kick in and the
|
||||
// request always targets real AWS, returning InvalidClientTokenId. Verified
|
||||
// by pointing AWS_ENDPOINT_URL_STS at port 1: same 403 either way, and a
|
||||
// sniffer in front of SeaweedFS records zero traffic. UC's own tests mock
|
||||
// StsClient out (BaseCRUDTestWithMockCredentials' EchoAwsStsClient,
|
||||
// Mockito.mockStatic in CloudCredentialVendorTest), so this never showed
|
||||
// up upstream. Fix is in unitycatalog/unitycatalog#1532; that bit is
|
||||
// logged via t.Logf and not asserted.
|
||||
func TestUnityCatalogMasterRoleIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
if !testutil.HasDocker() {
|
||||
t.Skip("docker not available")
|
||||
}
|
||||
|
||||
env := newTestEnv(t)
|
||||
defer env.cleanup(t)
|
||||
|
||||
t.Log(">>> starting SeaweedFS with STS-enabled IAM config...")
|
||||
env.startSeaweedFS(t, stsEnabledIAMConfig(env.accessKey, env.secretKey))
|
||||
t.Log(">>> SeaweedFS ready")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
s3c := env.newHostS3Client(t, ctx)
|
||||
if _, err := s3c.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(ucWarehouse)}); err != nil {
|
||||
t.Fatalf("create warehouse bucket: %v", err)
|
||||
}
|
||||
|
||||
// Slice 1: prove SeaweedFS STS works for the role UC would use.
|
||||
t.Run("SeaweedFsAssumesUnityCatalogVendedRole", func(t *testing.T) {
|
||||
stsEndpoint := fmt.Sprintf("http://127.0.0.1:%d", env.s3Port)
|
||||
creds, err := assumeRoleViaSeaweedFS(ctx, stsEndpoint, env.accessKey, env.secretKey, ucVendedRoleArn)
|
||||
if err != nil {
|
||||
t.Fatalf("AssumeRole on SeaweedFS STS: %v", err)
|
||||
}
|
||||
if creds.SessionToken == "" {
|
||||
t.Fatalf("expected non-empty session_token from STS, got %+v", creds)
|
||||
}
|
||||
if creds.AccessKeyID == env.accessKey {
|
||||
t.Fatalf("STS returned the static admin access key; AssumeRole did not vend a new one")
|
||||
}
|
||||
|
||||
// Confirm the vended creds actually work for an S3 round-trip.
|
||||
s3v := env.newHostS3ClientWithCreds(t, ctx, creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken)
|
||||
probeKey := "ns-probe/master-role-probe.txt"
|
||||
if _, err := s3v.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(ucWarehouse),
|
||||
Key: aws.String(probeKey),
|
||||
Body: nil,
|
||||
}); err != nil {
|
||||
t.Fatalf("PutObject with sts-vended creds: %v", err)
|
||||
}
|
||||
_, _ = s3v.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(ucWarehouse),
|
||||
Key: aws.String(probeKey),
|
||||
})
|
||||
})
|
||||
|
||||
// Slice 2: UC starts with master-role config and CRUD works.
|
||||
stsEndpoint := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
|
||||
t.Log(">>> starting Unity Catalog server (master role)...")
|
||||
env.startUnityCatalog(t, ctx, ucServerOpts{
|
||||
MasterRoleArn: ucVendedRoleArn,
|
||||
ExtraEnv: map[string]string{
|
||||
"AWS_ENDPOINT_URL": stsEndpoint,
|
||||
"AWS_ENDPOINT_URL_STS": stsEndpoint,
|
||||
"AWS_ACCESS_KEY_ID": env.accessKey,
|
||||
"AWS_SECRET_ACCESS_KEY": env.secretKey,
|
||||
"AWS_REGION": "us-east-1",
|
||||
},
|
||||
})
|
||||
t.Log(">>> Unity Catalog ready")
|
||||
|
||||
uc := newUCClient(fmt.Sprintf("http://127.0.0.1:%d", env.ucHostPort))
|
||||
suffix := time.Now().UnixNano()
|
||||
catalogName := fmt.Sprintf("seaweed_uc_role_%d", suffix)
|
||||
schemaName := fmt.Sprintf("ns_%d", suffix)
|
||||
tableName := fmt.Sprintf("events_%d", suffix)
|
||||
tableLocation := fmt.Sprintf("s3://%s/%s/%s/%s", ucWarehouse, ucWarehouseKey, schemaName, tableName)
|
||||
|
||||
defer func() {
|
||||
_ = uc.deleteTable(context.Background(), catalogName+"."+schemaName+"."+tableName)
|
||||
_ = uc.deleteSchema(context.Background(), catalogName+"."+schemaName)
|
||||
_ = uc.deleteCatalog(context.Background(), catalogName)
|
||||
}()
|
||||
|
||||
t.Run("UnityCatalogAcceptsMasterRoleConfig", func(t *testing.T) {
|
||||
if _, err := uc.createCatalog(ctx, ucCreateCatalog{
|
||||
Name: catalogName,
|
||||
StorageRoot: fmt.Sprintf("s3://%s/%s", ucWarehouse, ucWarehouseKey),
|
||||
}); err != nil {
|
||||
t.Fatalf("create catalog: %v", err)
|
||||
}
|
||||
if _, err := uc.createSchema(ctx, ucCreateSchema{Name: schemaName, CatalogName: catalogName}); err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
created, err := uc.createTable(ctx, ucCreateTable{
|
||||
Name: tableName,
|
||||
CatalogName: catalogName,
|
||||
SchemaName: schemaName,
|
||||
TableType: "EXTERNAL",
|
||||
DataSourceFormat: "DELTA",
|
||||
Columns: []ucColumn{
|
||||
{Name: "id", TypeText: "long", TypeName: "LONG", TypeJSON: `{"name":"id","type":"long","nullable":true,"metadata":{}}`, Position: 0, Nullable: true},
|
||||
},
|
||||
StorageLocation: tableLocation,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create external delta table: %v", err)
|
||||
}
|
||||
if created.TableID == "" {
|
||||
t.Fatalf("expected table_id in CreateTable response, got %+v", created)
|
||||
}
|
||||
|
||||
// The actual UC -> SeaweedFS STS handoff is the still-unsolved slice.
|
||||
// Log what happens, but don't fail the test on it: SeaweedFS' STS is
|
||||
// known-good (slice 1 above), and UC's CRUD is known-good (this slice).
|
||||
_, ucCredErr := uc.generateTemporaryTableCredentials(ctx, created.TableID, "READ_WRITE")
|
||||
if ucCredErr != nil {
|
||||
t.Logf("UC StsClient still hits real AWS (UC bug, fix in unitycatalog/unitycatalog#1532): %v", ucCredErr)
|
||||
} else {
|
||||
t.Logf("UC StsClient honors endpoint override; unitycatalog/unitycatalog#1532 (or equivalent) is in this image.")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// assumeRoleViaSeaweedFS calls sts:AssumeRole against SeaweedFS' STS endpoint
|
||||
// using the Go AWS SDK, mirroring how lakekeeper/polaris tests prove
|
||||
// SeaweedFS' STS path works.
|
||||
func assumeRoleViaSeaweedFS(ctx context.Context, endpoint, accessKey, secretKey, roleArn string) (aws.Credentials, error) {
|
||||
cfg, err := config.LoadDefaultConfig(ctx,
|
||||
config.WithRegion("us-east-1"),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
||||
)
|
||||
if err != nil {
|
||||
return aws.Credentials{}, fmt.Errorf("load aws config: %w", err)
|
||||
}
|
||||
|
||||
// Override the STS endpoint via service-specific BaseEndpoint instead of
|
||||
// the deprecated EndpointResolverWithOptions wired through the global
|
||||
// config. This is the supported path in aws-sdk-go-v2 since v1.21.
|
||||
client := sts.NewFromConfig(cfg, func(o *sts.Options) {
|
||||
o.BaseEndpoint = aws.String(endpoint)
|
||||
})
|
||||
resp, err := client.AssumeRole(ctx, &sts.AssumeRoleInput{
|
||||
RoleArn: aws.String(roleArn),
|
||||
RoleSessionName: aws.String("uc-master-role-test"),
|
||||
})
|
||||
if err != nil {
|
||||
return aws.Credentials{}, err
|
||||
}
|
||||
if resp.Credentials == nil {
|
||||
return aws.Credentials{}, fmt.Errorf("AssumeRole returned no credentials")
|
||||
}
|
||||
return aws.Credentials{
|
||||
AccessKeyID: aws.ToString(resp.Credentials.AccessKeyId),
|
||||
SecretAccessKey: aws.ToString(resp.Credentials.SecretAccessKey),
|
||||
SessionToken: aws.ToString(resp.Credentials.SessionToken),
|
||||
Source: "seaweedfs-sts",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package unity_catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
)
|
||||
|
||||
const (
|
||||
ucImageDefault = "unitycatalog/unitycatalog:v0.4.0"
|
||||
ucContainerCfg = "/home/unitycatalog/etc/conf/server.properties"
|
||||
ucStartupGrace = 90 * time.Second
|
||||
ucAPIBase = "/api/2.1/unity-catalog"
|
||||
ucWarehouse = "lakehouse"
|
||||
ucWarehouseKey = "warehouse"
|
||||
|
||||
// Role used by the master-role STS-vended variant of the test. The trust
|
||||
// policy is wide open so any caller can assume it; in production UC
|
||||
// would set this ARN as `aws.masterRoleArn`.
|
||||
ucVendedRoleArn = "arn:aws:iam::000000000000:role/UnityCatalogVendedRole"
|
||||
ucVendedRoleName = "UnityCatalogVendedRole"
|
||||
)
|
||||
|
||||
type testEnv struct {
|
||||
seaweedDir string
|
||||
weedBinary string
|
||||
dataDir string
|
||||
bindIP string
|
||||
s3Port int
|
||||
s3GrpcPort int
|
||||
masterPort int
|
||||
masterGrpcPort int
|
||||
filerPort int
|
||||
filerGrpcPort int
|
||||
volumePort int
|
||||
volumeGrpcPort int
|
||||
weedProcess *exec.Cmd
|
||||
weedCancel context.CancelFunc
|
||||
|
||||
accessKey string
|
||||
secretKey string
|
||||
|
||||
ucImage string
|
||||
ucContainerID string
|
||||
ucHostPort int
|
||||
}
|
||||
|
||||
// ucServerOpts customizes the Unity Catalog server.properties and runtime
|
||||
// environment. Defaults match the upstream playground (static keys, no master
|
||||
// role).
|
||||
type ucServerOpts struct {
|
||||
// MasterRoleArn populates aws.masterRoleArn. Empty means UC falls back to
|
||||
// static aws.accessKey / aws.secretKey for storage operations.
|
||||
MasterRoleArn string
|
||||
// ExtraEnv adds environment variables to the UC container, useful for
|
||||
// AWS_ENDPOINT_URL_STS-style overrides.
|
||||
ExtraEnv map[string]string
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
seaweedDir := wd
|
||||
for i := 0; i < 8; i++ {
|
||||
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
|
||||
break
|
||||
}
|
||||
seaweedDir = filepath.Dir(seaweedDir)
|
||||
}
|
||||
|
||||
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
|
||||
if _, err := os.Stat(weedBinary); err != nil {
|
||||
weedBinary = "weed"
|
||||
if _, err := exec.LookPath(weedBinary); err != nil {
|
||||
t.Skip("weed binary not found, skipping integration test")
|
||||
}
|
||||
}
|
||||
|
||||
dataDir, err := os.MkdirTemp("", "seaweed-uc-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdtemp: %v", err)
|
||||
}
|
||||
|
||||
bindIP := testutil.FindBindIP()
|
||||
ports := testutil.MustAllocatePorts(t, 9)
|
||||
masterPort, masterGrpcPort := ports[0], ports[1]
|
||||
volumePort, volumeGrpcPort := ports[2], ports[3]
|
||||
filerPort, filerGrpcPort := ports[4], ports[5]
|
||||
s3Port, s3GrpcPort := ports[6], ports[7]
|
||||
ucHostPort := ports[8]
|
||||
|
||||
image := os.Getenv("UC_IMAGE")
|
||||
if image == "" {
|
||||
image = ucImageDefault
|
||||
}
|
||||
|
||||
return &testEnv{
|
||||
seaweedDir: seaweedDir,
|
||||
weedBinary: weedBinary,
|
||||
dataDir: dataDir,
|
||||
bindIP: bindIP,
|
||||
s3Port: s3Port,
|
||||
s3GrpcPort: s3GrpcPort,
|
||||
masterPort: masterPort,
|
||||
masterGrpcPort: masterGrpcPort,
|
||||
filerPort: filerPort,
|
||||
filerGrpcPort: filerGrpcPort,
|
||||
volumePort: volumePort,
|
||||
volumeGrpcPort: volumeGrpcPort,
|
||||
accessKey: "admin",
|
||||
secretKey: "admin",
|
||||
ucImage: image,
|
||||
ucHostPort: ucHostPort,
|
||||
}
|
||||
}
|
||||
|
||||
// startSeaweedFS starts a `weed mini` instance. If iamJSON is empty, a minimal
|
||||
// admin-only IAM config is used. When iamJSON is non-empty, it is passed to
|
||||
// both -s3.config and -s3.iam.config so the STS handler is enabled (mirroring
|
||||
// the lakekeeper test).
|
||||
func (env *testEnv) startSeaweedFS(t *testing.T, iamJSON string) {
|
||||
t.Helper()
|
||||
|
||||
enableSTS := iamJSON != ""
|
||||
if iamJSON == "" {
|
||||
iamJSON = fmt.Sprintf(`{
|
||||
"identities": [
|
||||
{
|
||||
"name": "admin",
|
||||
"credentials": [{"accessKey": %q, "secretKey": %q}],
|
||||
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
|
||||
}
|
||||
]
|
||||
}`, env.accessKey, env.secretKey)
|
||||
}
|
||||
|
||||
iamConfigPath := filepath.Join(env.dataDir, "iam.json")
|
||||
if err := os.WriteFile(iamConfigPath, []byte(iamJSON), 0644); err != nil {
|
||||
t.Fatalf("write iam config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
env.weedCancel = cancel
|
||||
|
||||
weedArgs := []string{"-v", "4", "mini",
|
||||
"-master.port", fmt.Sprintf("%d", env.masterPort),
|
||||
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
|
||||
"-volume.port", fmt.Sprintf("%d", env.volumePort),
|
||||
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
|
||||
"-filer.port", fmt.Sprintf("%d", env.filerPort),
|
||||
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
|
||||
"-s3.port", fmt.Sprintf("%d", env.s3Port),
|
||||
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
|
||||
"-s3.config", iamConfigPath,
|
||||
"-ip", env.bindIP,
|
||||
"-ip.bind", "0.0.0.0",
|
||||
"-dir", env.dataDir,
|
||||
}
|
||||
if enableSTS {
|
||||
weedArgs = append(weedArgs, "-s3.iam.config", iamConfigPath, "-s3.iam.readOnly=false")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, env.weedBinary, weedArgs...)
|
||||
cmd.Dir = env.dataDir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start weed mini: %v", err)
|
||||
}
|
||||
env.weedProcess = cmd
|
||||
|
||||
if !testutil.WaitForService(fmt.Sprintf("http://127.0.0.1:%d/status", env.s3Port), testutil.SeaweedMiniStartupTimeout) {
|
||||
t.Fatalf("S3 API at 127.0.0.1:%d did not become ready", env.s3Port)
|
||||
}
|
||||
}
|
||||
|
||||
// startUnityCatalog launches the Unity Catalog OSS server in Docker against
|
||||
// the running SeaweedFS instance. It mirrors the upstream playground's
|
||||
// server.properties layout and bind-mounts only that single file (matching
|
||||
// docker-compose.yaml from the playground).
|
||||
func (env *testEnv) startUnityCatalog(t *testing.T, ctx context.Context, opts ucServerOpts) {
|
||||
t.Helper()
|
||||
|
||||
s3EndpointForContainer := fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
|
||||
|
||||
props := strings.Join([]string{
|
||||
"server.env=test",
|
||||
"server.authorization=disable",
|
||||
"server.cookie-timeout=PT1H",
|
||||
"server.managed-table.enabled=false",
|
||||
fmt.Sprintf("aws.masterRoleArn=%s", opts.MasterRoleArn),
|
||||
fmt.Sprintf("aws.accessKey=%s", env.accessKey),
|
||||
fmt.Sprintf("aws.secretKey=%s", env.secretKey),
|
||||
"aws.region=us-east-1",
|
||||
fmt.Sprintf("aws.endpoint=%s", s3EndpointForContainer),
|
||||
// UC keys perBucketS3Configs by NormalizedURL.from(bucketPath) and
|
||||
// looks up using the storageBase, which is "s3://<bucket>" (scheme +
|
||||
// authority only). The playground's "s3://lakehouse/warehouse" never
|
||||
// matches because of that asymmetry; the bucket-only form works.
|
||||
fmt.Sprintf("s3.bucketPath.0=s3://%s", ucWarehouse),
|
||||
"s3.region.0=us-east-1",
|
||||
fmt.Sprintf("s3.awsRoleArn.0=%s", opts.MasterRoleArn),
|
||||
fmt.Sprintf("s3.accessKey.0=%s", env.accessKey),
|
||||
fmt.Sprintf("s3.secretKey.0=%s", env.secretKey),
|
||||
fmt.Sprintf("s3.endpoint.0=%s", s3EndpointForContainer),
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
confDir := filepath.Join(env.dataDir, "uc-conf")
|
||||
if err := os.MkdirAll(confDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir uc-conf: %v", err)
|
||||
}
|
||||
propsPath := filepath.Join(confDir, "server.properties")
|
||||
if err := os.WriteFile(propsPath, []byte(props), 0644); err != nil {
|
||||
t.Fatalf("write server.properties: %v", err)
|
||||
}
|
||||
|
||||
containerName := fmt.Sprintf("seaweed-unity-catalog-%d", time.Now().UnixNano())
|
||||
|
||||
args := []string{
|
||||
"run", "-d", "--rm",
|
||||
"--name", containerName,
|
||||
"--add-host", "host.docker.internal:host-gateway",
|
||||
"-p", fmt.Sprintf("%d:8080", env.ucHostPort),
|
||||
"-v", fmt.Sprintf("%s:%s:ro", propsPath, ucContainerCfg),
|
||||
"-e", "JAVA_OPTS=-Xmx1g",
|
||||
}
|
||||
for k, v := range opts.ExtraEnv {
|
||||
args = append(args, "-e", fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
args = append(args, env.ucImage)
|
||||
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("docker run unity-catalog: %v\n%s", err, out)
|
||||
}
|
||||
// docker run -d prefixes pull progress / warnings before the final container
|
||||
// ID line when the image isn't cached locally. Trim to the last whitespace-
|
||||
// separated token so the ID survives a fresh CI runner.
|
||||
fields := strings.Fields(string(out))
|
||||
if len(fields) == 0 {
|
||||
t.Fatalf("docker run produced no output; combined: %q", string(out))
|
||||
}
|
||||
env.ucContainerID = fields[len(fields)-1]
|
||||
t.Logf("unity-catalog container id: %s", env.ucContainerID)
|
||||
|
||||
probe := fmt.Sprintf("http://127.0.0.1:%d%s/catalogs", env.ucHostPort, ucAPIBase)
|
||||
deadline := time.Now().Add(ucStartupGrace)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, probe, nil)
|
||||
resp, err := (&http.Client{Timeout: 3 * time.Second}).Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return
|
||||
}
|
||||
lastErr = fmt.Errorf("status %d", resp.StatusCode)
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("ctx done while waiting for unity catalog: %v", ctx.Err())
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
logs, _ := exec.Command("docker", "logs", "--tail", "200", env.ucContainerID).CombinedOutput()
|
||||
t.Fatalf("unity catalog did not become ready: %v\nrecent logs:\n%s", lastErr, logs)
|
||||
}
|
||||
|
||||
func (env *testEnv) cleanup(t *testing.T) {
|
||||
if env.ucContainerID != "" {
|
||||
if logs, err := exec.Command("docker", "logs", "--tail", "200", env.ucContainerID).CombinedOutput(); err == nil {
|
||||
t.Logf("unity-catalog tail logs:\n%s", logs)
|
||||
}
|
||||
_ = exec.Command("docker", "rm", "-f", env.ucContainerID).Run()
|
||||
}
|
||||
if env.weedCancel != nil {
|
||||
env.weedCancel()
|
||||
}
|
||||
if env.weedProcess != nil {
|
||||
_ = env.weedProcess.Wait()
|
||||
}
|
||||
if env.dataDir != "" {
|
||||
_ = os.RemoveAll(env.dataDir)
|
||||
}
|
||||
}
|
||||
|
||||
// -- S3 client helpers ---------------------------------------------------------
|
||||
|
||||
func (env *testEnv) newHostS3Client(t *testing.T, ctx context.Context) *s3.Client {
|
||||
t.Helper()
|
||||
return env.newHostS3ClientWithCreds(t, ctx, env.accessKey, env.secretKey, "")
|
||||
}
|
||||
|
||||
func (env *testEnv) newHostS3ClientWithCreds(t *testing.T, ctx context.Context, ak, sk, token string) *s3.Client {
|
||||
t.Helper()
|
||||
endpoint := fmt.Sprintf("http://127.0.0.1:%d", env.s3Port)
|
||||
cfg, err := config.LoadDefaultConfig(ctx,
|
||||
config.WithRegion("us-east-1"),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(ak, sk, token)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load aws config: %v", err)
|
||||
}
|
||||
return s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(endpoint)
|
||||
o.UsePathStyle = true
|
||||
})
|
||||
}
|
||||
|
||||
// stsEnabledIAMConfig returns an iam.json that defines an admin user, the
|
||||
// UnityCatalogVendedRole role with a permissive trust policy, and a FullAccess
|
||||
// policy attached to it. This is the SeaweedFS-side counterpart of the
|
||||
// `aws.masterRoleArn` configuration on the Unity Catalog server.
|
||||
func stsEnabledIAMConfig(accessKey, secretKey string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"identities": [
|
||||
{
|
||||
"name": "admin",
|
||||
"credentials": [{"accessKey": %q, "secretKey": %q}],
|
||||
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
|
||||
}
|
||||
],
|
||||
"sts": {
|
||||
"tokenDuration": "12h",
|
||||
"maxSessionLength": "24h",
|
||||
"issuer": "seaweedfs-sts",
|
||||
"signingKey": "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz"
|
||||
},
|
||||
"roles": [
|
||||
{
|
||||
"roleName": %q,
|
||||
"roleArn": %q,
|
||||
"trustPolicy": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "sts:AssumeRole"
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachedPolicies": ["FullAccess"]
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "FullAccess",
|
||||
"document": {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{"Effect": "Allow", "Action": "*", "Resource": "*"}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`, accessKey, secretKey, ucVendedRoleName, ucVendedRoleArn)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package unity_catalog provides integration tests that run Unity Catalog
|
||||
// OSS in Docker against a SeaweedFS S3 backend. The base test mirrors the
|
||||
// configuration used by the upstream UC playground at
|
||||
// https://github.com/data-engineering-helpers/mds-in-a-box/tree/main/unitycatalog-playground:
|
||||
// static aws.accessKey/aws.secretKey, no master role, EXTERNAL Delta tables.
|
||||
package unity_catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/test/testutil"
|
||||
)
|
||||
|
||||
// TestUnityCatalogDeltaIntegration brings up SeaweedFS, runs Unity Catalog OSS
|
||||
// in Docker against it, and exercises catalog/schema/table CRUD plus
|
||||
// temporary-table-credentials and storage I/O via vended credentials. The UC
|
||||
// server is configured with static keys (aws.masterRoleArn empty), matching
|
||||
// the playground's working configuration when targeting SeaweedFS.
|
||||
func TestUnityCatalogDeltaIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
if !testutil.HasDocker() {
|
||||
t.Skip("docker not available")
|
||||
}
|
||||
|
||||
env := newTestEnv(t)
|
||||
defer env.cleanup(t)
|
||||
|
||||
t.Log(">>> starting SeaweedFS for Unity Catalog test...")
|
||||
env.startSeaweedFS(t, "")
|
||||
t.Log(">>> SeaweedFS ready")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
s3c := env.newHostS3Client(t, ctx)
|
||||
if _, err := s3c.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(ucWarehouse)}); err != nil {
|
||||
t.Fatalf("create warehouse bucket: %v", err)
|
||||
}
|
||||
|
||||
t.Log(">>> starting Unity Catalog server (static keys)...")
|
||||
env.startUnityCatalog(t, ctx, ucServerOpts{})
|
||||
t.Log(">>> Unity Catalog ready")
|
||||
|
||||
uc := newUCClient(fmt.Sprintf("http://127.0.0.1:%d", env.ucHostPort))
|
||||
|
||||
suffix := time.Now().UnixNano()
|
||||
catalogName := fmt.Sprintf("seaweed_uc_%d", suffix)
|
||||
schemaName := fmt.Sprintf("ns_%d", suffix)
|
||||
tableName := fmt.Sprintf("events_%d", suffix)
|
||||
tableLocation := fmt.Sprintf("s3://%s/%s/%s/%s", ucWarehouse, ucWarehouseKey, schemaName, tableName)
|
||||
|
||||
defer func() {
|
||||
_ = uc.deleteTable(context.Background(), catalogName+"."+schemaName+"."+tableName)
|
||||
_ = uc.deleteSchema(context.Background(), catalogName+"."+schemaName)
|
||||
_ = uc.deleteCatalog(context.Background(), catalogName)
|
||||
}()
|
||||
|
||||
t.Run("CreateCatalog", func(t *testing.T) {
|
||||
got, err := uc.createCatalog(ctx, ucCreateCatalog{
|
||||
Name: catalogName,
|
||||
Comment: "seaweedfs integration test",
|
||||
StorageRoot: fmt.Sprintf("s3://%s/%s", ucWarehouse, ucWarehouseKey),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create catalog: %v", err)
|
||||
}
|
||||
if got.Name != catalogName {
|
||||
t.Fatalf("catalog name = %q, want %q", got.Name, catalogName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateSchema", func(t *testing.T) {
|
||||
got, err := uc.createSchema(ctx, ucCreateSchema{
|
||||
Name: schemaName,
|
||||
CatalogName: catalogName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
if got.Name != schemaName || got.CatalogName != catalogName {
|
||||
t.Fatalf("schema = %+v, want name=%q catalog=%q", got, schemaName, catalogName)
|
||||
}
|
||||
})
|
||||
|
||||
var createdTable ucTableInfo
|
||||
t.Run("CreateExternalDeltaTable", func(t *testing.T) {
|
||||
columns := []ucColumn{
|
||||
{Name: "id", TypeText: "long", TypeName: "LONG", TypeJSON: `{"name":"id","type":"long","nullable":true,"metadata":{}}`, Position: 0, Nullable: true},
|
||||
{Name: "value", TypeText: "string", TypeName: "STRING", TypeJSON: `{"name":"value","type":"string","nullable":true,"metadata":{}}`, Position: 1, Nullable: true},
|
||||
}
|
||||
got, err := uc.createTable(ctx, ucCreateTable{
|
||||
Name: tableName,
|
||||
CatalogName: catalogName,
|
||||
SchemaName: schemaName,
|
||||
TableType: "EXTERNAL",
|
||||
DataSourceFormat: "DELTA",
|
||||
Columns: columns,
|
||||
StorageLocation: tableLocation,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
if got.TableType != "EXTERNAL" || got.DataSourceFormat != "DELTA" {
|
||||
t.Fatalf("table types = %s/%s, want EXTERNAL/DELTA", got.TableType, got.DataSourceFormat)
|
||||
}
|
||||
if got.StorageLocation == "" {
|
||||
t.Fatalf("table storage_location is empty in response: %+v", got)
|
||||
}
|
||||
createdTable = *got
|
||||
})
|
||||
|
||||
t.Run("GetTable", func(t *testing.T) {
|
||||
full := catalogName + "." + schemaName + "." + tableName
|
||||
got, err := uc.getTable(ctx, full)
|
||||
if err != nil {
|
||||
t.Fatalf("get table: %v", err)
|
||||
}
|
||||
if got.Name != tableName {
|
||||
t.Fatalf("get table name = %q, want %q", got.Name, tableName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListTables", func(t *testing.T) {
|
||||
tables, err := uc.listTables(ctx, catalogName, schemaName)
|
||||
if err != nil {
|
||||
t.Fatalf("list tables: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, tbl := range tables {
|
||||
if tbl.Name == tableName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("created table %q not found in list", tableName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TemporaryTableCredentialsRejected", func(t *testing.T) {
|
||||
// With aws.masterRoleArn empty AND no s3.sessionToken.0 set, UC OSS
|
||||
// always tries to AssumeRole via its internal StsClient (see
|
||||
// AwsCredentialVendor.createPerBucketCredentialGenerator). Against a
|
||||
// non-AWS endpoint, that call doesn't reach a real STS, so UC returns
|
||||
// "S3 bucket configuration not found." or an STS-side error. This is
|
||||
// the gap users hit at <https://github.com/data-engineering-helpers/mds-in-a-box/blob/main/unitycatalog-playground/etc/conf/server.properties#L45>:
|
||||
// "with simple S3 access and secret keys, Unity Catalog does not seem
|
||||
// to work."
|
||||
//
|
||||
// The assertion is therefore inverted: we expect a non-nil error from
|
||||
// /temporary-table-credentials with this configuration. A future
|
||||
// variant can pin s3.sessionToken.0 (UC's StaticAwsCredentialGenerator
|
||||
// path) once SeaweedFS' SigV4 path tolerates the vended session token.
|
||||
if createdTable.TableID == "" {
|
||||
t.Fatalf("created table has empty table_id; cannot request temporary credentials")
|
||||
}
|
||||
_, err := uc.generateTemporaryTableCredentials(ctx, createdTable.TableID, "READ_WRITE")
|
||||
if err == nil {
|
||||
t.Fatalf("expected /temporary-table-credentials to fail with the static-key playground configuration; it succeeded unexpectedly")
|
||||
}
|
||||
t.Logf("expected failure (UC static-key path requires AWS STS): %v", err)
|
||||
})
|
||||
|
||||
t.Run("DeleteTableSchemaCatalog", func(t *testing.T) {
|
||||
if err := uc.deleteTable(ctx, catalogName+"."+schemaName+"."+tableName); err != nil {
|
||||
t.Fatalf("delete table: %v", err)
|
||||
}
|
||||
if err := uc.deleteSchema(ctx, catalogName+"."+schemaName); err != nil {
|
||||
t.Fatalf("delete schema: %v", err)
|
||||
}
|
||||
if err := uc.deleteCatalog(ctx, catalogName); err != nil {
|
||||
t.Fatalf("delete catalog: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/seaweedfs/seaweedfs/weed/admin/plugin"
|
||||
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/plugin_pb"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
@@ -738,6 +740,9 @@ func (s *AdminServer) parseOrBuildClusterContext(raw json.RawMessage) (*plugin_p
|
||||
if len(contextMessage.VolumeGrpcAddresses) == 0 {
|
||||
contextMessage.VolumeGrpcAddresses = append(contextMessage.VolumeGrpcAddresses, fallback.VolumeGrpcAddresses...)
|
||||
}
|
||||
if len(contextMessage.S3GrpcAddresses) == 0 {
|
||||
contextMessage.S3GrpcAddresses = append(contextMessage.S3GrpcAddresses, fallback.S3GrpcAddresses...)
|
||||
}
|
||||
if contextMessage.Metadata == nil {
|
||||
contextMessage.Metadata = map[string]string{}
|
||||
}
|
||||
@@ -751,6 +756,7 @@ func (s *AdminServer) buildDefaultPluginClusterContext() *plugin_pb.ClusterConte
|
||||
MasterGrpcAddresses: make([]string, 0),
|
||||
FilerGrpcAddresses: make([]string, 0),
|
||||
VolumeGrpcAddresses: make([]string, 0),
|
||||
S3GrpcAddresses: make([]string, 0),
|
||||
Metadata: map[string]string{
|
||||
"source": "admin",
|
||||
},
|
||||
@@ -794,9 +800,34 @@ func (s *AdminServer) buildDefaultPluginClusterContext() *plugin_pb.ClusterConte
|
||||
glog.V(1).Infof("failed to build default plugin volume context: %v", err)
|
||||
}
|
||||
|
||||
s3Seen := map[string]struct{}{}
|
||||
if err := s.WithMasterClient(func(client master_pb.SeaweedClient) error {
|
||||
resp, err := client.ListClusterNodes(context.Background(), &master_pb.ListClusterNodesRequest{
|
||||
ClientType: cluster.S3Type,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range resp.GetClusterNodes() {
|
||||
address := strings.TrimSpace(node.GetAddress())
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := s3Seen[address]; exists {
|
||||
continue
|
||||
}
|
||||
s3Seen[address] = struct{}{}
|
||||
clusterContext.S3GrpcAddresses = append(clusterContext.S3GrpcAddresses, address)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
glog.V(1).Infof("failed to list cluster S3 nodes: %v", err)
|
||||
}
|
||||
|
||||
sort.Strings(clusterContext.MasterGrpcAddresses)
|
||||
sort.Strings(clusterContext.FilerGrpcAddresses)
|
||||
sort.Strings(clusterContext.VolumeGrpcAddresses)
|
||||
sort.Strings(clusterContext.S3GrpcAddresses)
|
||||
|
||||
return clusterContext
|
||||
}
|
||||
@@ -905,8 +936,8 @@ func applyDescriptorDefaultsToPersistedConfig(
|
||||
if descriptor.AdminRuntimeDefaults != nil {
|
||||
runtime := config.AdminRuntime
|
||||
defaults := descriptor.AdminRuntimeDefaults
|
||||
if runtime.DetectionIntervalSeconds <= 0 {
|
||||
runtime.DetectionIntervalSeconds = defaults.DetectionIntervalSeconds
|
||||
if runtime.DetectionIntervalMinutes <= 0 {
|
||||
runtime.DetectionIntervalMinutes = defaults.DetectionIntervalMinutes
|
||||
}
|
||||
if runtime.DetectionTimeoutSeconds <= 0 {
|
||||
runtime.DetectionTimeoutSeconds = defaults.DetectionTimeoutSeconds
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestApplyDescriptorDefaultsToPersistedConfigBackfillsAdminDefaults(t *testi
|
||||
},
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
DetectionIntervalSeconds: 60,
|
||||
DetectionIntervalMinutes: 60,
|
||||
DetectionTimeoutSeconds: 300,
|
||||
},
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func TestApplyDescriptorDefaultsToPersistedConfigBackfillsAdminDefaults(t *testi
|
||||
if !ok || scriptKind.StringValue == "" {
|
||||
t.Fatalf("expected non-empty script default, got=%+v", script)
|
||||
}
|
||||
if config.AdminRuntime.DetectionIntervalSeconds != 60 {
|
||||
if config.AdminRuntime.DetectionIntervalMinutes != 60 {
|
||||
t.Fatalf("expected runtime detection interval default to be backfilled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ func (ms *MaintenanceScanner) getVolumeHealthMetrics() ([]*types.VolumeHealthMet
|
||||
DeletedBytes: volInfo.DeletedByteCount,
|
||||
LastModified: time.Unix(int64(volInfo.ModifiedAtSecond), 0),
|
||||
IsReadOnly: volInfo.ReadOnly,
|
||||
HasRemoteCopy: volInfo.RemoteStorageName != "",
|
||||
IsECVolume: false, // Will be determined from volume structure
|
||||
ReplicaCount: 1, // Will be counted
|
||||
ExpectedReplicas: int(volInfo.ReplicaPlacement),
|
||||
|
||||
@@ -1036,7 +1036,7 @@ func (r *Plugin) ensureJobTypeConfigFromDescriptor(jobType string, descriptor *p
|
||||
defaults := descriptor.AdminRuntimeDefaults
|
||||
adminRuntime = &plugin_pb.AdminRuntimeConfig{
|
||||
Enabled: defaults.Enabled,
|
||||
DetectionIntervalSeconds: defaults.DetectionIntervalSeconds,
|
||||
DetectionIntervalMinutes: defaults.DetectionIntervalMinutes,
|
||||
DetectionTimeoutSeconds: defaults.DetectionTimeoutSeconds,
|
||||
MaxJobsPerDetection: defaults.MaxJobsPerDetection,
|
||||
GlobalExecutionConcurrency: defaults.GlobalExecutionConcurrency,
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestEnsureJobTypeConfigFromDescriptorBootstrapsDefaults(t *testing.T) {
|
||||
},
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
Enabled: true,
|
||||
DetectionIntervalSeconds: 60,
|
||||
DetectionIntervalMinutes: 60,
|
||||
DetectionTimeoutSeconds: 20,
|
||||
MaxJobsPerDetection: 30,
|
||||
GlobalExecutionConcurrency: 4,
|
||||
|
||||
@@ -466,7 +466,7 @@ func (r *Plugin) loadSchedulerPolicy(jobType string) (schedulerPolicy, bool, err
|
||||
}
|
||||
|
||||
policy := schedulerPolicy{
|
||||
DetectionInterval: durationFromSeconds(adminRuntime.DetectionIntervalSeconds, defaultScheduledDetectionInterval),
|
||||
DetectionInterval: durationFromMinutes(adminRuntime.DetectionIntervalMinutes, defaultScheduledDetectionInterval),
|
||||
DetectionTimeout: durationFromSeconds(adminRuntime.DetectionTimeoutSeconds, defaultScheduledDetectionTimeout),
|
||||
ExecutionTimeout: durationFromSeconds(adminRuntime.ExecutionTimeoutSeconds, defaultScheduledExecutionTimeout),
|
||||
JobTypeMaxRuntime: durationFromSeconds(adminRuntime.JobTypeMaxRuntimeSeconds, defaultScheduledJobTypeMaxRuntime),
|
||||
@@ -548,7 +548,7 @@ func (r *Plugin) ListSchedulerStates() ([]SchedulerJobTypeState, error) {
|
||||
} else {
|
||||
state.Enabled = enabled
|
||||
if enabled {
|
||||
state.DetectionIntervalSeconds = secondsFromDuration(policy.DetectionInterval)
|
||||
state.DetectionIntervalMinutes = minutesFromDuration(policy.DetectionInterval)
|
||||
state.DetectionTimeoutSeconds = secondsFromDuration(policy.DetectionTimeout)
|
||||
state.ExecutionTimeoutSeconds = secondsFromDuration(policy.ExecutionTimeout)
|
||||
state.JobTypeMaxRuntimeSeconds = secondsFromDuration(policy.JobTypeMaxRuntime)
|
||||
@@ -613,8 +613,8 @@ func deriveSchedulerAdminRuntime(
|
||||
// default instead of the handler's declared baseline.
|
||||
if descriptor != nil && descriptor.AdminRuntimeDefaults != nil {
|
||||
defaults := descriptor.AdminRuntimeDefaults
|
||||
if adminConfig.DetectionIntervalSeconds <= 0 {
|
||||
adminConfig.DetectionIntervalSeconds = defaults.DetectionIntervalSeconds
|
||||
if adminConfig.DetectionIntervalMinutes <= 0 {
|
||||
adminConfig.DetectionIntervalMinutes = defaults.DetectionIntervalMinutes
|
||||
}
|
||||
if adminConfig.DetectionTimeoutSeconds <= 0 {
|
||||
adminConfig.DetectionTimeoutSeconds = defaults.DetectionTimeoutSeconds
|
||||
@@ -648,7 +648,7 @@ func deriveSchedulerAdminRuntime(
|
||||
defaults := descriptor.AdminRuntimeDefaults
|
||||
return &plugin_pb.AdminRuntimeConfig{
|
||||
Enabled: defaults.Enabled,
|
||||
DetectionIntervalSeconds: defaults.DetectionIntervalSeconds,
|
||||
DetectionIntervalMinutes: defaults.DetectionIntervalMinutes,
|
||||
DetectionTimeoutSeconds: defaults.DetectionTimeoutSeconds,
|
||||
MaxJobsPerDetection: defaults.MaxJobsPerDetection,
|
||||
GlobalExecutionConcurrency: defaults.GlobalExecutionConcurrency,
|
||||
@@ -1366,6 +1366,13 @@ func durationFromSeconds(seconds int32, defaultValue time.Duration) time.Duratio
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func durationFromMinutes(minutes int32, defaultValue time.Duration) time.Duration {
|
||||
if minutes <= 0 {
|
||||
return defaultValue
|
||||
}
|
||||
return time.Duration(minutes) * time.Minute
|
||||
}
|
||||
|
||||
func secondsFromDuration(duration time.Duration) int32 {
|
||||
if duration <= 0 {
|
||||
return 0
|
||||
@@ -1373,6 +1380,13 @@ func secondsFromDuration(duration time.Duration) int32 {
|
||||
return int32(duration / time.Second)
|
||||
}
|
||||
|
||||
func minutesFromDuration(duration time.Duration) int32 {
|
||||
if duration <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int32(duration / time.Minute)
|
||||
}
|
||||
|
||||
func waitForShutdownOrTimerWithContext(shutdown <-chan struct{}, ctx context.Context, duration time.Duration) bool {
|
||||
if duration <= 0 {
|
||||
return true
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestLoadSchedulerPolicyUsesAdminConfig(t *testing.T) {
|
||||
JobType: "vacuum",
|
||||
AdminRuntime: &plugin_pb.AdminRuntimeConfig{
|
||||
Enabled: true,
|
||||
DetectionIntervalSeconds: 30,
|
||||
DetectionIntervalMinutes: 30,
|
||||
DetectionTimeoutSeconds: 20,
|
||||
MaxJobsPerDetection: 123,
|
||||
GlobalExecutionConcurrency: 5,
|
||||
@@ -74,7 +74,7 @@ func TestLoadSchedulerPolicyUsesDescriptorDefaultsWhenConfigMissing(t *testing.T
|
||||
JobType: "ec",
|
||||
AdminRuntimeDefaults: &plugin_pb.AdminRuntimeDefaults{
|
||||
Enabled: true,
|
||||
DetectionIntervalSeconds: 60,
|
||||
DetectionIntervalMinutes: 60,
|
||||
DetectionTimeoutSeconds: 25,
|
||||
MaxJobsPerDetection: 30,
|
||||
GlobalExecutionConcurrency: 4,
|
||||
@@ -397,7 +397,7 @@ func TestListSchedulerStatesIncludesPolicyAndState(t *testing.T) {
|
||||
JobType: jobType,
|
||||
AdminRuntime: &plugin_pb.AdminRuntimeConfig{
|
||||
Enabled: true,
|
||||
DetectionIntervalSeconds: 45,
|
||||
DetectionIntervalMinutes: 45,
|
||||
DetectionTimeoutSeconds: 30,
|
||||
MaxJobsPerDetection: 80,
|
||||
GlobalExecutionConcurrency: 3,
|
||||
@@ -448,8 +448,8 @@ func TestListSchedulerStatesIncludesPolicyAndState(t *testing.T) {
|
||||
if state.NextDetectionAt.Unix() != nextDetectionAt.Unix() {
|
||||
t.Fatalf("unexpected next detection time: got=%v want=%v", state.NextDetectionAt, nextDetectionAt)
|
||||
}
|
||||
if state.DetectionIntervalSeconds != 45 {
|
||||
t.Fatalf("unexpected detection interval: got=%d", state.DetectionIntervalSeconds)
|
||||
if state.DetectionIntervalMinutes != 45 {
|
||||
t.Fatalf("unexpected detection interval: got=%d", state.DetectionIntervalMinutes)
|
||||
}
|
||||
if state.DetectionTimeoutSeconds != 30 {
|
||||
t.Fatalf("unexpected detection timeout: got=%d", state.DetectionTimeoutSeconds)
|
||||
@@ -657,7 +657,7 @@ func TestRunLaneSchedulerIterationLockBehavior(t *testing.T) {
|
||||
JobType: tt.jobType,
|
||||
AdminRuntime: &plugin_pb.AdminRuntimeConfig{
|
||||
Enabled: true,
|
||||
DetectionIntervalSeconds: 1,
|
||||
DetectionIntervalMinutes: 1,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -47,7 +47,7 @@ type SchedulerJobTypeStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DetectionInFlight bool `json:"detection_in_flight"`
|
||||
NextDetectionAt *time.Time `json:"next_detection_at,omitempty"`
|
||||
DetectionIntervalSeconds int32 `json:"detection_interval_seconds,omitempty"`
|
||||
DetectionIntervalMinutes int32 `json:"detection_interval_minutes,omitempty"`
|
||||
LastDetectedAt *time.Time `json:"last_detected_at,omitempty"`
|
||||
LastDetectedCount int `json:"last_detected_count,omitempty"`
|
||||
LastDetectionError string `json:"last_detection_error,omitempty"`
|
||||
@@ -347,7 +347,7 @@ func (r *Plugin) GetLaneSchedulerStatus(lane SchedulerLane) SchedulerStatus {
|
||||
Enabled: state.Enabled,
|
||||
DetectionInFlight: state.DetectionInFlight,
|
||||
NextDetectionAt: state.NextDetectionAt,
|
||||
DetectionIntervalSeconds: state.DetectionIntervalSeconds,
|
||||
DetectionIntervalMinutes: state.DetectionIntervalMinutes,
|
||||
}
|
||||
if !info.lastDetectedAt.IsZero() {
|
||||
jobStatus.LastDetectedAt = timeToPtr(info.lastDetectedAt)
|
||||
@@ -430,7 +430,7 @@ func (r *Plugin) GetSchedulerStatus() SchedulerStatus {
|
||||
Enabled: state.Enabled,
|
||||
DetectionInFlight: state.DetectionInFlight,
|
||||
NextDetectionAt: state.NextDetectionAt,
|
||||
DetectionIntervalSeconds: state.DetectionIntervalSeconds,
|
||||
DetectionIntervalMinutes: state.DetectionIntervalMinutes,
|
||||
}
|
||||
if !info.lastDetectedAt.IsZero() {
|
||||
jobStatus.LastDetectedAt = timeToPtr(info.lastDetectedAt)
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestGetLaneSchedulerStatusShowsActiveConcurrentLaneWork(t *testing.T) {
|
||||
JobType: jobType,
|
||||
AdminRuntime: &plugin_pb.AdminRuntimeConfig{
|
||||
Enabled: true,
|
||||
DetectionIntervalSeconds: 30,
|
||||
DetectionIntervalMinutes: 30,
|
||||
DetectionTimeoutSeconds: 15,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -88,7 +88,7 @@ type SchedulerJobTypeState struct {
|
||||
PolicyError string `json:"policy_error,omitempty"`
|
||||
DetectionInFlight bool `json:"detection_in_flight"`
|
||||
NextDetectionAt *time.Time `json:"next_detection_at,omitempty"`
|
||||
DetectionIntervalSeconds int32 `json:"detection_interval_seconds,omitempty"`
|
||||
DetectionIntervalMinutes int32 `json:"detection_interval_minutes,omitempty"`
|
||||
DetectionTimeoutSeconds int32 `json:"detection_timeout_seconds,omitempty"`
|
||||
ExecutionTimeoutSeconds int32 `json:"execution_timeout_seconds,omitempty"`
|
||||
JobTypeMaxRuntimeSeconds int32 `json:"job_type_max_runtime_seconds,omitempty"`
|
||||
|
||||
@@ -408,6 +408,57 @@ func TestECPlanningNotBlockedByUnrelatedBalance(t *testing.T) {
|
||||
"EC must still see all 4 disks even with an unrelated in-flight balance")
|
||||
}
|
||||
|
||||
// #9369: same-type physical disks collapse into one DiskInfo at the master;
|
||||
// the active topology must still expose one entry per physical disk_id.
|
||||
func TestECPlannerSeesEachPhysicalDisk(t *testing.T) {
|
||||
topology := NewActiveTopology(10)
|
||||
|
||||
const numServers = 7
|
||||
const disksPerServer = 2
|
||||
nodes := make([]*master_pb.DataNodeInfo, 0, numServers)
|
||||
for i := 1; i <= numServers; i++ {
|
||||
volumeInfos := make([]*master_pb.VolumeInformationMessage, 0, disksPerServer)
|
||||
for d := uint32(0); d < disksPerServer; d++ {
|
||||
volumeInfos = append(volumeInfos, &master_pb.VolumeInformationMessage{
|
||||
Id: uint32(i*10 + int(d)),
|
||||
DiskId: d,
|
||||
DiskType: "hdd",
|
||||
})
|
||||
}
|
||||
nodes = append(nodes, &master_pb.DataNodeInfo{
|
||||
Id: fmt.Sprintf("127.0.0.1:%d", 8080+i),
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
DiskId: 0,
|
||||
VolumeCount: int64(disksPerServer),
|
||||
MaxVolumeCount: 200,
|
||||
VolumeInfos: volumeInfos,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
require.NoError(t, topology.UpdateTopology(&master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: nodes,
|
||||
}},
|
||||
}},
|
||||
}))
|
||||
|
||||
candidates := topology.GetDisksWithEffectiveCapacity(TaskTypeErasureCoding, "", 0)
|
||||
assert.Equal(t, numServers*disksPerServer, len(candidates))
|
||||
|
||||
seen := make(map[string]bool, len(candidates))
|
||||
for _, c := range candidates {
|
||||
key := fmt.Sprintf("%s:%d", c.NodeID, c.DiskID)
|
||||
assert.False(t, seen[key], "duplicate placement target %s", key)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicInterfaces tests the public interface methods
|
||||
func TestPublicInterfaces(t *testing.T) {
|
||||
topology := NewActiveTopology(10)
|
||||
|
||||
@@ -8,6 +8,74 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
)
|
||||
|
||||
// splitDiskInfoByPhysicalDisk returns one master_pb.DiskInfo per physical
|
||||
// disk_id observed in VolumeInfos / EcShardInfos. Multiple same-type physical
|
||||
// disks collapse to one DiskInfo at the master; per-volume/per-shard records
|
||||
// keep the original disk_id and are the authoritative signal here. Capacity
|
||||
// is split evenly — the wire format doesn't carry per-disk capacity yet.
|
||||
func splitDiskInfoByPhysicalDisk(diskInfo *master_pb.DiskInfo) []*master_pb.DiskInfo {
|
||||
if diskInfo == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Records with DiskId=0 and a non-zero outer DiskId belong to the outer
|
||||
// disk — handles older payloads / fixtures that omit the per-record id.
|
||||
normalize := func(id uint32) uint32 {
|
||||
if id == 0 && diskInfo.DiskId != 0 {
|
||||
return diskInfo.DiskId
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
diskIDs := make(map[uint32]struct{})
|
||||
for _, vi := range diskInfo.VolumeInfos {
|
||||
diskIDs[normalize(vi.DiskId)] = struct{}{}
|
||||
}
|
||||
for _, eci := range diskInfo.EcShardInfos {
|
||||
diskIDs[normalize(eci.DiskId)] = struct{}{}
|
||||
}
|
||||
if len(diskIDs) == 0 {
|
||||
diskIDs[diskInfo.DiskId] = struct{}{}
|
||||
}
|
||||
|
||||
if len(diskIDs) == 1 {
|
||||
for diskID := range diskIDs {
|
||||
if diskID == diskInfo.DiskId {
|
||||
return []*master_pb.DiskInfo{diskInfo}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perDiskVolumes := make(map[uint32][]*master_pb.VolumeInformationMessage)
|
||||
for _, vi := range diskInfo.VolumeInfos {
|
||||
perDiskVolumes[normalize(vi.DiskId)] = append(perDiskVolumes[normalize(vi.DiskId)], vi)
|
||||
}
|
||||
perDiskShards := make(map[uint32][]*master_pb.VolumeEcShardInformationMessage)
|
||||
for _, eci := range diskInfo.EcShardInfos {
|
||||
perDiskShards[normalize(eci.DiskId)] = append(perDiskShards[normalize(eci.DiskId)], eci)
|
||||
}
|
||||
|
||||
count := int64(len(diskIDs))
|
||||
share := func(total int64) int64 { return total / count }
|
||||
|
||||
result := make([]*master_pb.DiskInfo, 0, len(diskIDs))
|
||||
for diskID := range diskIDs {
|
||||
result = append(result, &master_pb.DiskInfo{
|
||||
Type: diskInfo.Type,
|
||||
MaxVolumeCount: share(diskInfo.MaxVolumeCount),
|
||||
VolumeCount: int64(len(perDiskVolumes[diskID])),
|
||||
FreeVolumeCount: share(diskInfo.FreeVolumeCount),
|
||||
ActiveVolumeCount: share(diskInfo.ActiveVolumeCount),
|
||||
RemoteVolumeCount: share(diskInfo.RemoteVolumeCount),
|
||||
VolumeInfos: perDiskVolumes[diskID],
|
||||
EcShardInfos: perDiskShards[diskID],
|
||||
DiskId: diskID,
|
||||
Tags: append([]string(nil), diskInfo.Tags...),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CountTopologyResources counts datacenters, nodes, and disks in topology info
|
||||
func CountTopologyResources(topologyInfo *master_pb.TopologyInfo) (dcCount, nodeCount, diskCount int) {
|
||||
if topologyInfo == nil {
|
||||
@@ -73,24 +141,28 @@ func (at *ActiveTopology) UpdateTopology(topologyInfo *master_pb.TopologyInfo) e
|
||||
disks: make(map[uint32]*activeDisk),
|
||||
}
|
||||
|
||||
// Add disks for this node
|
||||
// One activeDisk per physical disk_id (#9369): the master keys
|
||||
// DiskInfos by disk type, so same-type disks must be split out.
|
||||
for diskType, diskInfo := range nodeInfo.DiskInfos {
|
||||
disk := &activeDisk{
|
||||
DiskInfo: &DiskInfo{
|
||||
NodeID: nodeInfo.Id,
|
||||
DiskID: diskInfo.DiskId,
|
||||
DiskType: diskType,
|
||||
DataCenter: dc.Id,
|
||||
Rack: rack.Id,
|
||||
DiskInfo: diskInfo,
|
||||
},
|
||||
}
|
||||
perDiskInfos := splitDiskInfoByPhysicalDisk(diskInfo)
|
||||
for _, perDisk := range perDiskInfos {
|
||||
disk := &activeDisk{
|
||||
DiskInfo: &DiskInfo{
|
||||
NodeID: nodeInfo.Id,
|
||||
DiskID: perDisk.DiskId,
|
||||
DiskType: diskType,
|
||||
DataCenter: dc.Id,
|
||||
Rack: rack.Id,
|
||||
DiskInfo: perDisk,
|
||||
},
|
||||
}
|
||||
|
||||
diskKey := fmt.Sprintf("%s:%d", nodeInfo.Id, diskInfo.DiskId)
|
||||
glog.V(3).Infof("UpdateTopology: adding disk key=%q nodeId=%q diskId=%d diskType=%q address=%q grpcPort=%d volumes=%d maxVolumes=%d",
|
||||
diskKey, nodeInfo.Id, diskInfo.DiskId, diskType, nodeInfo.Address, nodeInfo.GrpcPort, diskInfo.VolumeCount, diskInfo.MaxVolumeCount)
|
||||
node.disks[diskInfo.DiskId] = disk
|
||||
at.disks[diskKey] = disk
|
||||
diskKey := fmt.Sprintf("%s:%d", nodeInfo.Id, perDisk.DiskId)
|
||||
glog.V(3).Infof("UpdateTopology: adding disk key=%q nodeId=%q diskId=%d diskType=%q address=%q grpcPort=%d volumes=%d maxVolumes=%d",
|
||||
diskKey, nodeInfo.Id, perDisk.DiskId, diskType, nodeInfo.Address, nodeInfo.GrpcPort, perDisk.VolumeCount, perDisk.MaxVolumeCount)
|
||||
node.disks[perDisk.DiskId] = disk
|
||||
at.disks[diskKey] = disk
|
||||
}
|
||||
}
|
||||
|
||||
at.nodes[nodeInfo.Id] = node
|
||||
@@ -205,23 +277,27 @@ func (at *ActiveTopology) rebuildIndexes() {
|
||||
at.volumeIndex = make(map[uint32][]string)
|
||||
at.ecShardIndex = make(map[uint32][]string)
|
||||
|
||||
// Rebuild indexes from current topology
|
||||
// Index by the per-record DiskId (not the outer DiskInfo.DiskId) so the
|
||||
// keys match the per-physical-disk activeDisk entries — see #9369.
|
||||
for _, dc := range at.topologyInfo.DataCenterInfos {
|
||||
for _, rack := range dc.RackInfos {
|
||||
for _, nodeInfo := range rack.DataNodeInfos {
|
||||
for _, diskInfo := range nodeInfo.DiskInfos {
|
||||
diskKey := fmt.Sprintf("%s:%d", nodeInfo.Id, diskInfo.DiskId)
|
||||
|
||||
// Index volumes
|
||||
for _, volumeInfo := range diskInfo.VolumeInfos {
|
||||
volumeID := volumeInfo.Id
|
||||
at.volumeIndex[volumeID] = append(at.volumeIndex[volumeID], diskKey)
|
||||
diskID := volumeInfo.DiskId
|
||||
if diskID == 0 && diskInfo.DiskId != 0 {
|
||||
diskID = diskInfo.DiskId
|
||||
}
|
||||
diskKey := fmt.Sprintf("%s:%d", nodeInfo.Id, diskID)
|
||||
at.volumeIndex[volumeInfo.Id] = append(at.volumeIndex[volumeInfo.Id], diskKey)
|
||||
}
|
||||
|
||||
// Index EC shards
|
||||
for _, ecShardInfo := range diskInfo.EcShardInfos {
|
||||
volumeID := ecShardInfo.Id
|
||||
at.ecShardIndex[volumeID] = append(at.ecShardIndex[volumeID], diskKey)
|
||||
diskID := ecShardInfo.DiskId
|
||||
if diskID == 0 && diskInfo.DiskId != 0 {
|
||||
diskID = diskInfo.DiskId
|
||||
}
|
||||
diskKey := fmt.Sprintf("%s:%d", nodeInfo.Id, diskID)
|
||||
at.ecShardIndex[ecShardInfo.Id] = append(at.ecShardIndex[ecShardInfo.Id], diskKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +59,13 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
for i, crumb := range data.Breadcrumbs {
|
||||
if i == len(data.Breadcrumbs)-1 {
|
||||
<li class="breadcrumb-item active" aria-current="page">
|
||||
<a href={ dash.PUrl(ctx, fmt.Sprintf("/files?path=%s", crumb.Path)) } class="text-decoration-none">
|
||||
<a href={ dash.PUrl(ctx, fileBrowserPathURL(crumb.Path)) } class="text-decoration-none">
|
||||
{ crumb.Name }
|
||||
</a>
|
||||
</li>
|
||||
} else {
|
||||
<li class="breadcrumb-item">
|
||||
<a href={ dash.PUrl(ctx, fmt.Sprintf("/files?path=%s", crumb.Path)) } class="text-decoration-none">
|
||||
<a href={ dash.PUrl(ctx, fileBrowserPathURL(crumb.Path)) } class="text-decoration-none">
|
||||
if crumb.Name == "Root" {
|
||||
<i class="fas fa-home me-1"></i>
|
||||
}
|
||||
@@ -83,14 +83,14 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
<h6 class="m-0 font-weight-bold text-primary">
|
||||
<i class="fas fa-folder-open me-2"></i>
|
||||
if data.CurrentPath == "/" {
|
||||
<a href={ dash.PUrl(ctx, "/files?path=/") } class="text-decoration-none text-primary">Root Directory</a>
|
||||
<a href={ dash.PUrl(ctx, fileBrowserPathURL("/")) } class="text-decoration-none text-primary">Root Directory</a>
|
||||
} else if data.CurrentPath == "/buckets" {
|
||||
<a href={ dash.PUrl(ctx, "/files?path=/buckets") } class="text-decoration-none text-primary">Object Store Buckets Directory</a>
|
||||
<a href={ dash.PUrl(ctx, fileBrowserPathURL("/buckets")) } class="text-decoration-none text-primary">Object Store Buckets Directory</a>
|
||||
<a href={ dash.PUrl(ctx, "/object-store/buckets") } class="btn btn-sm btn-outline-primary ms-2">
|
||||
<i class="fas fa-cube me-1"></i>Manage Buckets
|
||||
</a>
|
||||
} else {
|
||||
<a href={ dash.PUrl(ctx, fmt.Sprintf("/files?path=%s", data.CurrentPath)) } class="text-decoration-none text-primary">{ filepath.Base(data.CurrentPath) }</a>
|
||||
<a href={ dash.PUrl(ctx, fileBrowserPathURL(data.CurrentPath)) } class="text-decoration-none text-primary">{ filepath.Base(data.CurrentPath) }</a>
|
||||
}
|
||||
</h6>
|
||||
|
||||
@@ -107,7 +107,7 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
if data.HasNextPage {
|
||||
<a href={ dash.PUrl(ctx, fmt.Sprintf("/files?path=%s&lastFileName=%s&limit=%d", data.CurrentPath, data.LastFileName, data.PageSize)) } class="btn btn-outline-primary" title="Next page">
|
||||
<a href={ dash.PUrl(ctx, fileBrowserPageURL(data.CurrentPath, data.LastFileName, data.PageSize)) } class="btn btn-outline-primary" title="Next page">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
} else {
|
||||
@@ -116,7 +116,7 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
</button>
|
||||
}
|
||||
if data.ParentPath != data.CurrentPath {
|
||||
<a href={ dash.PUrl(ctx, fmt.Sprintf("/files?path=%s", data.ParentPath)) } class="btn btn-outline-secondary" title="Go up one directory">
|
||||
<a href={ dash.PUrl(ctx, fileBrowserPathURL(data.ParentPath)) } class="btn btn-outline-secondary" title="Go up one directory">
|
||||
<i class="fas fa-arrow-up"></i> Up
|
||||
</a>
|
||||
}
|
||||
@@ -150,7 +150,7 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
<div class="d-flex align-items-center">
|
||||
if entry.IsDirectory {
|
||||
<i class="fas fa-folder text-warning me-2"></i>
|
||||
<a href={ dash.PUrl(ctx, fmt.Sprintf("/files?path=%s", entry.FullPath)) } class="text-decoration-none">
|
||||
<a href={ dash.PUrl(ctx, fileBrowserPathURL(entry.FullPath)) } class="text-decoration-none">
|
||||
{ entry.Name }
|
||||
</a>
|
||||
} else {
|
||||
@@ -238,7 +238,7 @@ templ FileBrowser(data dash.FileBrowserData) {
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
|
||||
if data.HasNextPage {
|
||||
<a href={ dash.PUrl(ctx, fmt.Sprintf("/files?path=%s&lastFileName=%s&limit=%d", data.CurrentPath, data.LastFileName, data.PageSize)) } class="btn btn-outline-primary">
|
||||
<a href={ dash.PUrl(ctx, fileBrowserPageURL(data.CurrentPath, data.LastFileName, data.PageSize)) } class="btn btn-outline-primary">
|
||||
Next <i class="fas fa-angle-right ms-1"></i>
|
||||
</a>
|
||||
} else {
|
||||
|
||||
@@ -140,7 +140,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 templ.SafeURL
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fmt.Sprintf("/files?path=%s", crumb.Path)))
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fileBrowserPathURL(crumb.Path)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 62, Col: 73}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 templ.SafeURL
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fmt.Sprintf("/files?path=%s", crumb.Path)))
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fileBrowserPathURL(crumb.Path)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 68, Col: 73}
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 templ.SafeURL
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/files?path=/"))
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fileBrowserPathURL("/")))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 86, Col: 45}
|
||||
}
|
||||
@@ -232,7 +232,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 templ.SafeURL
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, "/files?path=/buckets"))
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fileBrowserPathURL("/buckets")))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 88, Col: 58}
|
||||
}
|
||||
@@ -263,7 +263,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 templ.SafeURL
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fmt.Sprintf("/files?path=%s", data.CurrentPath)))
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fileBrowserPathURL(data.CurrentPath)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 93, Col: 77}
|
||||
}
|
||||
@@ -356,7 +356,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 templ.SafeURL
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fmt.Sprintf("/files?path=%s&lastFileName=%s&limit=%d", data.CurrentPath, data.LastFileName, data.PageSize)))
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fileBrowserPageURL(data.CurrentPath, data.LastFileName, data.PageSize)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 110, Col: 138}
|
||||
}
|
||||
@@ -380,7 +380,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fmt.Sprintf("/files?path=%s", data.ParentPath)))
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fileBrowserPathURL(data.ParentPath)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 119, Col: 78}
|
||||
}
|
||||
@@ -426,7 +426,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 templ.SafeURL
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fmt.Sprintf("/files?path=%s", entry.FullPath)))
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fileBrowserPathURL(entry.FullPath)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 153, Col: 83}
|
||||
}
|
||||
@@ -731,7 +731,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var35 templ.SafeURL
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fmt.Sprintf("/files?path=%s&lastFileName=%s&limit=%d", data.CurrentPath, data.LastFileName, data.PageSize)))
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(dash.PUrl(ctx, fileBrowserPageURL(data.CurrentPath, data.LastFileName, data.PageSize)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 241, Col: 138}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func fileBrowserPathURL(path string) string {
|
||||
return "/files?path=" + url.QueryEscape(path)
|
||||
}
|
||||
|
||||
func fileBrowserPageURL(path, lastFileName string, limit int) string {
|
||||
return fmt.Sprintf("%s&lastFileName=%s&limit=%d",
|
||||
fileBrowserPathURL(path),
|
||||
url.QueryEscape(lastFileName),
|
||||
limit,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFileBrowserPathURLEscapesQueryPath(t *testing.T) {
|
||||
path := "/buckets/airflow-logs/logs/run_id=backfill__2025-01-31T15:00:00+00:00"
|
||||
want := "/files?path=%2Fbuckets%2Fairflow-logs%2Flogs%2Frun_id%3Dbackfill__2025-01-31T15%3A00%3A00%2B00%3A00"
|
||||
|
||||
if got := fileBrowserPathURL(path); got != want {
|
||||
t.Fatalf("fileBrowserPathURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileBrowserPageURLEscapesCursor(t *testing.T) {
|
||||
got := fileBrowserPageURL("/buckets/airflow-logs/logs/run_id=a+b", "task:1+next", 50)
|
||||
want := "/files?path=%2Fbuckets%2Fairflow-logs%2Flogs%2Frun_id%3Da%2Bb&lastFileName=task%3A1%2Bnext&limit=50"
|
||||
|
||||
if got != want {
|
||||
t.Fatalf("fileBrowserPageURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -258,7 +258,7 @@ templ Plugin(page string, initialJob string, lane string) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="plugin-admin-detection-interval">Detection Interval (s)</label>
|
||||
<label class="form-label" for="plugin-admin-detection-interval">Detection Interval (min)</label>
|
||||
<input type="number" class="form-control" id="plugin-admin-detection-interval" min="0"/>
|
||||
<div class="form-text">How often to check for new work.</div>
|
||||
</div>
|
||||
@@ -758,7 +758,7 @@ templ Plugin(page string, initialJob string, lane string) {
|
||||
}
|
||||
|
||||
function updateURL(replace) {
|
||||
var url = buildPluginURL(state.activeTopTab, state.activeSubTab, state.selectedJobType);
|
||||
var url = basePath(buildPluginURL(state.activeTopTab, state.activeSubTab, state.selectedJobType));
|
||||
if (replace) {
|
||||
history.replaceState({ pluginTopTab: state.activeTopTab, pluginSubTab: state.activeSubTab, pluginJob: state.selectedJobType }, '', url);
|
||||
} else {
|
||||
@@ -2526,7 +2526,7 @@ templ Plugin(page string, initialJob string, lane string) {
|
||||
}
|
||||
|
||||
document.getElementById('plugin-admin-enabled').checked = pickBool('enabled');
|
||||
document.getElementById('plugin-admin-detection-interval').value = String(pickNumber('detection_interval_seconds'));
|
||||
document.getElementById('plugin-admin-detection-interval').value = String(pickNumber('detection_interval_minutes'));
|
||||
document.getElementById('plugin-admin-detection-timeout').value = String(pickNumber('detection_timeout_seconds'));
|
||||
document.getElementById('plugin-admin-execution-timeout').value = String(pickNumber('execution_timeout_seconds'));
|
||||
document.getElementById('plugin-admin-max-runtime').value = String(pickNumber('job_type_max_runtime_seconds'));
|
||||
@@ -2552,7 +2552,7 @@ templ Plugin(page string, initialJob string, lane string) {
|
||||
|
||||
return {
|
||||
enabled: !!document.getElementById('plugin-admin-enabled').checked,
|
||||
detection_interval_seconds: getInt('plugin-admin-detection-interval'),
|
||||
detection_interval_minutes: getInt('plugin-admin-detection-interval'),
|
||||
detection_timeout_seconds: getInt('plugin-admin-detection-timeout'),
|
||||
execution_timeout_seconds: getInt('plugin-admin-execution-timeout'),
|
||||
job_type_max_runtime_seconds: getInt('plugin-admin-max-runtime'),
|
||||
|
||||
@@ -112,7 +112,7 @@ templ PluginLane(page string, lane string) {
|
||||
html += '<tr>';
|
||||
html += '<td>' + jt.job_type + '</td>';
|
||||
html += '<td>' + (jt.enabled ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>') + '</td>';
|
||||
html += '<td>' + (jt.detection_interval_seconds || '-') + 's</td>';
|
||||
html += '<td>' + (jt.detection_interval_minutes || '-') + 'm</td>';
|
||||
html += '<td>' + (jt.detection_in_flight ? '<span class="badge bg-warning">Yes</span>' : 'No') + '</td>';
|
||||
html += '<td>' + (jt.next_detection_at ? new Date(jt.next_detection_at).toLocaleTimeString() : '-') + '</td>';
|
||||
html += '</tr>';
|
||||
@@ -132,7 +132,7 @@ templ PluginLane(page string, lane string) {
|
||||
html += '<td><a href="' + basePath('/plugin/configuration?job=' + s.job_type) + '">' + s.job_type + '</a></td>';
|
||||
html += '<td>' + (s.enabled ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>') + '</td>';
|
||||
html += '<td>' + (s.global_execution_concurrency || 1) + '</td>';
|
||||
html += '<td>' + (s.detection_interval_seconds || '-') + 's</td>';
|
||||
html += '<td>' + (s.detection_interval_minutes || '-') + 'm</td>';
|
||||
html += '<td>' + (s.last_run_status || '-') + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -156,7 +156,7 @@ func backupFromLocation(volumeServer pb.ServerAddress, grpcDialOption grpc.DialO
|
||||
|
||||
// If local volume is larger than remote, recreate it
|
||||
if datSize > stats.TailOffset {
|
||||
if err := v.Destroy(false); err != nil {
|
||||
if err := v.Destroy(false, false); err != nil {
|
||||
v.Close()
|
||||
return fmt.Errorf("destroying volume: %w", err), false
|
||||
}
|
||||
|
||||
+29
-5
@@ -1253,7 +1253,15 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) {
|
||||
// Wait for admin server's HTTP port to be ready before launching worker
|
||||
adminAddr := fmt.Sprintf("http://%s:%d", bindIp, *miniAdminOptions.port)
|
||||
glog.V(1).Infof("Waiting for admin server to be ready at %s...", adminAddr)
|
||||
if err := waitForAdminServerReady(adminAddr); err != nil {
|
||||
if err := waitForAdminServerReady(ctx, adminAddr); err != nil {
|
||||
// If the parent context was cancelled (e.g. a previous in-process
|
||||
// mini run is being torn down), bail out gracefully instead of
|
||||
// fataling — the test harness uses `cmd.Run` directly so a Fatalf
|
||||
// would terminate the entire test binary.
|
||||
if ctx.Err() != nil {
|
||||
glog.Warningf("Admin server readiness wait aborted: %v", err)
|
||||
return
|
||||
}
|
||||
glog.Fatalf("Admin server readiness check failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -1272,16 +1280,28 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) {
|
||||
waitForWorkerReady(workerGrpcAddr)
|
||||
}
|
||||
|
||||
// waitForAdminServerReady pings the admin server HTTP endpoint to check if it's ready
|
||||
func waitForAdminServerReady(adminAddr string) error {
|
||||
// waitForAdminServerReady pings the admin server HTTP endpoint to check if it's ready.
|
||||
// Returns ctx.Err() (typically context.Canceled) if the parent context is
|
||||
// cancelled mid-poll so a torn-down mini doesn't keep spinning for the full
|
||||
// timeout — relevant when tests embed mini in-process and reuse the same
|
||||
// goroutine pool across subtests.
|
||||
func waitForAdminServerReady(ctx context.Context, adminAddr string) error {
|
||||
healthAddr := getHealthCheckAddr(fmt.Sprintf("%s/health", adminAddr))
|
||||
maxAttempts := 60 // 60 * 500ms = 30 seconds max wait
|
||||
// 240 * 500ms = 120 seconds max wait. The previous 30-second ceiling was
|
||||
// too tight on busy CI runners where master + filer + volume + admin all
|
||||
// initialise on a shared worker — the S3 Policy Shell Integration Tests
|
||||
// flaked regularly even though the admin server still came up within a
|
||||
// minute or two. Two minutes leaves headroom without being absurd locally.
|
||||
maxAttempts := 240
|
||||
attempt := 0
|
||||
client := &http.Client{
|
||||
Timeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
for attempt < maxAttempts {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := client.Get(healthAddr)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
@@ -1289,7 +1309,11 @@ func waitForAdminServerReady(adminAddr string) error {
|
||||
return nil
|
||||
}
|
||||
attempt++
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("admin server did not become ready at %s after %d attempts", adminAddr, maxAttempts)
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestMiniDefaultPluginJobTypes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandlers(mini default) err = %v", err)
|
||||
}
|
||||
if len(handlers) != 6 {
|
||||
t.Fatalf("expected mini default job types to include 6 handlers, got %d", len(handlers))
|
||||
if len(handlers) != 7 {
|
||||
t.Fatalf("expected mini default job types to include 7 handlers, got %d", len(handlers))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_lifecycle_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/s3_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/iceberg"
|
||||
@@ -406,6 +407,7 @@ func (s3opt *S3Options) startS3Server() bool {
|
||||
}
|
||||
grpcS := pb.NewGrpcServer(security.LoadServerTLS(util.GetViper(), "grpc.s3"))
|
||||
s3_pb.RegisterSeaweedS3IamCacheServer(grpcS, s3ApiServer)
|
||||
s3_lifecycle_pb.RegisterSeaweedS3LifecycleInternalServer(grpcS, s3ApiServer)
|
||||
reflection.Register(grpcS)
|
||||
if grpcLocalL != nil {
|
||||
go grpcS.Serve(grpcLocalL)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/worker/tasks/vacuum"
|
||||
@@ -14,8 +15,26 @@ func TestWorkerDefaultJobTypes(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("buildPluginWorkerHandlers(default worker flag) err = %v", err)
|
||||
}
|
||||
// Expected: vacuum, volume_balance, admin_script, erasure_coding, iceberg_maintenance, ec_balance
|
||||
if len(handlers) != 6 {
|
||||
t.Fatalf("expected default worker job types to include 6 handlers, got %d", len(handlers))
|
||||
want := []string{
|
||||
"admin_script",
|
||||
"ec_balance",
|
||||
"erasure_coding",
|
||||
"iceberg_maintenance",
|
||||
"s3_lifecycle",
|
||||
"vacuum",
|
||||
"volume_balance",
|
||||
}
|
||||
got := make([]string, 0, len(handlers))
|
||||
for _, h := range handlers {
|
||||
got = append(got, h.Capability().JobType)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("default worker job types: got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("default worker job types: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func (store *FilerEtcStore) saveGroup(ctx context.Context, group *iam_pb.Group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filer.SaveInsideFiler(client, filer.IamConfigDirectory+"/"+IamGroupsDirectory, group.Name+".json", data)
|
||||
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory+"/"+IamGroupsDirectory, group.Name+".json", data)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -484,7 +484,7 @@ func (store *FilerEtcStore) saveIdentity(ctx context.Context, identity *iam_pb.I
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filer.SaveInsideFiler(client, filer.IamConfigDirectory+"/"+IamIdentitiesDirectory, identity.Name+".json", data)
|
||||
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory+"/"+IamIdentitiesDirectory, identity.Name+".json", data)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ func (store *FilerEtcStore) saveLegacyPoliciesCollection(ctx context.Context, po
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamPoliciesFile, content)
|
||||
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory, filer.IamPoliciesFile, content)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ func (store *FilerEtcStore) savePolicy(ctx context.Context, name string, documen
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filer.SaveInsideFiler(client, filer.IamConfigDirectory+"/"+IamPoliciesDirectory, name+".json", data)
|
||||
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory+"/"+IamPoliciesDirectory, name+".json", data)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ func (store *FilerEtcStore) saveServiceAccount(ctx context.Context, sa *iam_pb.S
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filer.SaveInsideFiler(client, filer.IamConfigDirectory+"/"+IamServiceAccountsDirectory, sa.Id+".json", data)
|
||||
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory+"/"+IamServiceAccountsDirectory, sa.Id+".json", data)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -41,15 +41,15 @@ func ReadInsideFiler(ctx context.Context, filerClient filer_pb.SeaweedFilerClien
|
||||
return
|
||||
}
|
||||
|
||||
func SaveInsideFiler(client filer_pb.SeaweedFilerClient, dir, name string, content []byte) error {
|
||||
func SaveInsideFiler(ctx context.Context, client filer_pb.SeaweedFilerClient, dir, name string, content []byte) error {
|
||||
|
||||
resp, err := filer_pb.LookupEntry(context.Background(), client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
resp, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: dir,
|
||||
Name: name,
|
||||
})
|
||||
|
||||
if err == filer_pb.ErrNotFound {
|
||||
err = filer_pb.CreateEntry(context.Background(), client, &filer_pb.CreateEntryRequest{
|
||||
err = filer_pb.CreateEntry(ctx, client, &filer_pb.CreateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: name,
|
||||
@@ -69,7 +69,7 @@ func SaveInsideFiler(client filer_pb.SeaweedFilerClient, dir, name string, conte
|
||||
entry.Content = content
|
||||
entry.Attributes.Mtime = time.Now().Unix()
|
||||
entry.Attributes.FileSize = uint64(len(content))
|
||||
err = filer_pb.UpdateEntry(context.Background(), client, &filer_pb.UpdateEntryRequest{
|
||||
err = filer_pb.UpdateEntry(ctx, client, &filer_pb.UpdateEntryRequest{
|
||||
Directory: dir,
|
||||
Entry: entry,
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@ func InsertMountMapping(filerClient filer_pb.FilerClient, dir string, remoteStor
|
||||
|
||||
// save back
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
return SaveInsideFiler(client, DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE, newContent)
|
||||
return SaveInsideFiler(context.Background(), client, DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE, newContent)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("save mapping: %w", err)
|
||||
@@ -83,7 +83,7 @@ func DeleteMountMapping(filerClient filer_pb.FilerClient, dir string) (err error
|
||||
|
||||
// save back
|
||||
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
return SaveInsideFiler(client, DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE, newContent)
|
||||
return SaveInsideFiler(context.Background(), client, DirectoryEtcRemote, REMOTE_STORAGE_MOUNT_FILE, newContent)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("save mapping: %w", err)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetProviderByIssuerAndAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
// Two providers for the same issuer, scoped to different accounts.
|
||||
must := func(rec *OIDCProviderRecord) {
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
}
|
||||
must(&OIDCProviderRecord{
|
||||
AccountID: "111",
|
||||
ARN: "arn:aws:iam::111:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"a"},
|
||||
})
|
||||
must(&OIDCProviderRecord{
|
||||
AccountID: "222",
|
||||
ARN: "arn:aws:iam::222:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"b"},
|
||||
})
|
||||
|
||||
t.Run("matching account returns scoped record", func(t *testing.T) {
|
||||
rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "111")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if rec.AccountID != "111" {
|
||||
t.Fatalf("unexpected record: %+v", rec)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown account is rejected", func(t *testing.T) {
|
||||
if _, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "999"); err == nil {
|
||||
t.Fatal("expected error for cross-account use")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("global provider satisfies any account", func(t *testing.T) {
|
||||
must(&OIDCProviderRecord{
|
||||
AccountID: "",
|
||||
ARN: "arn:aws:iam:::oidc-provider/global.example",
|
||||
URL: "https://global.example",
|
||||
ClientIDs: []string{"x"},
|
||||
})
|
||||
rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://global.example", "anything")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if rec.AccountID != "" {
|
||||
t.Fatalf("expected global record, got AccountID=%q", rec.AccountID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty caller account accepts any record", func(t *testing.T) {
|
||||
rec, err := store.GetProviderByIssuerAndAccount(ctx, "", "https://idp.example", "")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if rec == nil {
|
||||
t.Fatal("expected a record")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/oidc"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
@@ -23,14 +27,333 @@ const maxPoliciesForEvaluation = 1024
|
||||
|
||||
// IAMManager orchestrates all IAM components
|
||||
type IAMManager struct {
|
||||
stsService *sts.STSService
|
||||
policyEngine *policy.PolicyEngine
|
||||
roleStore RoleStore
|
||||
userStore UserStore
|
||||
filerAddressProvider func() string // Function to get current filer address
|
||||
initialized bool
|
||||
runtimePolicyMu sync.Mutex
|
||||
runtimePolicyNames map[string]struct{}
|
||||
stsService *sts.STSService
|
||||
policyEngine *policy.PolicyEngine
|
||||
roleStore RoleStore
|
||||
userStore UserStore
|
||||
oidcProviderStore OIDCProviderStore
|
||||
oidcAuditSink OIDCProviderAuditSink
|
||||
revocationStore SessionRevocationStore
|
||||
filerAddressProvider func() string // Function to get current filer address
|
||||
initialized bool
|
||||
runtimePolicyMu sync.Mutex
|
||||
runtimePolicyNames map[string]struct{}
|
||||
}
|
||||
|
||||
// SetOIDCProviderAuditSink configures the lifecycle event sink. When nil
|
||||
// (default), GlogAuditSink is used so events still surface in logs.
|
||||
func (m *IAMManager) SetOIDCProviderAuditSink(sink OIDCProviderAuditSink) {
|
||||
m.oidcAuditSink = sink
|
||||
}
|
||||
|
||||
// emitOIDCAudit logs a lifecycle event. Errors are swallowed: an audit
|
||||
// failure must never block an IAM mutation that has already succeeded.
|
||||
func (m *IAMManager) emitOIDCAudit(ctx context.Context, eventType OIDCProviderAuditEventType, arn, url string, detail map[string]string) {
|
||||
sink := m.oidcAuditSink
|
||||
if sink == nil {
|
||||
sink = GlogAuditSink{}
|
||||
}
|
||||
event := &OIDCProviderAuditEvent{
|
||||
Type: eventType,
|
||||
ARN: arn,
|
||||
URL: url,
|
||||
Detail: detail,
|
||||
OccurredAt: time.Now().UTC(),
|
||||
}
|
||||
if err := sink.Emit(ctx, event); err != nil {
|
||||
glog.Warningf("OIDC audit emit %s for %s: %v", eventType, arn, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSessionRevocationStore configures the per-session revocation list. When
|
||||
// nil, RevokeSession returns an error and IsSessionRevoked is a no-op (every
|
||||
// session is considered live until natural expiry). Operators who want
|
||||
// revocation must wire a store explicitly.
|
||||
func (m *IAMManager) SetSessionRevocationStore(store SessionRevocationStore) {
|
||||
m.revocationStore = store
|
||||
}
|
||||
|
||||
// RevokeSession marks a session as revoked using its JTI (which equals the
|
||||
// session ID for STS-issued tokens).
|
||||
func (m *IAMManager) RevokeSession(ctx context.Context, jti string, expiresAt time.Time, reason string) error {
|
||||
if m.revocationStore == nil {
|
||||
return fmt.Errorf("session revocation store not configured")
|
||||
}
|
||||
if jti == "" {
|
||||
return fmt.Errorf("jti cannot be empty")
|
||||
}
|
||||
return m.revocationStore.Revoke(ctx, m.getFilerAddress(), &RevocationEntry{
|
||||
JTI: jti,
|
||||
ExpiresAt: expiresAt,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// IsSessionRevoked returns true if the given JTI has been revoked. Returns
|
||||
// false (with nil error) when no revocation store is configured.
|
||||
func (m *IAMManager) IsSessionRevoked(ctx context.Context, jti string) (bool, error) {
|
||||
if m.revocationStore == nil || jti == "" {
|
||||
return false, nil
|
||||
}
|
||||
return m.revocationStore.IsRevoked(ctx, m.getFilerAddress(), jti)
|
||||
}
|
||||
|
||||
// PurgeRevokedSessions removes revocation entries whose underlying session
|
||||
// has already expired. Safe to call on a cron schedule.
|
||||
func (m *IAMManager) PurgeRevokedSessions(ctx context.Context) (int, error) {
|
||||
if m.revocationStore == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return m.revocationStore.Purge(ctx, m.getFilerAddress(), time.Now().UTC())
|
||||
}
|
||||
|
||||
// SetOIDCProviderStore configures the IAM-managed OIDC provider store. When
|
||||
// nil, OIDC provider IAM actions return ServiceNotReady. The store is the
|
||||
// source of truth for AssumeRoleWithWebIdentity provider resolution once
|
||||
// Phase 2b lands; in Phase 2a it is read-only and populated from static
|
||||
// configuration at boot.
|
||||
func (m *IAMManager) SetOIDCProviderStore(store OIDCProviderStore) {
|
||||
m.oidcProviderStore = store
|
||||
}
|
||||
|
||||
// GetOIDCProviderStore returns the configured store (may be nil).
|
||||
func (m *IAMManager) GetOIDCProviderStore() OIDCProviderStore {
|
||||
return m.oidcProviderStore
|
||||
}
|
||||
|
||||
// GetOIDCProvider returns the record for the given ARN, or an error if the
|
||||
// store is not configured or the record is missing.
|
||||
func (m *IAMManager) GetOIDCProvider(ctx context.Context, arn string) (*OIDCProviderRecord, error) {
|
||||
if m.oidcProviderStore == nil {
|
||||
return nil, fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
return m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
}
|
||||
|
||||
// ListOIDCProviders enumerates all configured OIDC providers.
|
||||
func (m *IAMManager) ListOIDCProviders(ctx context.Context) ([]*OIDCProviderRecord, error) {
|
||||
if m.oidcProviderStore == nil {
|
||||
return nil, fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
return m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
|
||||
}
|
||||
|
||||
// CreateOIDCProvider persists a new IAM-managed OIDC provider record. Refuses
|
||||
// to overwrite an existing record so callers see EntityAlreadyExists semantics.
|
||||
func (m *IAMManager) CreateOIDCProvider(ctx context.Context, rec *OIDCProviderRecord) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
if rec == nil {
|
||||
return fmt.Errorf("record cannot be nil")
|
||||
}
|
||||
if err := validateOIDCProviderRecord(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), rec.ARN)
|
||||
if err == nil && existing != nil {
|
||||
return fmt.Errorf("%w: %s", ErrOIDCProviderAlreadyExists, rec.ARN)
|
||||
}
|
||||
if err != nil && !errors.Is(err, ErrOIDCProviderNotFound) {
|
||||
return fmt.Errorf("lookup existing OIDC provider %q: %w", rec.ARN, err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rec.CreatedAt = now
|
||||
rec.UpdatedAt = now
|
||||
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.refreshOIDCProvidersBestEffort(ctx, "CreateOIDCProvider", rec.ARN)
|
||||
m.emitOIDCAudit(ctx, OIDCAuditEventCreated, rec.ARN, rec.URL, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOIDCProvider removes the IAM-managed record. Idempotent.
|
||||
func (m *IAMManager) DeleteOIDCProvider(ctx context.Context, arn string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
if err := m.oidcProviderStore.DeleteProvider(ctx, m.getFilerAddress(), arn); err != nil {
|
||||
return err
|
||||
}
|
||||
m.refreshOIDCProvidersBestEffort(ctx, "DeleteOIDCProvider", arn)
|
||||
m.emitOIDCAudit(ctx, OIDCAuditEventDeleted, arn, "", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddClientIDToOIDCProvider appends `clientID` to the provider's allowed
|
||||
// audience list. Adding an existing client ID is a no-op (AWS-compat).
|
||||
func (m *IAMManager) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
if clientID == "" {
|
||||
return fmt.Errorf("ClientID cannot be empty")
|
||||
}
|
||||
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, existing := range rec.ClientIDs {
|
||||
if existing == clientID {
|
||||
return nil // idempotent
|
||||
}
|
||||
}
|
||||
if len(rec.ClientIDs) >= 100 {
|
||||
return fmt.Errorf("ClientIDList must contain at most 100 entries")
|
||||
}
|
||||
rec.ClientIDs = append(rec.ClientIDs, clientID)
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.refreshOIDCProvidersBestEffort(ctx, "AddClientIDToOIDCProvider", arn)
|
||||
m.emitOIDCAudit(ctx, OIDCAuditEventClientIDAdded, rec.ARN, rec.URL, map[string]string{"clientId": clientID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveClientIDFromOIDCProvider drops `clientID` from the allowed audience
|
||||
// list. Removing a missing client ID is a no-op.
|
||||
func (m *IAMManager) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pruned := make([]string, 0, len(rec.ClientIDs))
|
||||
for _, existing := range rec.ClientIDs {
|
||||
if existing != clientID {
|
||||
pruned = append(pruned, existing)
|
||||
}
|
||||
}
|
||||
if len(pruned) == len(rec.ClientIDs) {
|
||||
return nil // not present; no-op
|
||||
}
|
||||
rec.ClientIDs = pruned
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.refreshOIDCProvidersBestEffort(ctx, "RemoveClientIDFromOIDCProvider", arn)
|
||||
m.emitOIDCAudit(ctx, OIDCAuditEventClientIDRemoved, rec.ARN, rec.URL, map[string]string{"clientId": clientID})
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateOIDCProviderThumbprints replaces the entire thumbprint list. AWS
|
||||
// constrains the list to 1..5 entries when non-empty; we mirror that bound.
|
||||
func (m *IAMManager) UpdateOIDCProviderThumbprints(ctx context.Context, arn string, thumbprints []string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
if len(thumbprints) > 5 {
|
||||
return fmt.Errorf("ThumbprintList must contain at most 5 entries, got %d", len(thumbprints))
|
||||
}
|
||||
for _, tp := range thumbprints {
|
||||
if !isValidSHA1Thumbprint(tp) {
|
||||
return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp)
|
||||
}
|
||||
}
|
||||
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec.Thumbprints = append([]string(nil), thumbprints...)
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.refreshOIDCProvidersBestEffort(ctx, "UpdateOIDCProviderThumbprints", arn)
|
||||
m.emitOIDCAudit(ctx, OIDCAuditEventThumbprintsSet, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(thumbprints))})
|
||||
return nil
|
||||
}
|
||||
|
||||
// TagOIDCProvider merges the supplied tags into the provider's tag set.
|
||||
func (m *IAMManager) TagOIDCProvider(ctx context.Context, arn string, tags map[string]string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rec.Tags == nil {
|
||||
rec.Tags = make(map[string]string, len(tags))
|
||||
}
|
||||
for k, v := range tags {
|
||||
rec.Tags[k] = v
|
||||
}
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.emitOIDCAudit(ctx, OIDCAuditEventTagsAdded, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(tags))})
|
||||
return nil
|
||||
}
|
||||
|
||||
// UntagOIDCProvider removes the named tags from the provider's tag set.
|
||||
func (m *IAMManager) UntagOIDCProvider(ctx context.Context, arn string, keys []string) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return fmt.Errorf("OIDC provider store not configured")
|
||||
}
|
||||
rec, err := m.oidcProviderStore.GetProviderByARN(ctx, m.getFilerAddress(), arn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
delete(rec.Tags, k)
|
||||
}
|
||||
rec.UpdatedAt = time.Now().UTC()
|
||||
if err := m.oidcProviderStore.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
return err
|
||||
}
|
||||
m.emitOIDCAudit(ctx, OIDCAuditEventTagsRemoved, rec.ARN, rec.URL, map[string]string{"count": fmt.Sprintf("%d", len(keys))})
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOIDCProviderRecord enforces the invariants AWS imposes on the
|
||||
// underlying CreateOpenIDConnectProvider call.
|
||||
func validateOIDCProviderRecord(rec *OIDCProviderRecord) error {
|
||||
if rec.URL == "" {
|
||||
return fmt.Errorf("Url is required")
|
||||
}
|
||||
if rec.ARN == "" {
|
||||
return fmt.Errorf("ARN is required")
|
||||
}
|
||||
if len(rec.ClientIDs) == 0 {
|
||||
return fmt.Errorf("ClientIDList must contain at least one entry")
|
||||
}
|
||||
if len(rec.ClientIDs) > 100 {
|
||||
return fmt.Errorf("ClientIDList must contain at most 100 entries")
|
||||
}
|
||||
if len(rec.Thumbprints) > 5 {
|
||||
return fmt.Errorf("ThumbprintList must contain at most 5 entries")
|
||||
}
|
||||
for _, tp := range rec.Thumbprints {
|
||||
if !isValidSHA1Thumbprint(tp) {
|
||||
return fmt.Errorf("invalid thumbprint %q: must be 40-character SHA-1 hex", tp)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidSHA1Thumbprint returns true iff `s` is exactly 40 hex characters,
|
||||
// matching the SHA-1 digest format AWS expects.
|
||||
func isValidSHA1Thumbprint(s string) bool {
|
||||
if len(s) != 40 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'f':
|
||||
case r >= 'A' && r <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IAMConfig holds configuration for all IAM components
|
||||
@@ -43,6 +366,17 @@ type IAMConfig struct {
|
||||
|
||||
// Role store configuration
|
||||
Roles *RoleStoreConfig `json:"roleStore"`
|
||||
|
||||
// OIDCProviders configures the IAM-managed OIDC provider store. Optional;
|
||||
// if absent the manager defaults to an in-memory store hydrated from
|
||||
// STS.Providers at boot.
|
||||
OIDCProviders *OIDCProviderStoreConfig `json:"oidcProviderStore,omitempty"`
|
||||
}
|
||||
|
||||
// OIDCProviderStoreConfig holds OIDC provider store configuration.
|
||||
type OIDCProviderStoreConfig struct {
|
||||
StoreType string `json:"storeType"` // memory, filer
|
||||
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"`
|
||||
}
|
||||
|
||||
// RoleStoreConfig holds role store configuration
|
||||
@@ -75,6 +409,12 @@ type RoleDefinition struct {
|
||||
|
||||
// Description is an optional description of the role
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// MaxSessionDuration is the upper bound (in seconds) on session length when
|
||||
// callers assume this role. Zero means "use the global STS default". When
|
||||
// set it must satisfy AWS bounds: 3600 ≤ MaxSessionDuration ≤ 43200.
|
||||
// Honoured by AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithCredentials.
|
||||
MaxSessionDuration int64 `json:"maxSessionDuration,omitempty"`
|
||||
}
|
||||
|
||||
// ActionRequest represents a request to perform an action
|
||||
@@ -190,10 +530,213 @@ func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() s
|
||||
}
|
||||
m.roleStore = roleStore
|
||||
|
||||
// Initialize OIDC provider store and hydrate from static configuration so
|
||||
// the read-only IAM API can return the same providers the STS service
|
||||
// already accepts. Mutations will land in Phase 2b.
|
||||
if err := m.initOIDCProviderStore(config); err != nil {
|
||||
return fmt.Errorf("failed to initialize OIDC provider store: %w", err)
|
||||
}
|
||||
|
||||
m.initialized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// initOIDCProviderStore creates the OIDC provider store and seeds it from the
|
||||
// static STS provider configuration. The static path remains the bootstrap
|
||||
// source: each enabled OIDC entry under STS.Providers is mirrored as an
|
||||
// OIDCProviderRecord so the IAM API surfaces the same set the STS service
|
||||
// validates against.
|
||||
func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
|
||||
store, err := m.createOIDCProviderStore(config.OIDCProviders)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.oidcProviderStore = store
|
||||
|
||||
if config.STS == nil {
|
||||
return nil
|
||||
}
|
||||
for _, pc := range config.STS.Providers {
|
||||
if pc == nil || !pc.Enabled || pc.Type != sts.ProviderTypeOIDC {
|
||||
continue
|
||||
}
|
||||
issuer, _ := pc.Config["issuer"].(string)
|
||||
if issuer == "" {
|
||||
glog.Warningf("OIDC provider %s in static config has empty issuer; skipping mirror to store", pc.Name)
|
||||
continue
|
||||
}
|
||||
accountID := ""
|
||||
if config.STS != nil {
|
||||
accountID = config.STS.AccountId
|
||||
}
|
||||
arn, err := DeriveOIDCProviderARN(accountID, issuer)
|
||||
if err != nil {
|
||||
glog.Warningf("derive ARN for static OIDC provider %s: %v", pc.Name, err)
|
||||
continue
|
||||
}
|
||||
clientIDs := extractClientIDs(pc.Config)
|
||||
ctx := context.Background()
|
||||
// Preserve CreatedAt across reboots when a persistent store already
|
||||
// has this provider — IAM's GetOpenIDConnectProvider response
|
||||
// shouldn't shift its CreateDate every time the server restarts.
|
||||
now := time.Now().UTC()
|
||||
createdAt := now
|
||||
if existing, err := store.GetProviderByARN(ctx, m.getFilerAddress(), arn); err == nil && existing != nil && !existing.CreatedAt.IsZero() {
|
||||
createdAt = existing.CreatedAt
|
||||
}
|
||||
rec := &OIDCProviderRecord{
|
||||
AccountID: accountID,
|
||||
ARN: arn,
|
||||
URL: issuer,
|
||||
ClientIDs: clientIDs,
|
||||
Thumbprints: extractStringList(pc.Config, "thumbprints"),
|
||||
AllowedPrincipalTagKeys: extractStringList(pc.Config, "allowedPrincipalTagKeys"),
|
||||
PolicyClaim: extractString(pc.Config, "policyClaim"),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := store.StoreProvider(ctx, m.getFilerAddress(), rec); err != nil {
|
||||
glog.Warningf("mirror static OIDC provider %s into store: %v", pc.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshOIDCProvidersBestEffort calls RefreshOIDCProvidersFromStore and
|
||||
// logs a warning on failure. The IAM API call has already succeeded by the
|
||||
// time we get here, so a refresh failure must not surface to the caller —
|
||||
// the worst case is that the local instance keeps the stale runtime view
|
||||
// until a peer's metadata-subscribe event triggers another refresh.
|
||||
func (m *IAMManager) refreshOIDCProvidersBestEffort(ctx context.Context, op, arn string) {
|
||||
if err := m.RefreshOIDCProvidersFromStore(ctx); err != nil {
|
||||
glog.Warningf("refresh OIDC providers after %s on %s: %v", op, arn, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshOIDCProvidersFromStore reloads every OIDCProviderRecord from the
|
||||
// configured store and pushes the resulting runtime providers into the STS
|
||||
// service so AssumeRoleWithWebIdentity sees the latest set without a
|
||||
// restart. Safe to call when no store is configured (returns nil) and when
|
||||
// the store is empty (clears the IAM-managed map). Records with empty URLs
|
||||
// or invalid configuration are logged and skipped so a single bad entry
|
||||
// does not stop the rest from refreshing.
|
||||
func (m *IAMManager) RefreshOIDCProvidersFromStore(ctx context.Context) error {
|
||||
if m.oidcProviderStore == nil || m.stsService == nil {
|
||||
return nil
|
||||
}
|
||||
records, err := m.oidcProviderStore.ListProviders(ctx, m.getFilerAddress())
|
||||
if err != nil {
|
||||
return fmt.Errorf("list OIDC providers: %w", err)
|
||||
}
|
||||
byIssuer := make(map[string][]sts.ScopedOIDCProvider, len(records))
|
||||
for _, rec := range records {
|
||||
if rec == nil || rec.URL == "" {
|
||||
continue
|
||||
}
|
||||
provider, err := buildOIDCProviderFromRecord(rec)
|
||||
if err != nil {
|
||||
glog.Warningf("skip refreshing OIDC provider %s: %v", rec.ARN, err)
|
||||
continue
|
||||
}
|
||||
// Multiple records may share an issuer when each is scoped to a
|
||||
// different account; STS picks the right one at validation time
|
||||
// based on the role being assumed. See lookupOIDCProviderForAccount.
|
||||
byIssuer[rec.URL] = append(byIssuer[rec.URL], sts.ScopedOIDCProvider{
|
||||
AccountID: rec.AccountID,
|
||||
Provider: provider,
|
||||
})
|
||||
}
|
||||
m.stsService.SetIAMManagedOIDCProviders(byIssuer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildOIDCProviderFromRecord turns a stored record into a runtime
|
||||
// OIDCProvider. The provider name is the ARN so re-registration is
|
||||
// idempotent and never collides with static-config entries.
|
||||
func buildOIDCProviderFromRecord(rec *OIDCProviderRecord) (*oidc.OIDCProvider, error) {
|
||||
if rec == nil {
|
||||
return nil, fmt.Errorf("record cannot be nil")
|
||||
}
|
||||
cfg := &oidc.OIDCConfig{
|
||||
Issuer: rec.URL,
|
||||
ClientIDs: append([]string(nil), rec.ClientIDs...),
|
||||
Thumbprints: append([]string(nil), rec.Thumbprints...),
|
||||
}
|
||||
provider := oidc.NewOIDCProvider(rec.ARN)
|
||||
if err := provider.Initialize(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// createOIDCProviderStore selects an OIDCProviderStore implementation. Defaults
|
||||
// to memory; "filer" requires a filerAddressProvider to be configured.
|
||||
func (m *IAMManager) createOIDCProviderStore(cfg *OIDCProviderStoreConfig) (OIDCProviderStore, error) {
|
||||
if cfg == nil || cfg.StoreType == "" || cfg.StoreType == "memory" {
|
||||
return NewMemoryOIDCProviderStore(), nil
|
||||
}
|
||||
if cfg.StoreType == "filer" {
|
||||
return NewFilerOIDCProviderStore(cfg.StoreConfig, m.filerAddressProvider), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported OIDC provider store type: %s", cfg.StoreType)
|
||||
}
|
||||
|
||||
// extractClientIDs reads a single clientId or a clientIds list from the
|
||||
// provider's static config map. Mirrors the OIDCConfig schema.
|
||||
func extractClientIDs(cfg map[string]interface{}) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if list, ok := cfg["clientIds"].([]interface{}); ok {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, v := range list {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
if id, ok := cfg["clientId"].(string); ok && id != "" {
|
||||
return []string{id}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractStringList reads a JSON string array out of the provider's static
|
||||
// config map and returns the non-empty entries. Returns nil when the key is
|
||||
// missing, the value is the wrong shape, or every entry is empty.
|
||||
func extractStringList(cfg map[string]interface{}, key string) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
list, ok := cfg[key].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, v := range list {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractString reads a single string field from the provider's static
|
||||
// config map; missing or non-string values produce "".
|
||||
func extractString(cfg map[string]interface{}, key string) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := cfg[key].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// getFilerAddress returns the current filer address using the provider function
|
||||
func (m *IAMManager) getFilerAddress() string {
|
||||
if m.filerAddressProvider != nil {
|
||||
@@ -272,6 +815,13 @@ func (m *IAMManager) CreateRole(ctx context.Context, filerAddress string, roleNa
|
||||
}
|
||||
}
|
||||
|
||||
// Validate per-role MaxSessionDuration if specified. AWS bounds: 1h..12h.
|
||||
if roleDef.MaxSessionDuration != 0 {
|
||||
if roleDef.MaxSessionDuration < 3600 || roleDef.MaxSessionDuration > 43200 {
|
||||
return fmt.Errorf("MaxSessionDuration must be between 3600 and 43200 seconds, got %d", roleDef.MaxSessionDuration)
|
||||
}
|
||||
}
|
||||
|
||||
// Store role definition
|
||||
return m.roleStore.StoreRole(ctx, "", roleName, roleDef)
|
||||
}
|
||||
@@ -315,6 +865,14 @@ func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts
|
||||
return nil, fmt.Errorf("IAM manager not initialized")
|
||||
}
|
||||
|
||||
// Claim-based mode bypasses role lookup and trust policy entirely; the
|
||||
// STS service handles the policy resolution from the JWT itself. Account
|
||||
// scoping still applies but at the provider-resolution layer in Phase 3c
|
||||
// (we'll plug that in once a multi-account assume path lands).
|
||||
if sts.IsClaimBasedPolicyRoleArn(request.RoleArn) {
|
||||
return m.stsService.AssumeRoleWithWebIdentity(ctx, request)
|
||||
}
|
||||
|
||||
// Extract role name from ARN
|
||||
roleName := utils.ExtractRoleNameFromArn(request.RoleArn)
|
||||
|
||||
@@ -324,15 +882,103 @@ func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts
|
||||
return nil, fmt.Errorf("role not found: %s", roleName)
|
||||
}
|
||||
|
||||
// Account scoping: when the role lives in account A, the OIDC provider
|
||||
// validating the token must be either global (AccountID="") or also live
|
||||
// in account A. Skip when we can't resolve account context; fall through
|
||||
// to the existing trust-policy enforcement.
|
||||
if err := m.enforceProviderAccountScope(ctx, request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate trust policy before allowing STS to assume the role
|
||||
if err := m.validateTrustPolicyForWebIdentity(ctx, roleDef, request.WebIdentityToken, request.DurationSeconds); err != nil {
|
||||
return nil, fmt.Errorf("trust policy validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Apply role-level MaxSessionDuration cap. The STS service still applies
|
||||
// the global MaxSessionLength and the source-token-expiry cap on top of
|
||||
// this; per-role takes precedence whenever it is the tightest bound.
|
||||
request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration)
|
||||
|
||||
// Use STS service to assume the role
|
||||
return m.stsService.AssumeRoleWithWebIdentity(ctx, request)
|
||||
}
|
||||
|
||||
// enforceProviderAccountScope checks that the OIDC provider matching the
|
||||
// token's issuer is registered in either the role's account or as a global
|
||||
// (account-less) provider. Returns nil when no provider store is configured
|
||||
// (preserves the static-config-only path) or when the issuer is not known to
|
||||
// the store (the existing STS-layer issuer→provider map handles that case
|
||||
// during validation).
|
||||
func (m *IAMManager) enforceProviderAccountScope(ctx context.Context, request *sts.AssumeRoleWithWebIdentityRequest) error {
|
||||
if m.oidcProviderStore == nil {
|
||||
return nil
|
||||
}
|
||||
roleAccount := utils.ParseRoleARN(request.RoleArn).AccountID
|
||||
if roleAccount == "" {
|
||||
// Legacy ARN form (no account): nothing to enforce.
|
||||
return nil
|
||||
}
|
||||
issuer, err := extractIssuerFromJWT(request.WebIdentityToken)
|
||||
if err != nil {
|
||||
return nil // signature validation will reject, no need to fail twice
|
||||
}
|
||||
// Look for a provider that is either global or scoped to the role's
|
||||
// account. Multiple providers may share an issuer (e.g. one global plus
|
||||
// one per tenant), and GetProviderByIssuer returns the first match
|
||||
// arbitrarily — using it here would falsely reject a valid request
|
||||
// whenever the wrong record happens to come back first.
|
||||
if _, err := m.oidcProviderStore.GetProviderByIssuerAndAccount(ctx, m.getFilerAddress(), issuer, roleAccount); err == nil {
|
||||
return nil
|
||||
}
|
||||
// No allowed match. Distinguish "issuer entirely unknown" (let the STS
|
||||
// layer reject with the existing not-registered error) from "issuer is
|
||||
// registered but only in a different account" (surface a precise error
|
||||
// so the operator knows the call was cross-account).
|
||||
if other, err := m.oidcProviderStore.GetProviderByIssuer(ctx, m.getFilerAddress(), issuer); err == nil {
|
||||
return fmt.Errorf("OIDC provider for issuer %s is registered in account %s; cannot be used to assume a role in account %s", issuer, other.AccountID, roleAccount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractIssuerFromJWT returns the iss claim of a JWT without verifying its
|
||||
// signature. Safe here because the caller still goes through full signature
|
||||
// + issuer validation in the STS service; this is purely for routing.
|
||||
func extractIssuerFromJWT(token string) (string, error) {
|
||||
parser := new(jwt.Parser)
|
||||
parsed, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid claims")
|
||||
}
|
||||
iss, _ := claims["iss"].(string)
|
||||
if iss == "" {
|
||||
return "", fmt.Errorf("token has no iss claim")
|
||||
}
|
||||
return iss, nil
|
||||
}
|
||||
|
||||
// capDurationByRole returns the requested duration clamped to the role's
|
||||
// MaxSessionDuration. A nil requested duration is left nil so the STS
|
||||
// service's calculateSessionDuration applies the global default (typically
|
||||
// 1 hour) — substituting the role's max here would silently mint a 12h
|
||||
// session for any caller who omitted DurationSeconds, which AWS does not
|
||||
// do. The role-max upper bound still applies in the downstream cap chain
|
||||
// once the request has a concrete duration.
|
||||
func capDurationByRole(requested *int64, roleMax int64) *int64 {
|
||||
if roleMax <= 0 || requested == nil {
|
||||
return requested
|
||||
}
|
||||
if *requested > roleMax {
|
||||
v := roleMax
|
||||
return &v
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
// AssumeRoleWithCredentials assumes a role using credentials (LDAP)
|
||||
func (m *IAMManager) AssumeRoleWithCredentials(ctx context.Context, request *sts.AssumeRoleWithCredentialsRequest) (*sts.AssumeRoleResponse, error) {
|
||||
if !m.initialized {
|
||||
@@ -353,6 +999,9 @@ func (m *IAMManager) AssumeRoleWithCredentials(ctx context.Context, request *sts
|
||||
return nil, fmt.Errorf("trust policy validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Apply role-level MaxSessionDuration cap.
|
||||
request.DurationSeconds = capDurationByRole(request.DurationSeconds, roleDef.MaxSessionDuration)
|
||||
|
||||
// Use STS service to assume the role
|
||||
return m.stsService.AssumeRoleWithCredentials(ctx, request)
|
||||
}
|
||||
@@ -387,6 +1036,18 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid session: %w", err)
|
||||
}
|
||||
// Reject sessions whose JTI has been added to the revocation
|
||||
// blocklist. SessionId == JTI for STS-issued tokens; this lookup
|
||||
// is hot, so the store implementation must be O(1).
|
||||
if sessionInfo != nil && sessionInfo.SessionId != "" {
|
||||
revoked, rerr := m.IsSessionRevoked(ctx, sessionInfo.SessionId)
|
||||
if rerr != nil {
|
||||
return false, fmt.Errorf("revocation check failed: %w", rerr)
|
||||
}
|
||||
if revoked {
|
||||
return false, fmt.Errorf("session has been revoked")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,13 +1111,20 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
|
||||
var baseResult *policy.EvaluationResult
|
||||
var err error
|
||||
subjectPolicyCount := 0
|
||||
// hasManagedSubject is true once we've resolved the principal to a registered
|
||||
// IAM user or role (or the caller has supplied PolicyNames directly). For a
|
||||
// managed subject, "no matching statement" must deny — the DefaultEffect=Allow
|
||||
// fallback is only meant for the unmanaged zero-config startup case.
|
||||
hasManagedSubject := false
|
||||
|
||||
if isAdmin {
|
||||
// Admin always has base access allowed
|
||||
baseResult = &policy.EvaluationResult{Effect: policy.EffectAllow}
|
||||
} else {
|
||||
policies := request.PolicyNames
|
||||
if len(policies) > 0 {
|
||||
hasManagedSubject = true
|
||||
}
|
||||
if len(policies) == 0 {
|
||||
// Extract role name from principal ARN
|
||||
roleName := utils.ExtractRoleNameFromPrincipal(request.Principal)
|
||||
@@ -472,6 +1140,7 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
if err != nil || user == nil {
|
||||
return false, fmt.Errorf("user not found for principal: %s (user=%s)", request.Principal, userName)
|
||||
}
|
||||
hasManagedSubject = true
|
||||
policies = user.GetPolicyNames()
|
||||
} else {
|
||||
// Get role definition
|
||||
@@ -480,11 +1149,10 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
return false, fmt.Errorf("role not found: %s", roleName)
|
||||
}
|
||||
|
||||
hasManagedSubject = true
|
||||
policies = roleDef.AttachedPolicies
|
||||
}
|
||||
}
|
||||
subjectPolicyCount = len(policies)
|
||||
|
||||
if bucketPolicyName != "" {
|
||||
// Enforce an upper bound on the number of policies to avoid excessive allocations
|
||||
if len(policies) >= maxPoliciesForEvaluation {
|
||||
@@ -508,10 +1176,11 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
|
||||
}
|
||||
|
||||
// Zero-config IAM uses DefaultEffect=Allow to preserve open-by-default behavior
|
||||
// for requests without any subject policies. Once a user or role has attached
|
||||
// policies, "no matching statement" must fall back to deny so the attachment
|
||||
// actually scopes access.
|
||||
if subjectPolicyCount > 0 && len(baseResult.MatchingStatements) == 0 {
|
||||
// for requests without any subject policies. Once we resolve the principal to
|
||||
// a registered IAM user or role (or the caller hands us policy names),
|
||||
// "no matching statement" must fall back to deny — otherwise a freshly
|
||||
// created user with zero policies would inherit full access.
|
||||
if hasManagedSubject && len(baseResult.MatchingStatements) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeUserStore struct {
|
||||
users map[string]*iam_pb.Identity
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) GetUser(_ context.Context, name string) (*iam_pb.Identity, error) {
|
||||
if u, ok := f.users[name]; ok {
|
||||
return u, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestIsActionAllowed_RegisteredUserWithoutPoliciesIsDenied is a regression test
|
||||
// for a security issue where a user created via the S3 IAM API with no policies
|
||||
// attached was granted access to every S3 action. The default-allow path was
|
||||
// intended for unmanaged zero-config startup, not for explicitly registered IAM
|
||||
// users with an empty policy set.
|
||||
func TestIsActionAllowed_RegisteredUserWithoutPoliciesIsDenied(t *testing.T) {
|
||||
manager := NewIAMManager()
|
||||
require.NoError(t, manager.Initialize(&IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
// Reproduce the zero-config setup: with no IAM config file, the
|
||||
// loader picks DefaultEffect=Allow for in-memory mode.
|
||||
DefaultEffect: string(policy.EffectAllow),
|
||||
StoreType: sts.StoreTypeMemory,
|
||||
},
|
||||
Roles: &RoleStoreConfig{StoreType: sts.StoreTypeMemory},
|
||||
}, func() string { return "localhost:8888" }))
|
||||
|
||||
manager.SetUserStore(&fakeUserStore{users: map[string]*iam_pb.Identity{
|
||||
"newuser": {Name: "newuser"}, // no PolicyNames, no Actions
|
||||
}})
|
||||
|
||||
allowed, err := manager.IsActionAllowed(context.Background(), &ActionRequest{
|
||||
Principal: "arn:aws:iam::seaweedfs:user/newuser",
|
||||
Action: "s3:DeleteBucket",
|
||||
Resource: "arn:aws:s3:::any-bucket",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "registered user with no policies must not get default-allow access")
|
||||
}
|
||||
|
||||
// TestIsActionAllowed_RegisteredRoleWithoutPoliciesIsDenied is the role-side
|
||||
// counterpart of the registered-user regression: a managed role that exists
|
||||
// but has zero AttachedPolicies must not inherit full access from the
|
||||
// zero-config DefaultEffect=Allow fallback. Coderabbit flagged this as an
|
||||
// uncovered branch of the same fix on PR #9317.
|
||||
func TestIsActionAllowed_RegisteredRoleWithoutPoliciesIsDenied(t *testing.T) {
|
||||
manager := NewIAMManager()
|
||||
require.NoError(t, manager.Initialize(&IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: string(policy.EffectAllow),
|
||||
StoreType: sts.StoreTypeMemory,
|
||||
},
|
||||
Roles: &RoleStoreConfig{StoreType: sts.StoreTypeMemory},
|
||||
}, func() string { return "localhost:8888" }))
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, manager.CreateRole(ctx, "", "EmptyRole", &RoleDefinition{
|
||||
RoleName: "EmptyRole",
|
||||
RoleArn: "arn:aws:iam::seaweedfs:role/EmptyRole",
|
||||
AttachedPolicies: nil, // explicit: managed role with no policies attached
|
||||
}))
|
||||
|
||||
allowed, err := manager.IsActionAllowed(ctx, &ActionRequest{
|
||||
// Use the assumed-role principal form the STS path produces. The
|
||||
// IAM manager extracts role name via ExtractRoleNameFromPrincipal,
|
||||
// finds the role definition, and must deny because AttachedPolicies
|
||||
// is empty even though DefaultEffect is Allow.
|
||||
Principal: "arn:aws:sts::seaweedfs:assumed-role/EmptyRole/some-session",
|
||||
Action: "s3:DeleteBucket",
|
||||
Resource: "arn:aws:s3:::any-bucket",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "registered role with no attached policies must not get default-allow access")
|
||||
}
|
||||
|
||||
// TestIsActionAllowed_RegisteredUserWithNonMatchingPolicyIsDenied covers the
|
||||
// adjacent case: the user has policies attached, but none of the policy
|
||||
// statements match the requested action/resource. The DefaultEffect=Allow
|
||||
// fallback must not silently grant access when an attached policy simply
|
||||
// does not say anything about this action.
|
||||
func TestIsActionAllowed_RegisteredUserWithNonMatchingPolicyIsDenied(t *testing.T) {
|
||||
manager := NewIAMManager()
|
||||
require.NoError(t, manager.Initialize(&IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{
|
||||
DefaultEffect: string(policy.EffectAllow),
|
||||
StoreType: sts.StoreTypeMemory,
|
||||
},
|
||||
Roles: &RoleStoreConfig{StoreType: sts.StoreTypeMemory},
|
||||
}, func() string { return "localhost:8888" }))
|
||||
|
||||
ctx := context.Background()
|
||||
require.NoError(t, manager.CreatePolicy(ctx, "", "ReadOnlyOneBucket", &policy.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []policy.Statement{{
|
||||
Effect: "Allow",
|
||||
Action: []string{"s3:GetObject"},
|
||||
Resource: []string{"arn:aws:s3:::reports/*"},
|
||||
}},
|
||||
}))
|
||||
|
||||
manager.SetUserStore(&fakeUserStore{users: map[string]*iam_pb.Identity{
|
||||
"alice": {Name: "alice", PolicyNames: []string{"ReadOnlyOneBucket"}},
|
||||
}})
|
||||
|
||||
allowed, err := manager.IsActionAllowed(ctx, &ActionRequest{
|
||||
Principal: "arn:aws:iam::seaweedfs:user/alice",
|
||||
Action: "s3:DeleteBucket",
|
||||
Resource: "arn:aws:s3:::other-bucket",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed, "action outside the user's attached policy must be denied")
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// OIDCProviderAuditEventType enumerates the lifecycle events emitted when an
|
||||
// IAM-managed OIDC provider record is mutated. Use-events (every successful
|
||||
// token validation) are intentionally not emitted by the lifecycle path:
|
||||
// they're too hot to stream into a filer-backed sink without spikes. A
|
||||
// future PR can add a sampled-use sink behind an explicit opt-in flag.
|
||||
type OIDCProviderAuditEventType string
|
||||
|
||||
const (
|
||||
OIDCAuditEventCreated OIDCProviderAuditEventType = "Create"
|
||||
OIDCAuditEventDeleted OIDCProviderAuditEventType = "Delete"
|
||||
OIDCAuditEventClientIDAdded OIDCProviderAuditEventType = "AddClientID"
|
||||
OIDCAuditEventClientIDRemoved OIDCProviderAuditEventType = "RemoveClientID"
|
||||
OIDCAuditEventThumbprintsSet OIDCProviderAuditEventType = "UpdateThumbprints"
|
||||
OIDCAuditEventTagsAdded OIDCProviderAuditEventType = "Tag"
|
||||
OIDCAuditEventTagsRemoved OIDCProviderAuditEventType = "Untag"
|
||||
)
|
||||
|
||||
// OIDCProviderAuditEvent is the payload emitted for each lifecycle event.
|
||||
type OIDCProviderAuditEvent struct {
|
||||
Type OIDCProviderAuditEventType `json:"type"`
|
||||
ARN string `json:"arn"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Detail map[string]string `json:"detail,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
// OIDCProviderAuditSink consumes lifecycle events. Implementations must be
|
||||
// safe for concurrent use; emit happens with the IAM manager's mutation lock
|
||||
// held, so a slow sink slows the IAM API.
|
||||
type OIDCProviderAuditSink interface {
|
||||
Emit(ctx context.Context, event *OIDCProviderAuditEvent) error
|
||||
}
|
||||
|
||||
// GlogAuditSink writes one structured log line per event. Always-on default
|
||||
// when an explicit sink isn't configured — events still appear in stdout/the
|
||||
// log aggregator, just not in a queryable record.
|
||||
type GlogAuditSink struct{}
|
||||
|
||||
func (GlogAuditSink) Emit(_ context.Context, event *OIDCProviderAuditEvent) error {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
glog.V(0).Infof("oidc-audit: %s arn=%s err-marshal=%v", event.Type, event.ARN, err)
|
||||
return nil
|
||||
}
|
||||
glog.V(0).Infof("oidc-audit: %s", string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemoryAuditSink keeps events in process memory for tests and short-lived
|
||||
// inspection. Not durable; drop entries by reading from Events() and
|
||||
// discarding.
|
||||
type MemoryAuditSink struct {
|
||||
mu sync.Mutex
|
||||
events []*OIDCProviderAuditEvent
|
||||
}
|
||||
|
||||
func NewMemoryAuditSink() *MemoryAuditSink {
|
||||
return &MemoryAuditSink{}
|
||||
}
|
||||
|
||||
func (m *MemoryAuditSink) Emit(_ context.Context, event *OIDCProviderAuditEvent) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := *event
|
||||
m.events = append(m.events, &cp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Events returns a copy of the captured event log so tests can assert.
|
||||
func (m *MemoryAuditSink) Events() []*OIDCProviderAuditEvent {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]*OIDCProviderAuditEvent, len(m.events))
|
||||
copy(out, m.events)
|
||||
return out
|
||||
}
|
||||
|
||||
// FilerAuditSink appends event records as separate files under a filer
|
||||
// directory. Each event is its own file so concurrent writers don't conflict;
|
||||
// the filename is `<unixnano>-<type>-<arnhash>.json`. For higher volumes,
|
||||
// switch to an append-only journal — the contract is just `Emit`.
|
||||
type FilerAuditSink struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerAuditSink returns a filer-backed sink. Default basePath
|
||||
// `/etc/iam/audit/oidc-providers` keeps audit records out of the active
|
||||
// IAM data directories.
|
||||
func NewFilerAuditSink(config map[string]interface{}, filerAddressProvider func() string) *FilerAuditSink {
|
||||
sink := &FilerAuditSink{
|
||||
basePath: "/etc/iam/audit/oidc-providers",
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
if config != nil {
|
||||
if bp, ok := config["basePath"].(string); ok && bp != "" {
|
||||
sink.basePath = strings.TrimSuffix(bp, "/")
|
||||
}
|
||||
}
|
||||
return sink
|
||||
}
|
||||
|
||||
func (f *FilerAuditSink) resolveFilerAddress() string {
|
||||
if f.filerAddressProvider != nil {
|
||||
return f.filerAddressProvider()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *FilerAuditSink) Emit(ctx context.Context, event *OIDCProviderAuditEvent) error {
|
||||
addr := f.resolveFilerAddress()
|
||||
if addr == "" {
|
||||
return fmt.Errorf("filer address not available")
|
||||
}
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
if event.OccurredAt.IsZero() {
|
||||
event.OccurredAt = time.Now().UTC()
|
||||
}
|
||||
data, err := json.MarshalIndent(event, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal audit event: %v", err)
|
||||
}
|
||||
// Filename: <unixnano>-<type>-<arnhash>.json. Two events at the same
|
||||
// nano against the same ARN+type are vanishingly rare, but two events
|
||||
// at the same nano against different ARNs are perfectly plausible
|
||||
// (audit-via-batch script). The ARN hash gives us an effectively-
|
||||
// collision-free name without leaking the ARN into the filer path.
|
||||
arnSum := sha1.Sum([]byte(event.ARN))
|
||||
name := fmt.Sprintf("%d-%s-%s.json", event.OccurredAt.UnixNano(), event.Type, hex.EncodeToString(arnSum[:8]))
|
||||
occurredAt := event.OccurredAt.Unix()
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(addr), f.grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: name,
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
// Reflect the event's occurrence time so file metadata
|
||||
// matches the audit record itself, not the filer write.
|
||||
Mtime: occurredAt,
|
||||
Crtime: occurredAt,
|
||||
FileMode: uint32(0o600),
|
||||
},
|
||||
Content: data,
|
||||
},
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
)
|
||||
|
||||
func newAuditableManager(t *testing.T) (*IAMManager, *MemoryAuditSink) {
|
||||
t.Helper()
|
||||
mgr := NewIAMManager()
|
||||
sink := NewMemoryAuditSink()
|
||||
cfg := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
mgr.SetOIDCProviderAuditSink(sink)
|
||||
return mgr, sink
|
||||
}
|
||||
|
||||
func TestOIDCAuditLifecycleEventsAreEmitted(t *testing.T) {
|
||||
mgr, sink := newAuditableManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
rec := &OIDCProviderRecord{
|
||||
AccountID: "111122223333",
|
||||
ARN: "arn:aws:iam::111122223333:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"x"},
|
||||
}
|
||||
if err := mgr.CreateOIDCProvider(ctx, rec); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := mgr.AddClientIDToOIDCProvider(ctx, rec.ARN, "y"); err != nil {
|
||||
t.Fatalf("AddClientID: %v", err)
|
||||
}
|
||||
if err := mgr.RemoveClientIDFromOIDCProvider(ctx, rec.ARN, "y"); err != nil {
|
||||
t.Fatalf("RemoveClientID: %v", err)
|
||||
}
|
||||
if err := mgr.UpdateOIDCProviderThumbprints(ctx, rec.ARN, []string{"0000000000000000000000000000000000000000"}); err != nil {
|
||||
t.Fatalf("UpdateThumbprints: %v", err)
|
||||
}
|
||||
if err := mgr.TagOIDCProvider(ctx, rec.ARN, map[string]string{"team": "infra"}); err != nil {
|
||||
t.Fatalf("Tag: %v", err)
|
||||
}
|
||||
if err := mgr.UntagOIDCProvider(ctx, rec.ARN, []string{"team"}); err != nil {
|
||||
t.Fatalf("Untag: %v", err)
|
||||
}
|
||||
if err := mgr.DeleteOIDCProvider(ctx, rec.ARN); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
want := []OIDCProviderAuditEventType{
|
||||
OIDCAuditEventCreated,
|
||||
OIDCAuditEventClientIDAdded,
|
||||
OIDCAuditEventClientIDRemoved,
|
||||
OIDCAuditEventThumbprintsSet,
|
||||
OIDCAuditEventTagsAdded,
|
||||
OIDCAuditEventTagsRemoved,
|
||||
OIDCAuditEventDeleted,
|
||||
}
|
||||
events := sink.Events()
|
||||
if len(events) != len(want) {
|
||||
t.Fatalf("expected %d events, got %d (%+v)", len(want), len(events), events)
|
||||
}
|
||||
for i, e := range events {
|
||||
if e.Type != want[i] {
|
||||
t.Errorf("event %d: type=%s want=%s", i, e.Type, want[i])
|
||||
}
|
||||
if e.ARN != rec.ARN {
|
||||
t.Errorf("event %d: ARN=%s want=%s", i, e.ARN, rec.ARN)
|
||||
}
|
||||
if e.OccurredAt.IsZero() {
|
||||
t.Errorf("event %d has zero OccurredAt", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCAuditDefaultsToGlogSink(t *testing.T) {
|
||||
// No SetOIDCProviderAuditSink call — should default to glog and not panic.
|
||||
mgr, _ := newAuditableManager(t)
|
||||
mgr.SetOIDCProviderAuditSink(nil)
|
||||
rec := &OIDCProviderRecord{
|
||||
AccountID: "111122223333",
|
||||
ARN: "arn:aws:iam::111122223333:oidc-provider/idp.example",
|
||||
URL: "https://idp.example",
|
||||
ClientIDs: []string{"x"},
|
||||
}
|
||||
if err := mgr.CreateOIDCProvider(context.Background(), rec); err != nil {
|
||||
t.Fatalf("Create with default sink: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
||||
)
|
||||
|
||||
func TestStaticConfigSeedsProviderStore(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
cfg := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
Providers: []*sts.ProviderConfig{
|
||||
{
|
||||
Name: "google",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://accounts.google.com",
|
||||
"clientId": "1234.apps.googleusercontent.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "github-actions",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://token.actions.githubusercontent.com",
|
||||
"clientId": "sts.amazonaws.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "disabled-google",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: false,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://accounts.disabled.example",
|
||||
"clientId": "x",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
store := mgr.GetOIDCProviderStore()
|
||||
if store == nil {
|
||||
t.Fatal("expected store to be initialized")
|
||||
}
|
||||
|
||||
got, err := mgr.ListOIDCProviders(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListOIDCProviders: %v", err)
|
||||
}
|
||||
// Disabled providers must not be mirrored.
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 providers, got %d (%v)", len(got), got)
|
||||
}
|
||||
|
||||
byArn := map[string]*OIDCProviderRecord{}
|
||||
for _, r := range got {
|
||||
byArn[r.ARN] = r
|
||||
}
|
||||
|
||||
googleARN := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
||||
g, ok := byArn[googleARN]
|
||||
if !ok {
|
||||
t.Fatalf("missing google ARN; have %v", byArn)
|
||||
}
|
||||
if len(g.ClientIDs) != 1 || g.ClientIDs[0] != "1234.apps.googleusercontent.com" {
|
||||
t.Fatalf("google clientIds wrong: %v", g.ClientIDs)
|
||||
}
|
||||
|
||||
ghARN := "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
|
||||
gh, ok := byArn[ghARN]
|
||||
if !ok {
|
||||
t.Fatalf("missing github-actions ARN; have %v", byArn)
|
||||
}
|
||||
if len(gh.ClientIDs) != 1 || gh.ClientIDs[0] != "sts.amazonaws.com" {
|
||||
t.Fatalf("github clientIds wrong: %v", gh.ClientIDs)
|
||||
}
|
||||
|
||||
// Spot-check the IAM read path returns the same record.
|
||||
rec, err := mgr.GetOIDCProvider(context.Background(), googleARN)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOIDCProvider: %v", err)
|
||||
}
|
||||
if rec.URL != "https://accounts.google.com" {
|
||||
t.Fatalf("URL mismatch: %s", rec.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticConfigMirrorsThumbprintsAndAdvancedFields(t *testing.T) {
|
||||
// Static config sets thumbprints, AllowedPrincipalTagKeys, and PolicyClaim;
|
||||
// the mirror into the IAM-managed store must carry all three. Without it,
|
||||
// the next RefreshOIDCProvidersFromStore rebuilds a runtime provider that
|
||||
// drops thumbprint pinning and silently disables claim-based policies and
|
||||
// principal-tag passthrough — and because IAM-managed entries take
|
||||
// precedence over the static-config map, the bootstrap provider gets
|
||||
// shadowed by this weaker rebuild.
|
||||
mgr := NewIAMManager()
|
||||
cfg := &IAMConfig{
|
||||
STS: &sts.STSConfig{
|
||||
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
||||
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
||||
Issuer: "test-sts",
|
||||
SigningKey: []byte("test-signing-key-32-characters-long"),
|
||||
AccountId: "111122223333",
|
||||
Providers: []*sts.ProviderConfig{
|
||||
{
|
||||
Name: "github-actions",
|
||||
Type: sts.ProviderTypeOIDC,
|
||||
Enabled: true,
|
||||
Config: map[string]interface{}{
|
||||
"issuer": "https://token.actions.githubusercontent.com",
|
||||
"clientId": "sts.amazonaws.com",
|
||||
"thumbprints": []interface{}{"6938fd4d98bab03faadb97b34396831e3780aea1"},
|
||||
"allowedPrincipalTagKeys": []interface{}{"team", "env"},
|
||||
"policyClaim": "policies",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
||||
Roles: &RoleStoreConfig{StoreType: "memory"},
|
||||
}
|
||||
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
rec, err := mgr.GetOIDCProvider(context.Background(), "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOIDCProvider: %v", err)
|
||||
}
|
||||
if got, want := rec.Thumbprints, []string{"6938fd4d98bab03faadb97b34396831e3780aea1"}; !equalStrings(got, want) {
|
||||
t.Fatalf("thumbprints mismatch: got %v want %v", got, want)
|
||||
}
|
||||
if got, want := rec.AllowedPrincipalTagKeys, []string{"team", "env"}; !equalStrings(got, want) {
|
||||
t.Fatalf("allowedPrincipalTagKeys mismatch: got %v want %v", got, want)
|
||||
}
|
||||
if rec.PolicyClaim != "policies" {
|
||||
t.Fatalf("policyClaim mismatch: got %q want %q", rec.PolicyClaim, "policies")
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestStoreNotConfiguredReturnsClearError(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
if _, err := mgr.GetOIDCProvider(context.Background(), "arn:..."); err == nil {
|
||||
t.Fatal("expected error when store not configured")
|
||||
}
|
||||
if _, err := mgr.ListOIDCProviders(context.Background()); err == nil {
|
||||
t.Fatal("expected error when store not configured")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the IAM manager and OIDCProviderStore. Callers
|
||||
// in the s3api layer use errors.Is rather than substring-matching the
|
||||
// formatted message, so the message can be edited freely without changing
|
||||
// the IAM error code surfaced to the API caller.
|
||||
var (
|
||||
ErrOIDCProviderNotFound = errors.New("OIDC provider not found")
|
||||
ErrOIDCProviderAlreadyExists = errors.New("OIDC provider already exists")
|
||||
)
|
||||
|
||||
// OIDCProviderRecord is the persisted, IAM-managed view of an OIDC identity
|
||||
// provider. It is the source of truth consulted at AssumeRoleWithWebIdentity
|
||||
// time. Static configuration entries are loaded into this same store at
|
||||
// startup so the resolution path is uniform.
|
||||
type OIDCProviderRecord struct {
|
||||
// AccountID scopes the record. Empty means "global" — usable from any
|
||||
// account. Non-empty records are only resolvable when the assuming
|
||||
// caller's RoleArn lives in the same account. Phase 2a leaves this
|
||||
// field unenforced; Phase 3c lights up the cross-account check.
|
||||
AccountID string `json:"accountId,omitempty"`
|
||||
|
||||
// ARN is the canonical IAM identifier for the provider:
|
||||
// arn:aws:iam::<account>:oidc-provider/<host>/<path>.
|
||||
ARN string `json:"arn"`
|
||||
|
||||
// URL is the issuer URL (no trailing slash), e.g.
|
||||
// https://token.actions.githubusercontent.com.
|
||||
URL string `json:"url"`
|
||||
|
||||
// ClientIDs are the audiences AWS calls "client IDs". A token's `aud` or
|
||||
// `azp` must match one of these for the token to be accepted.
|
||||
ClientIDs []string `json:"clientIds,omitempty"`
|
||||
|
||||
// Thumbprints are SHA-1 hex digests of the IDP's TLS certificate, matching
|
||||
// the AWS-compatible thumbprint algorithm. Used at JWKS-fetch time when
|
||||
// non-empty; an empty list means "trust the system root store".
|
||||
Thumbprints []string `json:"thumbprints,omitempty"`
|
||||
|
||||
// AllowedPrincipalTagKeys is the per-provider ABAC allowlist for the
|
||||
// `https://aws.amazon.com/tags/principal_tags` claim namespace. Empty
|
||||
// means no tags are surfaced from this provider.
|
||||
AllowedPrincipalTagKeys []string `json:"allowedPrincipalTagKeys,omitempty"`
|
||||
|
||||
// PolicyClaim, when non-empty, enables claim-based policy mode for this
|
||||
// provider: tokens whose RoleArn matches the configured sentinel will
|
||||
// derive their effective policies from this JWT claim.
|
||||
PolicyClaim string `json:"policyClaim,omitempty"`
|
||||
|
||||
// Tags is the AWS-style tag set attached to the IAM resource itself
|
||||
// (audit/inventory metadata, not propagated into sessions).
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// OIDCProviderStore stores OIDCProviderRecord entries. Implementations are
|
||||
// expected to be safe for concurrent use.
|
||||
type OIDCProviderStore interface {
|
||||
StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error
|
||||
GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error)
|
||||
GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error)
|
||||
GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error)
|
||||
ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error)
|
||||
DeleteProvider(ctx context.Context, filerAddress string, arn string) error
|
||||
}
|
||||
|
||||
// MemoryOIDCProviderStore is a process-local store, suitable for tests and
|
||||
// single-node deployments. It also acts as the in-memory cache hydrated from
|
||||
// static config at boot.
|
||||
type MemoryOIDCProviderStore struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]*OIDCProviderRecord // keyed by ARN
|
||||
}
|
||||
|
||||
// NewMemoryOIDCProviderStore creates an empty in-memory store.
|
||||
func NewMemoryOIDCProviderStore() *MemoryOIDCProviderStore {
|
||||
return &MemoryOIDCProviderStore{providers: make(map[string]*OIDCProviderRecord)}
|
||||
}
|
||||
|
||||
// StoreProvider replaces any existing record with the same ARN.
|
||||
func (m *MemoryOIDCProviderStore) StoreProvider(ctx context.Context, _ string, record *OIDCProviderRecord) error {
|
||||
if record == nil {
|
||||
return fmt.Errorf("record cannot be nil")
|
||||
}
|
||||
if record.ARN == "" {
|
||||
return fmt.Errorf("record.ARN is required")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.providers[record.ARN] = copyOIDCProviderRecord(record)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProviderByARN returns the record with the given ARN, or an error if absent.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByARN(ctx context.Context, _ string, arn string) (*OIDCProviderRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
rec, ok := m.providers[arn]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
|
||||
}
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
|
||||
// GetProviderByIssuer scans for the first record whose URL matches `issuer`.
|
||||
// Comparison strips trailing slashes and is case-insensitive on host.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByIssuer(ctx context.Context, _ string, issuer string) (*OIDCProviderRecord, error) {
|
||||
want := normalizeIssuer(issuer)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, rec := range m.providers {
|
||||
if normalizeIssuer(rec.URL) == want {
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
|
||||
}
|
||||
|
||||
// GetProviderByIssuerAndAccount returns the record matching `issuer` whose
|
||||
// AccountID is empty (global) or equals `accountID`. AWS-style account
|
||||
// scoping: cross-account references must go through a shared/global
|
||||
// provider, which is why an empty AccountID is treated as a wildcard.
|
||||
//
|
||||
// `accountID` may itself be empty when the caller has no specific account
|
||||
// context (e.g. claim-based mode); in that case any registered provider
|
||||
// matching the issuer is acceptable.
|
||||
func (m *MemoryOIDCProviderStore) GetProviderByIssuerAndAccount(ctx context.Context, _ string, issuer, accountID string) (*OIDCProviderRecord, error) {
|
||||
want := normalizeIssuer(issuer)
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, rec := range m.providers {
|
||||
if normalizeIssuer(rec.URL) != want {
|
||||
continue
|
||||
}
|
||||
if rec.AccountID == "" || accountID == "" || rec.AccountID == accountID {
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer %s in account %s", issuer, accountID)
|
||||
}
|
||||
|
||||
// ListProviders returns every record sorted by ARN for stable output.
|
||||
func (m *MemoryOIDCProviderStore) ListProviders(ctx context.Context, _ string) ([]*OIDCProviderRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*OIDCProviderRecord, 0, len(m.providers))
|
||||
for _, rec := range m.providers {
|
||||
out = append(out, copyOIDCProviderRecord(rec))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProvider removes the record. Idempotent — deleting a missing ARN is success.
|
||||
func (m *MemoryOIDCProviderStore) DeleteProvider(ctx context.Context, _ string, arn string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.providers, arn)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilerOIDCProviderStore persists records as JSON files in a filer directory,
|
||||
// mirroring FilerRoleStore.
|
||||
type FilerOIDCProviderStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerOIDCProviderStore returns a filer-backed store. Default path
|
||||
// `/etc/iam/oidc-providers` aligns with the existing roles directory.
|
||||
func NewFilerOIDCProviderStore(config map[string]interface{}, filerAddressProvider func() string) *FilerOIDCProviderStore {
|
||||
store := &FilerOIDCProviderStore{
|
||||
basePath: "/etc/iam/oidc-providers",
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
if config != nil {
|
||||
if bp, ok := config["basePath"].(string); ok && bp != "" {
|
||||
store.basePath = strings.TrimSuffix(bp, "/")
|
||||
}
|
||||
}
|
||||
glog.V(2).Infof("Initialized FilerOIDCProviderStore with basePath %s", store.basePath)
|
||||
return store
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) resolveFilerAddress(filerAddress string) string {
|
||||
if filerAddress != "" {
|
||||
return filerAddress
|
||||
}
|
||||
if f.filerAddressProvider != nil {
|
||||
return f.filerAddressProvider()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) fileName(arn string) string {
|
||||
// Hash the ARN to a fixed-width filename so we can store any character set
|
||||
// (including the URL fragments AWS lets through) without filer-path issues.
|
||||
h := sha1.Sum([]byte(arn))
|
||||
return hex.EncodeToString(h[:]) + ".json"
|
||||
}
|
||||
|
||||
// StoreProvider persists `record` as JSON.
|
||||
func (f *FilerOIDCProviderStore) StoreProvider(ctx context.Context, filerAddress string, record *OIDCProviderRecord) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
if record == nil || record.ARN == "" {
|
||||
return fmt.Errorf("record with ARN is required")
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal OIDC provider: %v", err)
|
||||
}
|
||||
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: f.fileName(record.ARN),
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0o600),
|
||||
},
|
||||
Content: data,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("store OIDC provider %s: %v", record.ARN, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetProviderByARN looks up the record file by its ARN-derived filename.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddress string, arn string) (*OIDCProviderRecord, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required")
|
||||
}
|
||||
|
||||
var data []byte
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(arn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrOIDCProviderNotFound, err)
|
||||
}
|
||||
if resp.Entry == nil {
|
||||
return fmt.Errorf("OIDC provider not found: %s", arn)
|
||||
}
|
||||
data = resp.Entry.Content
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rec OIDCProviderRecord
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal OIDC provider: %v", err)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// GetProviderByIssuer linearly scans the directory. Phase 2b adds an issuer
|
||||
// index when write throughput grows; for read-only this is good enough.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByIssuer(ctx context.Context, filerAddress string, issuer string) (*OIDCProviderRecord, error) {
|
||||
records, err := f.ListProviders(ctx, filerAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := normalizeIssuer(issuer)
|
||||
for _, rec := range records {
|
||||
if normalizeIssuer(rec.URL) == want {
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer: %s", issuer)
|
||||
}
|
||||
|
||||
// GetProviderByIssuerAndAccount filters by AccountID on top of the issuer scan.
|
||||
// See the MemoryOIDCProviderStore equivalent for semantics.
|
||||
func (f *FilerOIDCProviderStore) GetProviderByIssuerAndAccount(ctx context.Context, filerAddress string, issuer, accountID string) (*OIDCProviderRecord, error) {
|
||||
records, err := f.ListProviders(ctx, filerAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := normalizeIssuer(issuer)
|
||||
for _, rec := range records {
|
||||
if normalizeIssuer(rec.URL) != want {
|
||||
continue
|
||||
}
|
||||
if rec.AccountID == "" || accountID == "" || rec.AccountID == accountID {
|
||||
return rec, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no OIDC provider registered for issuer %s in account %s", issuer, accountID)
|
||||
}
|
||||
|
||||
// ListProviders enumerates every record in the directory.
|
||||
func (f *FilerOIDCProviderStore) ListProviders(ctx context.Context, filerAddress string) ([]*OIDCProviderRecord, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return nil, fmt.Errorf("filer address is required")
|
||||
}
|
||||
|
||||
var out []*OIDCProviderRecord
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Stream-paginate via StartFromFileName so deployments with more
|
||||
// than 1000 providers don't get a silently truncated list.
|
||||
const pageSize = 1000
|
||||
startFrom := ""
|
||||
for {
|
||||
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
||||
Directory: f.basePath,
|
||||
Limit: pageSize,
|
||||
StartFromFileName: startFrom,
|
||||
InclusiveStartFrom: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list OIDC providers: %v", err)
|
||||
}
|
||||
lastName := ""
|
||||
pageCount := 0
|
||||
for {
|
||||
resp, recvErr := stream.Recv()
|
||||
if recvErr != nil {
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("recv OIDC provider entry: %w", recvErr)
|
||||
}
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
lastName = resp.Entry.Name
|
||||
pageCount++
|
||||
if !strings.HasSuffix(resp.Entry.Name, ".json") {
|
||||
continue
|
||||
}
|
||||
var rec OIDCProviderRecord
|
||||
if err := json.Unmarshal(resp.Entry.Content, &rec); err != nil {
|
||||
glog.Warningf("skipping malformed OIDC provider record %s: %v", resp.Entry.Name, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, &rec)
|
||||
}
|
||||
if pageCount < pageSize {
|
||||
break
|
||||
}
|
||||
startFrom = lastName
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProvider removes the record. Missing ARN is treated as success.
|
||||
func (f *FilerOIDCProviderStore) DeleteProvider(ctx context.Context, filerAddress string, arn string) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(arn),
|
||||
IsDeleteData: true,
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delete OIDC provider %s: %v", arn, err)
|
||||
}
|
||||
if resp.Error != "" && !strings.Contains(resp.Error, "not found") {
|
||||
return fmt.Errorf("delete OIDC provider %s: %s", arn, resp.Error)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (f *FilerOIDCProviderStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
|
||||
}
|
||||
|
||||
// DeriveOIDCProviderARN turns an issuer URL into the canonical IAM ARN AWS uses.
|
||||
// Empty `accountID` produces a global-style ARN.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// https://accounts.google.com -> arn:aws:iam::<acct>:oidc-provider/accounts.google.com
|
||||
// https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED ->
|
||||
// arn:aws:iam::<acct>:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED
|
||||
func DeriveOIDCProviderARN(accountID, issuerURL string) (string, error) {
|
||||
if issuerURL == "" {
|
||||
return "", fmt.Errorf("issuer URL is required")
|
||||
}
|
||||
u, err := url.Parse(issuerURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", fmt.Errorf("invalid issuer URL: %s", issuerURL)
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
resource := host + strings.TrimSuffix(u.Path, "/")
|
||||
return fmt.Sprintf("arn:aws:iam::%s:oidc-provider/%s", accountID, resource), nil
|
||||
}
|
||||
|
||||
// normalizeIssuer compares-friendly form: lowercased host, no trailing slash.
|
||||
func normalizeIssuer(issuer string) string {
|
||||
u, err := url.Parse(issuer)
|
||||
if err != nil || u.Host == "" {
|
||||
return strings.TrimSuffix(strings.ToLower(issuer), "/")
|
||||
}
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
u.Path = strings.TrimSuffix(u.Path, "/")
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func copyOIDCProviderRecord(rec *OIDCProviderRecord) *OIDCProviderRecord {
|
||||
if rec == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *rec
|
||||
if rec.ClientIDs != nil {
|
||||
cp.ClientIDs = append([]string(nil), rec.ClientIDs...)
|
||||
}
|
||||
if rec.Thumbprints != nil {
|
||||
cp.Thumbprints = append([]string(nil), rec.Thumbprints...)
|
||||
}
|
||||
if rec.AllowedPrincipalTagKeys != nil {
|
||||
cp.AllowedPrincipalTagKeys = append([]string(nil), rec.AllowedPrincipalTagKeys...)
|
||||
}
|
||||
if rec.Tags != nil {
|
||||
cp.Tags = make(map[string]string, len(rec.Tags))
|
||||
for k, v := range rec.Tags {
|
||||
cp.Tags[k] = v
|
||||
}
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newRecord(arn, url string) *OIDCProviderRecord {
|
||||
now := time.Now()
|
||||
return &OIDCProviderRecord{
|
||||
ARN: arn,
|
||||
URL: url,
|
||||
ClientIDs: []string{"sts.amazonaws.com"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
rec := newRecord(
|
||||
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com",
|
||||
"https://token.actions.githubusercontent.com",
|
||||
)
|
||||
|
||||
// Store + Get round-trip preserves the record.
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("StoreProvider: %v", err)
|
||||
}
|
||||
got, err := store.GetProviderByARN(ctx, "", rec.ARN)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderByARN: %v", err)
|
||||
}
|
||||
if got.URL != rec.URL {
|
||||
t.Fatalf("URL mismatch: got=%s want=%s", got.URL, rec.URL)
|
||||
}
|
||||
|
||||
// Mutate the returned copy and verify the store wasn't affected.
|
||||
got.ClientIDs[0] = "tampered"
|
||||
again, _ := store.GetProviderByARN(ctx, "", rec.ARN)
|
||||
if again.ClientIDs[0] == "tampered" {
|
||||
t.Fatal("store handed out a shared slice; mutations leaked back")
|
||||
}
|
||||
|
||||
// List returns the entry.
|
||||
all, err := store.ListProviders(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListProviders: %v", err)
|
||||
}
|
||||
if len(all) != 1 || all[0].ARN != rec.ARN {
|
||||
t.Fatalf("ListProviders unexpected result: %+v", all)
|
||||
}
|
||||
|
||||
// Delete -> Get returns not found.
|
||||
if err := store.DeleteProvider(ctx, "", rec.ARN); err != nil {
|
||||
t.Fatalf("DeleteProvider: %v", err)
|
||||
}
|
||||
if _, err := store.GetProviderByARN(ctx, "", rec.ARN); err == nil {
|
||||
t.Fatal("expected not-found after delete")
|
||||
}
|
||||
// Delete is idempotent.
|
||||
if err := store.DeleteProvider(ctx, "", rec.ARN); err != nil {
|
||||
t.Fatalf("idempotent delete should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreGetByIssuerNormalizesHost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
|
||||
rec := newRecord(
|
||||
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com",
|
||||
"https://Token.Actions.GithubUserContent.com/", // mixed case + trailing slash
|
||||
)
|
||||
if err := store.StoreProvider(ctx, "", rec); err != nil {
|
||||
t.Fatalf("StoreProvider: %v", err)
|
||||
}
|
||||
|
||||
cases := []string{
|
||||
"https://token.actions.githubusercontent.com",
|
||||
"https://token.actions.githubusercontent.com/",
|
||||
"https://TOKEN.actions.GITHUBUSERCONTENT.com",
|
||||
}
|
||||
for _, want := range cases {
|
||||
got, err := store.GetProviderByIssuer(ctx, "", want)
|
||||
if err != nil {
|
||||
t.Errorf("issuer %q: GetProviderByIssuer: %v", want, err)
|
||||
continue
|
||||
}
|
||||
if got.ARN != rec.ARN {
|
||||
t.Errorf("issuer %q: ARN mismatch: got=%s", want, got.ARN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreGetByIssuerMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
_, err := store.GetProviderByIssuer(ctx, "", "https://other.example/")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unregistered issuer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOIDCProviderARN(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
accountID string
|
||||
issuer string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "google",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://accounts.google.com",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "EKS with path",
|
||||
accountID: "999999999999",
|
||||
issuer: "https://oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED",
|
||||
want: "arn:aws:iam::999999999999:oidc-provider/oidc.eks.us-west-2.amazonaws.com/id/EXAMPLED",
|
||||
},
|
||||
{
|
||||
name: "uppercase host normalized",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://Accounts.Google.com",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "trailing slash trimmed",
|
||||
accountID: "111122223333",
|
||||
issuer: "https://accounts.google.com/",
|
||||
want: "arn:aws:iam::111122223333:oidc-provider/accounts.google.com",
|
||||
},
|
||||
{
|
||||
name: "empty account allowed",
|
||||
accountID: "",
|
||||
issuer: "https://accounts.google.com",
|
||||
want: "arn:aws:iam:::oidc-provider/accounts.google.com",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := DeriveOIDCProviderARN(tc.accountID, tc.issuer)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got=%s want=%s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOIDCProviderARNRejectsBadInput(t *testing.T) {
|
||||
cases := []string{"", "not a url", "http://"}
|
||||
for _, in := range cases {
|
||||
if _, err := DeriveOIDCProviderARN("123", in); err == nil {
|
||||
t.Errorf("expected error for input %q", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsEmptyARN(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
rec := newRecord("", "https://issuer/")
|
||||
if err := store.StoreProvider(ctx, "", rec); err == nil {
|
||||
t.Fatal("expected error storing record with empty ARN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRejectsNil(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemoryOIDCProviderStore()
|
||||
if err := store.StoreProvider(ctx, "", nil); err == nil {
|
||||
t.Fatal("expected error storing nil record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIssuerRejectsEmpty(t *testing.T) {
|
||||
if got := normalizeIssuer(""); got != "" {
|
||||
t.Fatalf("empty issuer should normalize to empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeIssuerHandlesNonURL(t *testing.T) {
|
||||
// Defense in depth: even when issuer is junk, normalize doesn't panic and
|
||||
// at least lowercases the input.
|
||||
got := normalizeIssuer("Some Random String/")
|
||||
if !strings.Contains(got, "some random") {
|
||||
t.Fatalf("normalize should lowercase non-URL input, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package integration
|
||||
|
||||
import "testing"
|
||||
|
||||
func intPtr(v int64) *int64 { return &v }
|
||||
|
||||
func TestCapDurationByRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requested *int64
|
||||
roleMax int64
|
||||
want *int64
|
||||
}{
|
||||
{"no cap, no request", nil, 0, nil},
|
||||
{"no cap, with request", intPtr(7200), 0, intPtr(7200)},
|
||||
{"cap only, no request -> nil so STS default applies", nil, 3600, nil},
|
||||
{"request below cap -> request", intPtr(1800), 3600, intPtr(1800)},
|
||||
{"request equal cap -> request", intPtr(3600), 3600, intPtr(3600)},
|
||||
{"request above cap -> cap", intPtr(43200), 3600, intPtr(3600)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := capDurationByRole(tc.requested, tc.roleMax)
|
||||
switch {
|
||||
case got == nil && tc.want == nil:
|
||||
return
|
||||
case got == nil || tc.want == nil:
|
||||
t.Fatalf("nilness mismatch: got=%v want=%v", got, tc.want)
|
||||
case *got != *tc.want:
|
||||
t.Fatalf("got=%d want=%d", *got, *tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// RevocationEntry is one row in the revocation list. ExpiresAt is set to the
|
||||
// session's natural expiration so the store can garbage-collect entries that
|
||||
// can no longer be reused.
|
||||
type RevocationEntry struct {
|
||||
JTI string `json:"jti"`
|
||||
RevokedAt time.Time `json:"revokedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// SessionRevocationStore is the per-deployment blocklist of revoked sessions.
|
||||
// Implementations must be safe for concurrent use; the IsRevoked path is hot
|
||||
// (checked on every signed request) and must not block on slow IO.
|
||||
type SessionRevocationStore interface {
|
||||
Revoke(ctx context.Context, filerAddress string, entry *RevocationEntry) error
|
||||
IsRevoked(ctx context.Context, filerAddress string, jti string) (bool, error)
|
||||
Purge(ctx context.Context, filerAddress string, before time.Time) (int, error)
|
||||
}
|
||||
|
||||
// MemorySessionRevocationStore keeps the blocklist in process memory. Suitable
|
||||
// for single-node deployments and tests; for HA, swap in the filer-backed
|
||||
// implementation so revocations propagate across `weed` instances.
|
||||
type MemorySessionRevocationStore struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]*RevocationEntry
|
||||
}
|
||||
|
||||
// NewMemorySessionRevocationStore returns an empty in-memory blocklist.
|
||||
func NewMemorySessionRevocationStore() *MemorySessionRevocationStore {
|
||||
return &MemorySessionRevocationStore{entries: make(map[string]*RevocationEntry)}
|
||||
}
|
||||
|
||||
func (m *MemorySessionRevocationStore) Revoke(ctx context.Context, _ string, entry *RevocationEntry) error {
|
||||
if entry == nil || entry.JTI == "" {
|
||||
return fmt.Errorf("entry with JTI is required")
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := *entry
|
||||
if cp.RevokedAt.IsZero() {
|
||||
cp.RevokedAt = time.Now().UTC()
|
||||
}
|
||||
m.entries[entry.JTI] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemorySessionRevocationStore) IsRevoked(ctx context.Context, _ string, jti string) (bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
_, ok := m.entries[jti]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (m *MemorySessionRevocationStore) Purge(ctx context.Context, _ string, before time.Time) (int, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
count := 0
|
||||
for k, v := range m.entries {
|
||||
if !v.ExpiresAt.IsZero() && v.ExpiresAt.Before(before) {
|
||||
delete(m.entries, k)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// FilerSessionRevocationStore persists entries under a filer directory.
|
||||
// Files are named after the JTI so IsRevoked is a single LookupDirectoryEntry
|
||||
// call — O(1) on a name-indexed filer. Purge enumerates the directory; that
|
||||
// cost is operator-controlled (cron-driven) so the hot path stays cheap.
|
||||
type FilerSessionRevocationStore struct {
|
||||
grpcDialOption grpc.DialOption
|
||||
basePath string
|
||||
filerAddressProvider func() string
|
||||
}
|
||||
|
||||
// NewFilerSessionRevocationStore returns a filer-backed blocklist store.
|
||||
// Default basePath `/etc/iam/revoked-sessions` aligns with the other IAM
|
||||
// directories and is safe to back up alongside roles + providers.
|
||||
func NewFilerSessionRevocationStore(config map[string]interface{}, filerAddressProvider func() string) *FilerSessionRevocationStore {
|
||||
store := &FilerSessionRevocationStore{
|
||||
basePath: "/etc/iam/revoked-sessions",
|
||||
filerAddressProvider: filerAddressProvider,
|
||||
}
|
||||
if config != nil {
|
||||
if bp, ok := config["basePath"].(string); ok && bp != "" {
|
||||
store.basePath = strings.TrimSuffix(bp, "/")
|
||||
}
|
||||
}
|
||||
glog.V(2).Infof("Initialized FilerSessionRevocationStore with basePath %s", store.basePath)
|
||||
return store
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) resolveFilerAddress(filerAddress string) string {
|
||||
if filerAddress != "" {
|
||||
return filerAddress
|
||||
}
|
||||
if f.filerAddressProvider != nil {
|
||||
return f.filerAddressProvider()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fileName hashes the JTI before using it as a filename. RevokeSession is
|
||||
// an exported API that accepts an arbitrary string; even though every
|
||||
// SeaweedFS-issued session id is a safe random token, an external caller
|
||||
// could pass "../../etc/passwd" or similar. Hashing produces a fixed-width
|
||||
// hex name that's both filesystem-safe and stable, so the lookup-on-revoke
|
||||
// path still finds the right entry.
|
||||
func (f *FilerSessionRevocationStore) fileName(jti string) string {
|
||||
sum := sha1.Sum([]byte(jti))
|
||||
return hex.EncodeToString(sum[:]) + ".json"
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) Revoke(ctx context.Context, filerAddress string, entry *RevocationEntry) error {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return fmt.Errorf("filer address is required")
|
||||
}
|
||||
if entry == nil || entry.JTI == "" {
|
||||
return fmt.Errorf("entry with JTI is required")
|
||||
}
|
||||
if entry.RevokedAt.IsZero() {
|
||||
entry.RevokedAt = time.Now().UTC()
|
||||
}
|
||||
data, err := json.MarshalIndent(entry, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal revocation entry: %v", err)
|
||||
}
|
||||
return f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
_, err := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Entry: &filer_pb.Entry{
|
||||
Name: f.fileName(entry.JTI),
|
||||
IsDirectory: false,
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: time.Now().Unix(),
|
||||
Crtime: time.Now().Unix(),
|
||||
FileMode: uint32(0o600),
|
||||
},
|
||||
Content: data,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke %s: %v", entry.JTI, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) IsRevoked(ctx context.Context, filerAddress string, jti string) (bool, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return false, fmt.Errorf("filer address is required")
|
||||
}
|
||||
revoked := false
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
resp, err := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: f.fileName(jti),
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "no such") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
revoked = resp.Entry != nil
|
||||
return nil
|
||||
})
|
||||
return revoked, err
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) Purge(ctx context.Context, filerAddress string, before time.Time) (int, error) {
|
||||
filerAddress = f.resolveFilerAddress(filerAddress)
|
||||
if filerAddress == "" {
|
||||
return 0, fmt.Errorf("filer address is required")
|
||||
}
|
||||
count := 0
|
||||
err := f.withFilerClient(filerAddress, func(client filer_pb.SeaweedFilerClient) error {
|
||||
// Stream-paginate the directory: ListEntriesRequest has no built-in
|
||||
// "page until done" semantics, so we use StartFromFileName to walk
|
||||
// the directory in chunks and avoid the previous hardcoded 10k cap.
|
||||
const pageSize = 1000
|
||||
startFrom := ""
|
||||
for {
|
||||
stream, err := client.ListEntries(ctx, &filer_pb.ListEntriesRequest{
|
||||
Directory: f.basePath,
|
||||
Limit: pageSize,
|
||||
StartFromFileName: startFrom,
|
||||
InclusiveStartFrom: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list revocation entries: %w", err)
|
||||
}
|
||||
lastName := ""
|
||||
pageCount := 0
|
||||
for {
|
||||
resp, recvErr := stream.Recv()
|
||||
if recvErr != nil {
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("recv revocation entry: %w", recvErr)
|
||||
}
|
||||
if resp.Entry == nil || resp.Entry.IsDirectory {
|
||||
continue
|
||||
}
|
||||
lastName = resp.Entry.Name
|
||||
pageCount++
|
||||
var entry RevocationEntry
|
||||
if err := json.Unmarshal(resp.Entry.Content, &entry); err != nil {
|
||||
continue
|
||||
}
|
||||
if entry.ExpiresAt.IsZero() || entry.ExpiresAt.After(before) {
|
||||
continue
|
||||
}
|
||||
if _, err := client.DeleteEntry(ctx, &filer_pb.DeleteEntryRequest{
|
||||
Directory: f.basePath,
|
||||
Name: resp.Entry.Name,
|
||||
IsDeleteData: true,
|
||||
}); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if pageCount < pageSize {
|
||||
return nil
|
||||
}
|
||||
startFrom = lastName
|
||||
}
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (f *FilerSessionRevocationStore) withFilerClient(filerAddress string, fn func(filer_pb.SeaweedFilerClient) error) error {
|
||||
return pb.WithGrpcFilerClient(false, 0, pb.ServerAddress(filerAddress), f.grpcDialOption, fn)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryRevocationStoreCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemorySessionRevocationStore()
|
||||
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "abc"); revoked {
|
||||
t.Fatal("empty store should report not revoked")
|
||||
}
|
||||
|
||||
if err := store.Revoke(ctx, "", &RevocationEntry{JTI: "abc", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "abc"); !revoked {
|
||||
t.Fatal("expected revoked")
|
||||
}
|
||||
// Different JTIs are independent.
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "xyz"); revoked {
|
||||
t.Fatal("xyz should not be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRevocationStoreRejectsBadInput(t *testing.T) {
|
||||
store := NewMemorySessionRevocationStore()
|
||||
if err := store.Revoke(context.Background(), "", nil); err == nil {
|
||||
t.Fatal("expected error for nil entry")
|
||||
}
|
||||
if err := store.Revoke(context.Background(), "", &RevocationEntry{}); err == nil {
|
||||
t.Fatal("expected error for empty JTI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRevocationStorePurge(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := NewMemorySessionRevocationStore()
|
||||
now := time.Now()
|
||||
|
||||
must := func(jti string, expiresAt time.Time) {
|
||||
if err := store.Revoke(ctx, "", &RevocationEntry{JTI: jti, ExpiresAt: expiresAt}); err != nil {
|
||||
t.Fatalf("revoke %s: %v", jti, err)
|
||||
}
|
||||
}
|
||||
must("expired-1", now.Add(-2*time.Hour))
|
||||
must("expired-2", now.Add(-time.Minute))
|
||||
must("future-1", now.Add(time.Hour))
|
||||
|
||||
count, err := store.Purge(ctx, "", now)
|
||||
if err != nil {
|
||||
t.Fatalf("purge: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 purged, got %d", count)
|
||||
}
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "expired-1"); revoked {
|
||||
t.Fatal("expired-1 should be gone")
|
||||
}
|
||||
if revoked, _ := store.IsRevoked(ctx, "", "future-1"); !revoked {
|
||||
t.Fatal("future-1 should still be revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMManagerRevocationDefaultIsNoop(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
// Without SetSessionRevocationStore, revocation is a no-op.
|
||||
if got, err := mgr.IsSessionRevoked(context.Background(), "anything"); err != nil || got {
|
||||
t.Fatalf("expected (false, nil); got (%v, %v)", got, err)
|
||||
}
|
||||
if err := mgr.RevokeSession(context.Background(), "abc", time.Now().Add(time.Hour), "test"); err == nil {
|
||||
t.Fatal("expected error when no store configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIAMManagerRevocationFlow(t *testing.T) {
|
||||
mgr := NewIAMManager()
|
||||
mgr.SetSessionRevocationStore(NewMemorySessionRevocationStore())
|
||||
|
||||
jti := "session-1"
|
||||
if err := mgr.RevokeSession(context.Background(), jti, time.Now().Add(time.Hour), "logout"); err != nil {
|
||||
t.Fatalf("RevokeSession: %v", err)
|
||||
}
|
||||
revoked, err := mgr.IsSessionRevoked(context.Background(), jti)
|
||||
if err != nil {
|
||||
t.Fatalf("IsSessionRevoked: %v", err)
|
||||
}
|
||||
if !revoked {
|
||||
t.Fatal("expected revoked")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeIDP wraps an httptest.Server and counts how many times each well-known
|
||||
// endpoint is hit. Tests use it to assert discovery vs. fallback behaviour.
|
||||
type fakeIDP struct {
|
||||
server *httptest.Server
|
||||
discoveryHits atomic.Int32
|
||||
jwksHits atomic.Int32
|
||||
customJWKSHits atomic.Int32
|
||||
disableDiscovery bool
|
||||
discoveryStatusCode int
|
||||
discoveryIssuer string
|
||||
omitDiscoveryIssuer bool // when true, the discovery doc omits the "issuer" field entirely
|
||||
customJWKSPathSuffix string // optional suffix that fakeIDP serves at /custom/<suffix>
|
||||
jwks JWKS
|
||||
}
|
||||
|
||||
func newFakeIDP(t *testing.T) *fakeIDP {
|
||||
t.Helper()
|
||||
idp := &fakeIDP{
|
||||
discoveryStatusCode: http.StatusOK,
|
||||
jwks: JWKS{Keys: []JWK{{Kty: "RSA", Kid: "k1", Use: "sig", Alg: "RS256", N: "AQAB", E: "AQAB"}}},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.discoveryHits.Add(1)
|
||||
if idp.disableDiscovery {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(idp.discoveryStatusCode)
|
||||
issuer := idp.discoveryIssuer
|
||||
if issuer == "" {
|
||||
issuer = idp.server.URL
|
||||
}
|
||||
jwksURI := idp.server.URL + "/discovered/jwks"
|
||||
if idp.customJWKSPathSuffix != "" {
|
||||
jwksURI = idp.server.URL + "/custom/" + idp.customJWKSPathSuffix
|
||||
}
|
||||
body := map[string]string{"jwks_uri": jwksURI}
|
||||
if !idp.omitDiscoveryIssuer {
|
||||
body["issuer"] = issuer
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
})
|
||||
mux.HandleFunc("/discovered/jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.jwksHits.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(idp.jwks)
|
||||
})
|
||||
mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.jwksHits.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(idp.jwks)
|
||||
})
|
||||
mux.HandleFunc("/custom/", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.customJWKSHits.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(idp.jwks)
|
||||
})
|
||||
idp.server = httptest.NewServer(mux)
|
||||
t.Cleanup(idp.server.Close)
|
||||
return idp
|
||||
}
|
||||
|
||||
func newProviderForIDP(t *testing.T, idp *fakeIDP, jwksURIOverride string) *OIDCProvider {
|
||||
t.Helper()
|
||||
p := NewOIDCProvider("test")
|
||||
cfg := &OIDCConfig{
|
||||
Issuer: idp.server.URL,
|
||||
ClientID: "test-client",
|
||||
JWKSUri: jwksURIOverride,
|
||||
}
|
||||
if err := p.Initialize(cfg); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestDiscoveryHappyPath(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
p := newProviderForIDP(t, idp, "")
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 discovery hit, got %d", got)
|
||||
}
|
||||
if got := idp.jwksHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 JWKS hit at discovered uri, got %d", got)
|
||||
}
|
||||
|
||||
// A second fetch reuses the cached jwks_uri without re-discovering.
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS second: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 1 {
|
||||
t.Fatalf("discovery should be cached, got %d hits", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryFallback404(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
idp.disableDiscovery = true
|
||||
p := newProviderForIDP(t, idp, "")
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 discovery probe, got %d", got)
|
||||
}
|
||||
if got := idp.jwksHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 JWKS hit at fallback uri, got %d", got)
|
||||
}
|
||||
|
||||
// Subsequent fetches retry discovery — discoveryFailed resets at the top
|
||||
// of fetchJWKSLocked when no URI was cached, so a transient 5xx at
|
||||
// startup doesn't lock the provider into the fallback path forever.
|
||||
// Retry rate is bounded by the JWKS TTL (one retry per refresh cycle).
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS second: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 2 {
|
||||
t.Fatalf("discovery probe should retry while no URI is cached, got %d hits", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryDisabledByExplicitJWKSUri(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
override := idp.server.URL + "/custom/explicit"
|
||||
idp.customJWKSPathSuffix = "explicit"
|
||||
p := newProviderForIDP(t, idp, override)
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 0 {
|
||||
t.Fatalf("explicit JWKSUri should bypass discovery, got %d hits", got)
|
||||
}
|
||||
if got := idp.customJWKSHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 custom JWKS hit, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryRejectsIssuerMismatch(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
idp.discoveryIssuer = "https://attacker.example/"
|
||||
p := newProviderForIDP(t, idp, "")
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS should fall back to /.well-known/jwks.json, got error: %v", err)
|
||||
}
|
||||
// Discovery probe was tried once, rejected, then fell through to fallback path.
|
||||
if got := idp.discoveryHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 discovery probe, got %d", got)
|
||||
}
|
||||
if got := idp.jwksHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 fallback JWKS hit, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoveryRejectsMissingIssuer: a discovery document that omits the
|
||||
// issuer field entirely must be treated the same as one that supplies a
|
||||
// mismatched issuer. Otherwise an attacker who can intercept the discovery
|
||||
// response can strip the issuer field and the comparison silently passes,
|
||||
// letting the document point fetchJWKS at any URL it pleases.
|
||||
func TestDiscoveryRejectsMissingIssuer(t *testing.T) {
|
||||
idp := newFakeIDP(t)
|
||||
idp.omitDiscoveryIssuer = true
|
||||
p := newProviderForIDP(t, idp, "")
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS should fall back to /.well-known/jwks.json on issuer-missing discovery: %v", err)
|
||||
}
|
||||
if got := idp.discoveryHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 discovery probe, got %d", got)
|
||||
}
|
||||
// The discovery document was rejected; the JWKS that ultimately served
|
||||
// us must be the fallback one, not the discovered URI. The fakeIDP
|
||||
// counts both hits under jwksHits since they share a counter; what
|
||||
// matters is that customJWKSHits stayed zero.
|
||||
if got := idp.customJWKSHits.Load(); got != 0 {
|
||||
t.Fatalf("custom JWKS endpoint must not have been used, got %d hits", got)
|
||||
}
|
||||
if got := idp.jwksHits.Load(); got != 1 {
|
||||
t.Fatalf("expected 1 fallback JWKS hit, got %d", got)
|
||||
}
|
||||
}
|
||||
+448
-39
@@ -5,9 +5,11 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -25,13 +28,21 @@ import (
|
||||
|
||||
// OIDCProvider implements OpenID Connect authentication
|
||||
type OIDCProvider struct {
|
||||
name string
|
||||
config *OIDCConfig
|
||||
initialized bool
|
||||
jwksCache *JWKS
|
||||
httpClient *http.Client
|
||||
jwksFetchedAt time.Time
|
||||
jwksTTL time.Duration
|
||||
name string
|
||||
config *OIDCConfig
|
||||
initialized bool
|
||||
httpClient *http.Client
|
||||
jwksTTL time.Duration
|
||||
|
||||
// mu guards the lazily-mutated cache fields below: jwksCache, jwksFetchedAt,
|
||||
// resolvedJWKSUri, and discoveryFailed are all populated on the first
|
||||
// validate-token call and refreshed when the cache expires. Multiple S3
|
||||
// requests can land here in parallel, so they need synchronization.
|
||||
mu sync.RWMutex
|
||||
jwksCache *JWKS
|
||||
jwksFetchedAt time.Time
|
||||
resolvedJWKSUri string
|
||||
discoveryFailed bool
|
||||
}
|
||||
|
||||
// OIDCConfig holds OIDC provider configuration
|
||||
@@ -39,8 +50,15 @@ type OIDCConfig struct {
|
||||
// Issuer is the OIDC issuer URL
|
||||
Issuer string `json:"issuer"`
|
||||
|
||||
// ClientID is the OAuth2 client ID
|
||||
ClientID string `json:"clientId"`
|
||||
// ClientID is the OAuth2 client ID. Either ClientID or ClientIDs is
|
||||
// required; when both are present, ClientID is appended to the audience
|
||||
// allowlist.
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
|
||||
// ClientIDs is the AWS-compatible audience allowlist. Tokens are accepted
|
||||
// when any of `aud` or `azp` matches any entry. Mutually compatible with
|
||||
// ClientID for backward compatibility.
|
||||
ClientIDs []string `json:"clientIds,omitempty"`
|
||||
|
||||
// ClientSecret is the OAuth2 client secret (optional for public clients)
|
||||
ClientSecret string `json:"clientSecret,omitempty"`
|
||||
@@ -63,6 +81,25 @@ type OIDCConfig struct {
|
||||
// JWKSCacheTTLSeconds sets how long to cache JWKS before refresh (default 3600 seconds)
|
||||
JWKSCacheTTLSeconds int `json:"jwksCacheTTLSeconds,omitempty"`
|
||||
|
||||
// Thumbprints, when non-empty, pins the issuer's TLS certificate against
|
||||
// this allowlist of SHA-1 hex digests. Matches the AWS IAM
|
||||
// CreateOpenIDConnectProvider semantics. Empty means "trust the system
|
||||
// root store" (or whatever TLSCACert configures).
|
||||
Thumbprints []string `json:"thumbprints,omitempty"`
|
||||
|
||||
// AllowedPrincipalTagKeys filters the keys read from the AWS principal
|
||||
// session tags claim. Empty means "no tags surfaced". Provide an explicit
|
||||
// allowlist (e.g. ["team", "env"]) to opt specific keys in.
|
||||
AllowedPrincipalTagKeys []string `json:"allowedPrincipalTagKeys,omitempty"`
|
||||
|
||||
// PolicyClaim names a JWT claim whose value carries the effective policy
|
||||
// list for the session. When non-empty and the assume request opts into
|
||||
// claim-based policy mode via the ClaimBasedPolicyRoleArn sentinel, the
|
||||
// policies are pulled from this claim rather than from a server-side
|
||||
// role mapping. Accepted shapes: string (single policy), comma-separated
|
||||
// string, or string array.
|
||||
PolicyClaim string `json:"policyClaim,omitempty"`
|
||||
|
||||
// TLSCACert is the path to the CA certificate file for custom/self-signed certificates
|
||||
TLSCACert string `json:"tlsCaCert,omitempty"`
|
||||
|
||||
@@ -71,6 +108,193 @@ type OIDCConfig struct {
|
||||
TLSInsecureSkipVerify bool `json:"tlsInsecureSkipVerify,omitempty"`
|
||||
}
|
||||
|
||||
// PrincipalTagsClaim is the AWS-defined namespace claim that carries
|
||||
// principal session tags. Tokens that include this claim must encode it as
|
||||
// an object whose top-level keys are tag names. AWS uses the same string;
|
||||
// see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html.
|
||||
const PrincipalTagsClaim = "https://aws.amazon.com/tags/principal_tags"
|
||||
|
||||
// extractClaimPolicies reads policy names from the configured JWT claim.
|
||||
// Accepts three shapes: a single string ("readonly"), a comma-separated
|
||||
// string ("readonly,billing"), or a string array. Returns nil when the
|
||||
// provider isn't in claim-based mode or the claim is absent/empty.
|
||||
func extractClaimPolicies(claims map[string]interface{}, claimName string) []string {
|
||||
if claimName == "" {
|
||||
return nil
|
||||
}
|
||||
raw, ok := claims[claimName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
return splitPolicyClaimString(v)
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, e := range v {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitPolicyClaimString(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// filterPrincipalTags drops keys that are not on `allowed`. An empty
|
||||
// allowlist means "deny all" — security-conservative default that forces
|
||||
// operators to explicitly opt tags in. Returns nil when the result is empty.
|
||||
//
|
||||
// Comparison is case-insensitive on the key, matching AWS IAM's session-tag
|
||||
// rules (the AWS docs explicitly state tag keys are case-insensitive even
|
||||
// though the original casing is preserved on the value side). Without this
|
||||
// an IDP whose claim casing drifts from the operator's allowlist string
|
||||
// would fail in surprising ways.
|
||||
func filterPrincipalTags(tags map[string]string, allowed []string) map[string]string {
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
allowSet := make(map[string]struct{}, len(allowed))
|
||||
for _, k := range allowed {
|
||||
allowSet[strings.ToLower(k)] = struct{}{}
|
||||
}
|
||||
out := make(map[string]string, len(tags))
|
||||
for k, v := range tags {
|
||||
if _, ok := allowSet[strings.ToLower(k)]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extractPrincipalTags pulls the principal-tags namespace claim out of the
|
||||
// JWT claim map. Only string values survive — everything else is dropped to
|
||||
// avoid surfacing structured data into a flat policy condition key. Returns
|
||||
// nil when the claim is absent or empty.
|
||||
func extractPrincipalTags(claims map[string]interface{}) map[string]string {
|
||||
raw, ok := claims[PrincipalTagsClaim]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
obj, ok := raw.(map[string]interface{})
|
||||
if !ok || len(obj) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(obj))
|
||||
for k, v := range obj {
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
out[k] = s
|
||||
case []interface{}:
|
||||
// Multi-value tag: AWS condition keys carry only a single value, so
|
||||
// take the first stringy element. Multi-value matching can land
|
||||
// later if a real customer needs it.
|
||||
for _, e := range s {
|
||||
if str, ok := e.(string); ok {
|
||||
out[k] = str
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeThumbprints lowercases and de-duplicates the configured allowlist.
|
||||
// Returns a set keyed by lowercase hex for O(1) lookup during TLS verification.
|
||||
func normalizeThumbprints(in []string) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(in))
|
||||
for _, t := range in {
|
||||
t = strings.ToLower(strings.TrimSpace(t))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out[t] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// verifyThumbprintMatch checks that some certificate in the negotiated chain
|
||||
// hashes to a thumbprint in `expected`. AWS pins the certificate immediately
|
||||
// below the root in the chain; we accept any chain certificate to also cover
|
||||
// self-signed deployments and skip-verify test setups. When the chain has not
|
||||
// been built (InsecureSkipVerify), we fall back to PeerCertificates.
|
||||
func verifyThumbprintMatch(cs tls.ConnectionState, expected map[string]struct{}) error {
|
||||
candidates := collectThumbprintCandidates(cs)
|
||||
for _, c := range candidates {
|
||||
sum := sha1.Sum(c.Raw)
|
||||
hexSum := hex.EncodeToString(sum[:])
|
||||
if _, ok := expected[hexSum]; ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("OIDC TLS thumbprint did not match any configured allowlist entry")
|
||||
}
|
||||
|
||||
// collectThumbprintCandidates flattens the verified chains and peer cert list
|
||||
// into a single slice of unique certificates worth checking.
|
||||
func collectThumbprintCandidates(cs tls.ConnectionState) []*x509.Certificate {
|
||||
var out []*x509.Certificate
|
||||
seen := map[string]struct{}{}
|
||||
add := func(c *x509.Certificate) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
k := string(c.Raw)
|
||||
if _, ok := seen[k]; ok {
|
||||
return
|
||||
}
|
||||
seen[k] = struct{}{}
|
||||
out = append(out, c)
|
||||
}
|
||||
for _, chain := range cs.VerifiedChains {
|
||||
for _, c := range chain {
|
||||
add(c)
|
||||
}
|
||||
}
|
||||
for _, c := range cs.PeerCertificates {
|
||||
add(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// JWKS represents JSON Web Key Set
|
||||
type JWKS struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
@@ -145,6 +369,20 @@ func (p *OIDCProvider) Initialize(config interface{}) error {
|
||||
glog.Warningf("OIDC provider %q is configured to skip TLS verification. This is insecure and should not be used in production.", p.name)
|
||||
}
|
||||
|
||||
// Thumbprint pinning: when configured, every TLS handshake to the IDP must
|
||||
// present a chain whose terminal certificate (the cert just below the root,
|
||||
// matching AWS IAM semantics) hashes to one of the listed SHA-1 digests.
|
||||
// VerifyConnection runs after the chain build, so cs.VerifiedChains is
|
||||
// populated when InsecureSkipVerify is false; we additionally pin against
|
||||
// PeerCertificates so self-signed test setups work too.
|
||||
if len(oidcConfig.Thumbprints) > 0 {
|
||||
expected := normalizeThumbprints(oidcConfig.Thumbprints)
|
||||
tlsConfig.VerifyConnection = func(cs tls.ConnectionState) error {
|
||||
return verifyThumbprintMatch(cs, expected)
|
||||
}
|
||||
glog.V(2).Infof("OIDC provider %q: TLS thumbprint pinning enabled (%d allowed)", p.name, len(expected))
|
||||
}
|
||||
|
||||
if oidcConfig.TLSCACert != "" {
|
||||
// Validate that the CA cert path is absolute to prevent reading unintended files
|
||||
if !filepath.IsAbs(oidcConfig.TLSCACert) {
|
||||
@@ -184,7 +422,7 @@ func (p *OIDCProvider) validateConfig(config *OIDCConfig) error {
|
||||
return fmt.Errorf("issuer is required")
|
||||
}
|
||||
|
||||
if config.ClientID == "" {
|
||||
if config.ClientID == "" && len(config.ClientIDs) == 0 {
|
||||
return fmt.Errorf("client ID is required")
|
||||
}
|
||||
|
||||
@@ -196,6 +434,32 @@ func (p *OIDCProvider) validateConfig(config *OIDCConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// allowedAudiences returns the merged list of acceptable audiences for this
|
||||
// provider. Both the singular ClientID and the plural ClientIDs are honoured;
|
||||
// duplicates collapse silently.
|
||||
func (p *OIDCProvider) allowedAudiences() []string {
|
||||
if p.config == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
add := func(s string) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
return
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
add(p.config.ClientID)
|
||||
for _, c := range p.config.ClientIDs {
|
||||
add(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user with an OIDC token
|
||||
func (p *OIDCProvider) Authenticate(ctx context.Context, token string) (*providers.ExternalIdentity, error) {
|
||||
if !p.initialized {
|
||||
@@ -266,12 +530,15 @@ func (p *OIDCProvider) Authenticate(ctx context.Context, token string) (*provide
|
||||
}
|
||||
|
||||
identity := &providers.ExternalIdentity{
|
||||
UserID: claims.Subject,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
Groups: groups,
|
||||
Attributes: attributes,
|
||||
Provider: p.name,
|
||||
UserID: claims.Subject,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
Groups: groups,
|
||||
Attributes: attributes,
|
||||
Provider: p.name,
|
||||
Issuer: claims.Issuer,
|
||||
PrincipalTags: filterPrincipalTags(extractPrincipalTags(claims.Claims), p.config.AllowedPrincipalTagKeys),
|
||||
ClaimPolicies: extractClaimPolicies(claims.Claims, p.config.PolicyClaim),
|
||||
}
|
||||
|
||||
// Pass the token expiration to limit session duration
|
||||
@@ -422,33 +689,45 @@ func (p *OIDCProvider) ValidateToken(ctx context.Context, token string) (*provid
|
||||
return nil, fmt.Errorf("%w: expected %s, got %s", providers.ErrProviderInvalidIssuer, p.config.Issuer, issuer)
|
||||
}
|
||||
|
||||
// Check audience claim (aud) or authorized party (azp) - Keycloak uses azp
|
||||
// Per RFC 7519, aud can be either a string or an array of strings
|
||||
// Check audience claim (aud) or authorized party (azp) — Keycloak uses azp.
|
||||
// Per RFC 7519, aud can be either a string or an array of strings.
|
||||
// Multiple client IDs are supported per AWS IAM CreateOpenIDConnectProvider
|
||||
// semantics: any one match in the allowlist accepts the token.
|
||||
allowed := p.allowedAudiences()
|
||||
allowedSet := make(map[string]struct{}, len(allowed))
|
||||
for _, a := range allowed {
|
||||
allowedSet[a] = struct{}{}
|
||||
}
|
||||
|
||||
var audienceMatched bool
|
||||
if audClaim, ok := claims["aud"]; ok {
|
||||
switch aud := audClaim.(type) {
|
||||
case string:
|
||||
if aud == p.config.ClientID {
|
||||
if _, ok := allowedSet[aud]; ok {
|
||||
audienceMatched = true
|
||||
}
|
||||
case []interface{}:
|
||||
for _, a := range aud {
|
||||
if str, ok := a.(string); ok && str == p.config.ClientID {
|
||||
audienceMatched = true
|
||||
break
|
||||
if str, ok := a.(string); ok {
|
||||
if _, ok := allowedSet[str]; ok {
|
||||
audienceMatched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !audienceMatched {
|
||||
if azp, ok := claims["azp"].(string); ok && azp == p.config.ClientID {
|
||||
audienceMatched = true
|
||||
if azp, ok := claims["azp"].(string); ok {
|
||||
if _, ok := allowedSet[azp]; ok {
|
||||
audienceMatched = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !audienceMatched {
|
||||
return nil, fmt.Errorf("%w: expected client ID %s", providers.ErrProviderInvalidAudience, p.config.ClientID)
|
||||
return nil, fmt.Errorf("%w: token audience matches none of the configured client IDs", providers.ErrProviderInvalidAudience)
|
||||
}
|
||||
|
||||
subject, ok := claims["sub"].(string)
|
||||
@@ -550,39 +829,169 @@ func (p *OIDCProvider) mapClaimsToRolesWithConfig(claims *providers.TokenClaims)
|
||||
return roles
|
||||
}
|
||||
|
||||
// getPublicKey retrieves the public key for the given key ID from JWKS
|
||||
// getPublicKey retrieves the public key for the given key ID from JWKS.
|
||||
// Cache hits use the read lock so concurrent token validations don't
|
||||
// serialize on JWKS lookup. Misses and expirations promote to the write
|
||||
// lock so the JWKS fetch + cache write happens once per refresh cycle.
|
||||
func (p *OIDCProvider) getPublicKey(ctx context.Context, kid string) (interface{}, error) {
|
||||
// Fetch JWKS if not cached or refresh if expired
|
||||
if p.jwksCache == nil || (!p.jwksFetchedAt.IsZero() && time.Since(p.jwksFetchedAt) > p.jwksTTL) {
|
||||
if err := p.fetchJWKS(ctx); err != nil {
|
||||
// Fast path: read lock and look in cache.
|
||||
p.mu.RLock()
|
||||
if p.jwksCache != nil && (p.jwksFetchedAt.IsZero() || time.Since(p.jwksFetchedAt) <= p.jwksTTL) {
|
||||
for _, key := range p.jwksCache.Keys {
|
||||
if key.Kid == kid {
|
||||
k := key
|
||||
p.mu.RUnlock()
|
||||
return p.parseJWK(&k)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
// Slow path: take the write lock for the (re)fetch + retry. Re-check the
|
||||
// cache under the write lock in case another goroutine already refreshed.
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
cacheValid := p.jwksCache != nil && (p.jwksFetchedAt.IsZero() || time.Since(p.jwksFetchedAt) <= p.jwksTTL)
|
||||
if !cacheValid {
|
||||
if err := p.fetchJWKSLocked(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch JWKS: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Find the key with matching kid
|
||||
for _, key := range p.jwksCache.Keys {
|
||||
if key.Kid == kid {
|
||||
return p.parseJWK(&key)
|
||||
k := key
|
||||
return p.parseJWK(&k)
|
||||
}
|
||||
}
|
||||
|
||||
// Key not found in cache. Refresh JWKS once to handle key rotation and retry.
|
||||
if err := p.fetchJWKS(ctx); err != nil {
|
||||
// Key not found in cache. Refresh JWKS once to handle key rotation.
|
||||
if err := p.fetchJWKSLocked(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh JWKS after key miss: %v", err)
|
||||
}
|
||||
for _, key := range p.jwksCache.Keys {
|
||||
if key.Kid == kid {
|
||||
return p.parseJWK(&key)
|
||||
k := key
|
||||
return p.parseJWK(&k)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("key with ID %s not found in JWKS after refresh", kid)
|
||||
}
|
||||
|
||||
// fetchJWKS fetches the JWKS from the provider
|
||||
// discoveryDocument is the subset of the OpenID Provider Configuration we need.
|
||||
// See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata.
|
||||
type discoveryDocument struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JWKSUri string `json:"jwks_uri"`
|
||||
}
|
||||
|
||||
// resolveJWKSUriLocked determines the JWKS URI for the provider. The caller
|
||||
// must hold p.mu (write lock); the function reads/writes p.resolvedJWKSUri
|
||||
// and p.discoveryFailed without taking the lock itself.
|
||||
//
|
||||
// Order of resolution:
|
||||
// 1. explicit config.JWKSUri (operator override; never overridden by discovery).
|
||||
// 2. cached resolvedJWKSUri from a prior discovery (refreshed when JWKS cache expires).
|
||||
// 3. .well-known/openid-configuration discovery (per OIDC Discovery 1.0).
|
||||
// 4. fallback to {issuer}/.well-known/jwks.json (compat path for IDPs that
|
||||
// don't publish discovery).
|
||||
func (p *OIDCProvider) resolveJWKSUriLocked(ctx context.Context) (string, error) {
|
||||
if p.config.JWKSUri != "" {
|
||||
return p.config.JWKSUri, nil
|
||||
}
|
||||
if p.resolvedJWKSUri != "" {
|
||||
return p.resolvedJWKSUri, nil
|
||||
}
|
||||
|
||||
issuer := strings.TrimSuffix(p.config.Issuer, "/")
|
||||
|
||||
if !p.discoveryFailed {
|
||||
discoveryURL := issuer + "/.well-known/openid-configuration"
|
||||
uri, err := p.fetchDiscoveryJWKSUri(ctx, discoveryURL)
|
||||
switch {
|
||||
case err == nil:
|
||||
p.resolvedJWKSUri = uri
|
||||
return uri, nil
|
||||
default:
|
||||
// Cache the failure so we don't pay the discovery RTT on every refresh.
|
||||
// Operators with non-discovery IDPs see one failed lookup at startup.
|
||||
glog.V(3).Infof("OIDC discovery at %s failed (%v); falling back to /.well-known/jwks.json", discoveryURL, err)
|
||||
p.discoveryFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
return issuer + "/.well-known/jwks.json", nil
|
||||
}
|
||||
|
||||
// fetchDiscoveryJWKSUri retrieves the OIDC discovery document and returns
|
||||
// the jwks_uri field. The issuer claim in the document must match config.Issuer
|
||||
// to defend against issuer-substitution attacks during discovery.
|
||||
func (p *OIDCProvider) fetchDiscoveryJWKSUri(ctx context.Context, discoveryURL string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", discoveryURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create discovery request: %v", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetch discovery document: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("discovery endpoint returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var doc discoveryDocument
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return "", fmt.Errorf("decode discovery document: %v", err)
|
||||
}
|
||||
|
||||
if doc.JWKSUri == "" {
|
||||
return "", fmt.Errorf("discovery document missing jwks_uri")
|
||||
}
|
||||
|
||||
// Issuer must be present and match: a discovery doc that points to a
|
||||
// different issuer is either a misconfiguration or an attack against
|
||||
// issuer-confusion, and a doc that omits the issuer field entirely
|
||||
// would have bypassed the previous check (doc.Issuer != "" guard) and
|
||||
// silently accepted whatever JWKS URI the document supplied. OIDC
|
||||
// Discovery 1.0 §3 mandates the issuer field, so treat missing as a
|
||||
// hard failure. Compare after trimming a single trailing slash on each
|
||||
// side because real IdPs disagree on whether the configured issuer
|
||||
// has one.
|
||||
if strings.TrimSuffix(doc.Issuer, "/") != strings.TrimSuffix(p.config.Issuer, "/") {
|
||||
return "", fmt.Errorf("discovery issuer %q does not match configured issuer %q", doc.Issuer, p.config.Issuer)
|
||||
}
|
||||
|
||||
return doc.JWKSUri, nil
|
||||
}
|
||||
|
||||
// fetchJWKS is a thin wrapper around fetchJWKSLocked that acquires the
|
||||
// write lock. Used by tests; production callers in getPublicKey already
|
||||
// hold the lock and call fetchJWKSLocked directly.
|
||||
func (p *OIDCProvider) fetchJWKS(ctx context.Context) error {
|
||||
jwksURL := p.config.JWKSUri
|
||||
if jwksURL == "" {
|
||||
jwksURL = strings.TrimSuffix(p.config.Issuer, "/") + "/.well-known/jwks.json"
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.fetchJWKSLocked(ctx)
|
||||
}
|
||||
|
||||
// fetchJWKSLocked fetches the JWKS from the provider. The caller must hold
|
||||
// p.mu (write lock); the function writes p.jwksCache and p.jwksFetchedAt
|
||||
// without taking the lock itself.
|
||||
//
|
||||
// Each fetch reattempts discovery if the previous attempt failed: a
|
||||
// transient 5xx that flipped discoveryFailed at startup shouldn't lock the
|
||||
// provider into the fallback path forever. The retry rate is bounded by
|
||||
// the JWKS TTL (typically 1h), so the discovery RTT cost is amortized.
|
||||
func (p *OIDCProvider) fetchJWKSLocked(ctx context.Context) error {
|
||||
if p.config.JWKSUri == "" && p.resolvedJWKSUri == "" {
|
||||
p.discoveryFailed = false
|
||||
}
|
||||
jwksURL, err := p.resolveJWKSUriLocked(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve JWKS URI: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", jwksURL, nil)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractClaimPoliciesString(t *testing.T) {
|
||||
got := extractClaimPolicies(map[string]interface{}{"policy": "readonly"}, "policy")
|
||||
if !reflect.DeepEqual(got, []string{"readonly"}) {
|
||||
t.Fatalf("got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimPoliciesCommaSeparated(t *testing.T) {
|
||||
got := extractClaimPolicies(map[string]interface{}{"policy": "readonly, billing , "}, "policy")
|
||||
if !reflect.DeepEqual(got, []string{"readonly", "billing"}) {
|
||||
t.Fatalf("got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimPoliciesArray(t *testing.T) {
|
||||
got := extractClaimPolicies(map[string]interface{}{
|
||||
"policy": []interface{}{"readonly", "billing", 42, ""}, // non-string + empty filtered
|
||||
}, "policy")
|
||||
if !reflect.DeepEqual(got, []string{"readonly", "billing"}) {
|
||||
t.Fatalf("got=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimPoliciesMissing(t *testing.T) {
|
||||
if got := extractClaimPolicies(map[string]interface{}{}, "policy"); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimPoliciesEmptyClaimName(t *testing.T) {
|
||||
// Provider not in claim-mode -> never read the claim, even if present.
|
||||
if got := extractClaimPolicies(map[string]interface{}{"policy": "readonly"}, ""); got != nil {
|
||||
t.Fatalf("empty claim name should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClaimPoliciesUnsupportedShape(t *testing.T) {
|
||||
// Numeric / object / bool values should be ignored, not panic.
|
||||
cases := []interface{}{
|
||||
42,
|
||||
3.14,
|
||||
map[string]interface{}{"x": "y"},
|
||||
true,
|
||||
}
|
||||
for _, v := range cases {
|
||||
if got := extractClaimPolicies(map[string]interface{}{"policy": v}, "policy"); got != nil {
|
||||
t.Fatalf("value %v: expected nil, got %v", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPrincipalTagsObjectShape(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
PrincipalTagsClaim: map[string]interface{}{
|
||||
"team": "infra",
|
||||
"env": "prod",
|
||||
"empty": "",
|
||||
// non-string values are dropped
|
||||
"count": 42,
|
||||
},
|
||||
}
|
||||
got := extractPrincipalTags(claims)
|
||||
want := map[string]string{"team": "infra", "env": "prod", "empty": ""}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrincipalTagsArrayValueTakesFirst(t *testing.T) {
|
||||
claims := map[string]interface{}{
|
||||
PrincipalTagsClaim: map[string]interface{}{
|
||||
"team": []interface{}{"infra", "ignored"},
|
||||
},
|
||||
}
|
||||
got := extractPrincipalTags(claims)
|
||||
if got["team"] != "infra" {
|
||||
t.Fatalf("expected first array element, got %q", got["team"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrincipalTagsAbsent(t *testing.T) {
|
||||
if got := extractPrincipalTags(map[string]interface{}{}); got != nil {
|
||||
t.Fatalf("expected nil for missing claim, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPrincipalTagsWrongShape(t *testing.T) {
|
||||
claims := map[string]interface{}{PrincipalTagsClaim: "not-an-object"}
|
||||
if got := extractPrincipalTags(claims); got != nil {
|
||||
t.Fatalf("expected nil for non-object claim, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPrincipalTagsAllowlist(t *testing.T) {
|
||||
in := map[string]string{"team": "infra", "env": "prod", "secret": "shh"}
|
||||
|
||||
t.Run("empty allowlist denies all", func(t *testing.T) {
|
||||
if got := filterPrincipalTags(in, nil); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial allowlist", func(t *testing.T) {
|
||||
got := filterPrincipalTags(in, []string{"team", "env"})
|
||||
want := map[string]string{"team": "infra", "env": "prod"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got=%v want=%v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowlist with no matches", func(t *testing.T) {
|
||||
if got := filterPrincipalTags(in, []string{"unknown"}); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case-insensitive on key", func(t *testing.T) {
|
||||
// AWS session tag keys are case-insensitive. An IDP whose claim
|
||||
// uses different casing than the operator's allowlist string
|
||||
// must still match.
|
||||
got := filterPrincipalTags(map[string]string{"Team": "infra"}, []string{"TEAM"})
|
||||
want := map[string]string{"Team": "infra"} // original casing preserved on output
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got=%v want=%v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// thumbprintFromTLS returns the SHA-1 hex of the first peer certificate the
|
||||
// given TLS server presents. It mirrors the pinning algorithm used by the
|
||||
// OIDC provider so the test is hashing the cert that will actually be
|
||||
// verified at runtime.
|
||||
func thumbprintFromTLS(t *testing.T, server *httptest.Server) string {
|
||||
t.Helper()
|
||||
if server.TLS == nil || len(server.Certificate().Raw) == 0 {
|
||||
t.Fatal("server has no TLS certificate")
|
||||
}
|
||||
sum := sha1.Sum(server.Certificate().Raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// newTLSIDP starts a TLS-only test IDP that always serves a static JWKS.
|
||||
// httptest TLS servers use a self-signed certificate by default, which means
|
||||
// callers who want a successful handshake must either trust that cert or
|
||||
// use TLSInsecureSkipVerify.
|
||||
func newTLSIDP(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(JWKS{Keys: []JWK{{Kty: "RSA", Kid: "k1", Use: "sig", Alg: "RS256", N: "AQAB", E: "AQAB"}}})
|
||||
})
|
||||
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r) // force fallback path
|
||||
})
|
||||
server := httptest.NewTLSServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
// pinnedProvider configures an OIDC provider that pins TLS to the given
|
||||
// thumbprint allowlist and trusts only the test server's self-signed cert.
|
||||
func pinnedProvider(t *testing.T, server *httptest.Server, thumbprints []string) *OIDCProvider {
|
||||
t.Helper()
|
||||
p := NewOIDCProvider("thumbprint-test")
|
||||
cfg := &OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "anything",
|
||||
Thumbprints: thumbprints,
|
||||
TLSInsecureSkipVerify: true, // we're doing the trust decision via thumbprint
|
||||
}
|
||||
if err := p.Initialize(cfg); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
// Replace the inner transport's RootCAs with the server's cert so the chain
|
||||
// builder also accepts it; this exercises the verified-chain path.
|
||||
if transport, ok := p.httpClient.Transport.(*http.Transport); ok && transport.TLSClientConfig != nil {
|
||||
transport.TLSClientConfig.InsecureSkipVerify = false
|
||||
transport.TLSClientConfig.RootCAs = server.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestThumbprintMatchAccepted(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
tp := thumbprintFromTLS(t, server)
|
||||
p := pinnedProvider(t, server, []string{tp})
|
||||
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS with matching thumbprint should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbprintMismatchRejected(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
// Flip the last byte so the digest can't match anything legitimate.
|
||||
bad := "0000000000000000000000000000000000000000"
|
||||
p := pinnedProvider(t, server, []string{bad})
|
||||
|
||||
err := p.fetchJWKS(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("fetchJWKS with mismatched thumbprint should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbprintAllowlistAcceptsMixedCase(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
tp := thumbprintFromTLS(t, server)
|
||||
// Mix uppercase + whitespace to ensure normalization is applied.
|
||||
p := pinnedProvider(t, server, []string{" " + uppercase(tp) + " "})
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("normalized thumbprint should match: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// uppercase returns s with letters uppercased — cheap helper avoiding a
|
||||
// dependency on strings.ToUpper at the package level for clarity.
|
||||
func uppercase(s string) string {
|
||||
out := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
c -= 32
|
||||
}
|
||||
out[i] = c
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// guard against a future refactor that drops VerifyConnection by checking
|
||||
// that omitting Thumbprints leaves the connection unverified-by-pin.
|
||||
func TestThumbprintEmptyAllowlistSkipsCheck(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
p := pinnedProvider(t, server, nil)
|
||||
transport, ok := p.httpClient.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatal("expected *http.Transport")
|
||||
}
|
||||
if transport.TLSClientConfig.VerifyConnection != nil {
|
||||
t.Fatal("VerifyConnection must be nil when no thumbprints are configured")
|
||||
}
|
||||
// And the full TLS handshake still succeeds normally.
|
||||
if err := p.fetchJWKS(context.Background()); err != nil {
|
||||
t.Fatalf("fetchJWKS with no thumbprints: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the underlying tls.Config still rejects truly invalid handshakes
|
||||
// when InsecureSkipVerify is on but no thumbprint allowlist is configured —
|
||||
// in that case we explicitly want to *trust* the connection (skip-verify),
|
||||
// just as the existing test code did.
|
||||
func TestThumbprintSkipVerifyHonoured(t *testing.T) {
|
||||
server := newTLSIDP(t)
|
||||
p := NewOIDCProvider("skip")
|
||||
if err := p.Initialize(&OIDCConfig{
|
||||
Issuer: server.URL,
|
||||
ClientID: "x",
|
||||
TLSInsecureSkipVerify: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
transport := p.httpClient.Transport.(*http.Transport)
|
||||
if !transport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("InsecureSkipVerify should be set")
|
||||
}
|
||||
if got := transport.TLSClientConfig.MinVersion; got != tls.VersionTLS12 {
|
||||
t.Fatalf("MinVersion = %v want TLS12", got)
|
||||
}
|
||||
}
|
||||
@@ -48,16 +48,19 @@ var (
|
||||
|
||||
// isSafePolicyVariable reports whether a policy variable may be substituted
|
||||
// with a value from RequestContext. The fixed allowlist covers AWS-defined
|
||||
// variables; any jwt:/saml:/oidc: claim is also allowed because those come
|
||||
// from a validated identity token (the STS session JWT or federated assertion)
|
||||
// and the claim set is controlled by the trusted identity provider.
|
||||
// variables; any jwt:/saml:/oidc:/aws:PrincipalTag/ claim is also allowed
|
||||
// because those come from a validated identity token (the STS session JWT
|
||||
// or federated assertion) and the claim set is controlled by the trusted
|
||||
// identity provider.
|
||||
func isSafePolicyVariable(variable string) bool {
|
||||
if safePolicyVariables[variable] {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(variable, "jwt:") ||
|
||||
strings.HasPrefix(variable, "saml:") ||
|
||||
strings.HasPrefix(variable, "oidc:")
|
||||
strings.HasPrefix(variable, "oidc:") ||
|
||||
strings.HasPrefix(variable, "aws:PrincipalTag/") ||
|
||||
strings.HasPrefix(variable, "aws:RequestTag/")
|
||||
}
|
||||
|
||||
// PolicyEngine evaluates policies against requests
|
||||
|
||||
@@ -49,6 +49,22 @@ type ExternalIdentity struct {
|
||||
// Provider is the name of the identity provider
|
||||
Provider string `json:"provider"`
|
||||
|
||||
// Issuer is the OIDC `iss` claim (or equivalent) from the source token.
|
||||
// Stable per (provider, identity) and used together with UserID to derive
|
||||
// a stable parent-user hash that survives token rotation.
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
|
||||
// PrincipalTags are key/value pairs extracted from the AWS principal-tags
|
||||
// namespace claim (`https://aws.amazon.com/tags/principal_tags`). They are
|
||||
// surfaced as `aws:PrincipalTag/<key>` in the policy request context
|
||||
// (subject to per-provider allowlist filtering at the STS layer).
|
||||
PrincipalTags map[string]string `json:"principalTags,omitempty"`
|
||||
|
||||
// ClaimPolicies are policy names pulled from the provider-configured
|
||||
// PolicyClaim. Empty when the provider isn't running in claim-based
|
||||
// policy mode or the claim was absent.
|
||||
ClaimPolicies []string `json:"claimPolicies,omitempty"`
|
||||
|
||||
// TokenExpiration is the expiration time of the source identity token
|
||||
// This is used to limit session duration to not exceed the token's exp claim
|
||||
TokenExpiration *time.Time `json:"tokenExpiration,omitempty"`
|
||||
|
||||
@@ -13,6 +13,85 @@ type CommonResponse struct {
|
||||
} `xml:"ResponseMetadata"`
|
||||
}
|
||||
|
||||
// IAMTag mirrors AWS IAM's Tag list element with the Key/Value pair shape.
|
||||
type IAMTag struct {
|
||||
Key string `xml:"Key"`
|
||||
Value string `xml:"Value"`
|
||||
}
|
||||
|
||||
// OpenIDConnectProviderListEntry is one element of ListOpenIDConnectProviders.
|
||||
type OpenIDConnectProviderListEntry struct {
|
||||
Arn string `xml:"Arn"`
|
||||
}
|
||||
|
||||
// ListOpenIDConnectProvidersResponse is the response for ListOpenIDConnectProviders.
|
||||
type ListOpenIDConnectProvidersResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ ListOpenIDConnectProvidersResponse"`
|
||||
ListOpenIDConnectProvidersResult struct {
|
||||
OpenIDConnectProviderList []*OpenIDConnectProviderListEntry `xml:"OpenIDConnectProviderList>member"`
|
||||
} `xml:"ListOpenIDConnectProvidersResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// GetOpenIDConnectProviderResponse is the response for GetOpenIDConnectProvider.
|
||||
type GetOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ GetOpenIDConnectProviderResponse"`
|
||||
GetOpenIDConnectProviderResult struct {
|
||||
Url string `xml:"Url"`
|
||||
ClientIDList []string `xml:"ClientIDList>member,omitempty"`
|
||||
ThumbprintList []string `xml:"ThumbprintList>member,omitempty"`
|
||||
Tags []*IAMTag `xml:"Tags>member,omitempty"`
|
||||
CreateDate string `xml:"CreateDate,omitempty"`
|
||||
} `xml:"GetOpenIDConnectProviderResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// CreateOpenIDConnectProviderResponse is the response for CreateOpenIDConnectProvider.
|
||||
type CreateOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ CreateOpenIDConnectProviderResponse"`
|
||||
CreateOpenIDConnectProviderResult struct {
|
||||
OpenIDConnectProviderArn string `xml:"OpenIDConnectProviderArn"`
|
||||
Tags []*IAMTag `xml:"Tags>member,omitempty"`
|
||||
} `xml:"CreateOpenIDConnectProviderResult"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// DeleteOpenIDConnectProviderResponse is the response for DeleteOpenIDConnectProvider.
|
||||
type DeleteOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ DeleteOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// AddClientIDToOpenIDConnectProviderResponse is the response for AddClientIDToOpenIDConnectProvider.
|
||||
type AddClientIDToOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ AddClientIDToOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// RemoveClientIDFromOpenIDConnectProviderResponse is the response for RemoveClientIDFromOpenIDConnectProvider.
|
||||
type RemoveClientIDFromOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ RemoveClientIDFromOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UpdateOpenIDConnectProviderThumbprintResponse is the response for UpdateOpenIDConnectProviderThumbprint.
|
||||
type UpdateOpenIDConnectProviderThumbprintResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UpdateOpenIDConnectProviderThumbprintResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// TagOpenIDConnectProviderResponse is the response for TagOpenIDConnectProvider.
|
||||
type TagOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ TagOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// UntagOpenIDConnectProviderResponse is the response for UntagOpenIDConnectProvider.
|
||||
type UntagOpenIDConnectProviderResponse struct {
|
||||
XMLName xml.Name `xml:"https://iam.amazonaws.com/doc/2010-05-08/ UntagOpenIDConnectProviderResponse"`
|
||||
CommonResponse
|
||||
}
|
||||
|
||||
// SetRequestId stores the request ID generated for the current HTTP request.
|
||||
func (r *CommonResponse) SetRequestId(requestID string) {
|
||||
r.ResponseMetadata.RequestId = requestID
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package sts
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsClaimBasedPolicyRoleArn(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
arn string
|
||||
want bool
|
||||
}{
|
||||
{"empty matches", "", true},
|
||||
{"sentinel matches", ClaimBasedPolicyRoleArn, true},
|
||||
{"concrete role does not", "arn:aws:iam::123:role/admin", false},
|
||||
{"random ARN does not", "arn:aws:sts::123:assumed-role/X/Y", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsClaimBasedPolicyRoleArn(tc.arn); got != tc.want {
|
||||
t.Fatalf("got=%v want=%v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,10 @@ const (
|
||||
ConfigFieldBasePath = "basePath"
|
||||
ConfigFieldIssuer = "issuer"
|
||||
ConfigFieldClientID = "clientId"
|
||||
ConfigFieldClientIDs = "clientIds"
|
||||
ConfigFieldThumbprints = "thumbprints"
|
||||
ConfigFieldAllowedPrincipalTagKeys = "allowedPrincipalTagKeys"
|
||||
ConfigFieldPolicyClaim = "policyClaim"
|
||||
ConfigFieldClientSecret = "clientSecret"
|
||||
ConfigFieldJWKSUri = "jwksUri"
|
||||
ConfigFieldScopes = "scopes"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
|
||||
)
|
||||
|
||||
// stubIdentityProvider is the minimal IdentityProvider needed to drive
|
||||
// lookupOIDCProviderForAccount; the lookup never calls Authenticate.
|
||||
type stubIdentityProvider struct{ name string }
|
||||
|
||||
func (s *stubIdentityProvider) Name() string { return s.name }
|
||||
func (s *stubIdentityProvider) Initialize(interface{}) error { return nil }
|
||||
func (s *stubIdentityProvider) Authenticate(context.Context, string) (*providers.ExternalIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubIdentityProvider) GetUserInfo(context.Context, string) (*providers.ExternalIdentity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubIdentityProvider) ValidateToken(context.Context, string) (*providers.TokenClaims, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountPrefersAccountMatch(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
accountA := &stubIdentityProvider{name: "A"}
|
||||
accountB := &stubIdentityProvider{name: "B"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
|
||||
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
|
||||
issuer: {
|
||||
{AccountID: "111111111111", Provider: accountA},
|
||||
{AccountID: "222222222222", Provider: accountB},
|
||||
},
|
||||
})
|
||||
|
||||
got, ok := s.lookupOIDCProviderForAccount(issuer, "222222222222")
|
||||
if !ok || got != accountB {
|
||||
t.Fatalf("expected accountB provider for matching account, got %v ok=%v", got, ok)
|
||||
}
|
||||
got, ok = s.lookupOIDCProviderForAccount(issuer, "111111111111")
|
||||
if !ok || got != accountA {
|
||||
t.Fatalf("expected accountA provider for matching account, got %v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountFallsBackToGlobal(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
global := &stubIdentityProvider{name: "global"}
|
||||
accountA := &stubIdentityProvider{name: "A"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
|
||||
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
|
||||
issuer: {
|
||||
{AccountID: "", Provider: global},
|
||||
{AccountID: "111111111111", Provider: accountA},
|
||||
},
|
||||
})
|
||||
|
||||
// Account not represented in records → global match.
|
||||
got, ok := s.lookupOIDCProviderForAccount(issuer, "999999999999")
|
||||
if !ok || got != global {
|
||||
t.Fatalf("expected global provider as fallback, got %v ok=%v", got, ok)
|
||||
}
|
||||
// Empty account hint → never picks an account-scoped record arbitrarily;
|
||||
// only the global record is eligible.
|
||||
got, ok = s.lookupOIDCProviderForAccount(issuer, "")
|
||||
if !ok || got != global {
|
||||
t.Fatalf("expected global provider when account unknown, got %v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountSkipsAccountSpecificWhenAccountUnknown(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
accountA := &stubIdentityProvider{name: "A"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{}}
|
||||
s.SetIAMManagedOIDCProviders(map[string][]ScopedOIDCProvider{
|
||||
issuer: {
|
||||
{AccountID: "111111111111", Provider: accountA},
|
||||
},
|
||||
})
|
||||
|
||||
// No account hint AND no global record → must fall through (return false),
|
||||
// not silently pick the account-A entry. Picking an arbitrary entry is the
|
||||
// pre-fix bug that lets a token be validated by the wrong tenant's record.
|
||||
if _, ok := s.lookupOIDCProviderForAccount(issuer, ""); ok {
|
||||
t.Fatalf("expected no match when account unknown and only account-scoped records exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupOIDCProviderForAccountFallsBackToStatic(t *testing.T) {
|
||||
const issuer = "https://example.com"
|
||||
static := &stubIdentityProvider{name: "static"}
|
||||
|
||||
s := &STSService{issuerToProvider: map[string]providers.IdentityProvider{issuer: static}}
|
||||
s.SetIAMManagedOIDCProviders(nil)
|
||||
|
||||
got, ok := s.lookupOIDCProviderForAccount(issuer, "111111111111")
|
||||
if !ok || got != static {
|
||||
t.Fatalf("expected static-config provider as last resort, got %v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComputeParentUserStability(t *testing.T) {
|
||||
// Same (sub, iss) must produce the same hash, regardless of order or
|
||||
// whitespace mutations callers should never apply.
|
||||
a := ComputeParentUser("alice", "https://idp.example/")
|
||||
b := ComputeParentUser("alice", "https://idp.example/")
|
||||
if a == "" {
|
||||
t.Fatal("parent user should not be empty for non-empty sub")
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("expected stable hash, got %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserDistinguishesIssuer(t *testing.T) {
|
||||
// The point of incorporating iss is that the same `sub` from two providers
|
||||
// must not collide. If this assertion ever fails, the hash input is wrong.
|
||||
a := ComputeParentUser("alice", "https://idp-a.example/")
|
||||
b := ComputeParentUser("alice", "https://idp-b.example/")
|
||||
if a == b {
|
||||
t.Fatalf("hashes for different issuers must differ, both = %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserDistinguishesSubject(t *testing.T) {
|
||||
a := ComputeParentUser("alice", "https://idp.example/")
|
||||
b := ComputeParentUser("bob", "https://idp.example/")
|
||||
if a == b {
|
||||
t.Fatalf("hashes for different subjects must differ, both = %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserEmptySub(t *testing.T) {
|
||||
if got := ComputeParentUser("", "https://idp.example/"); got != "" {
|
||||
t.Fatalf("empty sub should produce empty parent user, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserEmptyIss(t *testing.T) {
|
||||
// Without an issuer, two different IDPs that both name a user "alice"
|
||||
// would collide on the same parent_user. Refuse rather than hash.
|
||||
if got := ComputeParentUser("alice", ""); got != "" {
|
||||
t.Fatalf("empty iss should produce empty parent user, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeParentUserEncoding(t *testing.T) {
|
||||
got := ComputeParentUser("alice", "https://idp.example/")
|
||||
// Base64 RawURL has no padding and uses URL-safe alphabet — important
|
||||
// because parent_user shows up in filer paths and audit log fields.
|
||||
if strings.ContainsAny(got, "=+/") {
|
||||
t.Fatalf("parent user should be base64 raw url, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionClaimsRoundTripParentUser(t *testing.T) {
|
||||
parent := ComputeParentUser("alice", "https://idp.example/")
|
||||
claims := NewSTSSessionClaims("sid-1", "issuer", time.Now().Add(time.Hour)).
|
||||
WithRoleInfo("arn:aws:iam::123:role/r", "arn:aws:sts::123:assumed-role/r/s", "arn:aws:sts::123:assumed-role/r/s").
|
||||
WithParentUser(parent)
|
||||
|
||||
info := claims.ToSessionInfo()
|
||||
if info.ParentUser != parent {
|
||||
t.Fatalf("ParentUser lost on round-trip: got %q want %q", info.ParentUser, parent)
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,20 @@ func (f *ProviderFactory) convertToOIDCConfig(configMap map[string]interface{})
|
||||
return nil, fmt.Errorf(ErrIssuerRequired)
|
||||
}
|
||||
|
||||
if clientID, ok := configMap[ConfigFieldClientID].(string); ok {
|
||||
clientIDPresent := false
|
||||
if clientID, ok := configMap[ConfigFieldClientID].(string); ok && clientID != "" {
|
||||
config.ClientID = clientID
|
||||
} else {
|
||||
clientIDPresent = true
|
||||
}
|
||||
// Accept the AWS-compatible plural form. Either field satisfies the
|
||||
// "at least one client id" requirement; both can be set together.
|
||||
if rawList, ok := configMap[ConfigFieldClientIDs]; ok {
|
||||
if list, err := f.convertToStringSlice(rawList); err == nil && len(list) > 0 {
|
||||
config.ClientIDs = list
|
||||
clientIDPresent = true
|
||||
}
|
||||
}
|
||||
if !clientIDPresent {
|
||||
return nil, fmt.Errorf(ErrClientIDRequired)
|
||||
}
|
||||
|
||||
@@ -123,6 +134,22 @@ func (f *ProviderFactory) convertToOIDCConfig(configMap map[string]interface{})
|
||||
config.TLSCACert = tlsCaCert
|
||||
}
|
||||
|
||||
if rawThumbprints, ok := configMap[ConfigFieldThumbprints]; ok {
|
||||
if list, err := f.convertToStringSlice(rawThumbprints); err == nil {
|
||||
config.Thumbprints = list
|
||||
}
|
||||
}
|
||||
|
||||
if rawAllowed, ok := configMap[ConfigFieldAllowedPrincipalTagKeys]; ok {
|
||||
if list, err := f.convertToStringSlice(rawAllowed); err == nil {
|
||||
config.AllowedPrincipalTagKeys = list
|
||||
}
|
||||
}
|
||||
|
||||
if policyClaim, ok := configMap[ConfigFieldPolicyClaim].(string); ok {
|
||||
config.PolicyClaim = policyClaim
|
||||
}
|
||||
|
||||
if tlsInsecureSkipVerify, ok := configMap[ConfigFieldTLSInsecureSkipVerify].(bool); ok {
|
||||
config.TLSInsecureSkipVerify = tlsInsecureSkipVerify
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package sts
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -8,6 +10,20 @@ import (
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
)
|
||||
|
||||
// ComputeParentUser returns a stable per-identity hash derived from the OIDC
|
||||
// (sub, iss) tuple. Only the (sub, iss) pair is guaranteed stable across token
|
||||
// refreshes per OpenID Connect Core 1.0 §5.7, so any per-user state (audit
|
||||
// logs, quotas) must key off this value rather than the access-key or session
|
||||
// id. The hash is base64-rawurl-encoded SHA-256 over "openid:<sub>:<iss>" so
|
||||
// it stays filesystem-safe and bounded in length for storage in audit paths.
|
||||
func ComputeParentUser(sub, iss string) string {
|
||||
if sub == "" || iss == "" {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256([]byte("openid:" + sub + ":" + iss))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// defaultCredentialGenerator is a reusable instance for generating temporary credentials
|
||||
// Reusing a single instance across all calls to ToSessionInfo() reduces allocation overhead
|
||||
// since this method may be called frequently during signature verification
|
||||
@@ -45,6 +61,12 @@ type STSSessionClaims struct {
|
||||
// Session metadata
|
||||
AssumedAt time.Time `json:"assumed_at"` // when role was assumed
|
||||
MaxDuration int64 `json:"max_dur,omitempty"` // maximum session duration in seconds
|
||||
|
||||
// ParentUser is a stable hash of (sub, iss) for tokens minted from an OIDC
|
||||
// identity. It survives token rotation since only the (sub, iss) tuple is
|
||||
// guaranteed stable per OpenID Connect Core 1.0. Empty for non-federated
|
||||
// session types.
|
||||
ParentUser string `json:"puid,omitempty"`
|
||||
}
|
||||
|
||||
// NewSTSSessionClaims creates new STS session claims with all required information
|
||||
@@ -57,6 +79,10 @@ func NewSTSSessionClaims(sessionId, issuer string, expiresAt time.Time) *STSSess
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
// jti = sessionId. The session id is already a unique random
|
||||
// identifier, so reusing it as the JWT id avoids a second secret
|
||||
// while still giving the revocation layer a stable lookup key.
|
||||
ID: sessionId,
|
||||
},
|
||||
SessionId: sessionId,
|
||||
TokenType: TokenTypeSession,
|
||||
@@ -96,6 +122,7 @@ func (c *STSSessionClaims) ToSessionInfo() *SessionInfo {
|
||||
ExternalUserId: c.ExternalUserId,
|
||||
ProviderIssuer: c.ProviderIssuer,
|
||||
RequestContext: c.RequestContext,
|
||||
ParentUser: c.ParentUser,
|
||||
// Provide the Subject (sub) from registered claims
|
||||
Subject: c.Subject,
|
||||
Credentials: credentials,
|
||||
@@ -182,3 +209,10 @@ func (c *STSSessionClaims) WithSessionName(sessionName string) *STSSessionClaims
|
||||
c.SessionName = sessionName
|
||||
return c
|
||||
}
|
||||
|
||||
// WithParentUser sets the stable per-identity hash for the session. See
|
||||
// ComputeParentUser for the derivation rule.
|
||||
func (c *STSSessionClaims) WithParentUser(parentUser string) *STSSessionClaims {
|
||||
c.ParentUser = parentUser
|
||||
return c
|
||||
}
|
||||
|
||||
+196
-22
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -68,6 +69,21 @@ func (fd FlexibleDuration) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(fd.Duration.String())
|
||||
}
|
||||
|
||||
// ClaimBasedPolicyRoleArn is the AWS-shaped sentinel ARN that callers pass
|
||||
// in the AssumeRoleWithWebIdentity RoleArn field to opt into claim-based
|
||||
// policy resolution. Using a recognisable role-style ARN here (rather than
|
||||
// requiring an empty RoleArn) lets SDKs and AWS CLI builds that always
|
||||
// require RoleArn to be set still reach this code path.
|
||||
const ClaimBasedPolicyRoleArn = "arn:aws:iam:::role/sts-claim-based"
|
||||
|
||||
// IsClaimBasedPolicyRoleArn reports whether the supplied RoleArn opts the
|
||||
// caller into claim-based policy mode. Either the empty string (caller
|
||||
// omitted RoleArn entirely; common for SDKs that didn't expect to need one)
|
||||
// or the explicit sentinel triggers the mode.
|
||||
func IsClaimBasedPolicyRoleArn(arn string) bool {
|
||||
return arn == "" || arn == ClaimBasedPolicyRoleArn
|
||||
}
|
||||
|
||||
// STSService provides Security Token Service functionality
|
||||
// This service is now completely stateless - all session information is embedded
|
||||
// in JWT tokens, eliminating the need for session storage and enabling true
|
||||
@@ -79,6 +95,31 @@ type STSService struct {
|
||||
issuerToProvider map[string]providers.IdentityProvider // Efficient issuer-based provider lookup
|
||||
tokenGenerator *TokenGenerator
|
||||
trustPolicyValidator TrustPolicyValidator // Interface for trust policy validation
|
||||
|
||||
// iamManagedOIDCMu guards iamManagedOIDCByIssuer. The map is the live view
|
||||
// of providers persisted in the IAM-managed OIDCProviderStore; it is
|
||||
// atomically replaced by SetIAMManagedOIDCProviders whenever the store
|
||||
// changes (either via a local IAM API call or a metadata-subscribe event
|
||||
// from a peer). Lookups consult this map first and fall back to the
|
||||
// static-config issuerToProvider so admin-managed entries always take
|
||||
// precedence over the bootstrap config.
|
||||
//
|
||||
// The slice value lets multiple records share an issuer when each is
|
||||
// scoped to a different account; lookup picks the entry whose AccountID
|
||||
// matches the role being assumed (or the global, AccountID="" entry as a
|
||||
// fallback). Without this, two accounts' records for the same issuer
|
||||
// would race for one map slot and a token could be validated by a
|
||||
// provider that wasn't scoped to the role's account.
|
||||
iamManagedOIDCMu sync.RWMutex
|
||||
iamManagedOIDCByIssuer map[string][]ScopedOIDCProvider
|
||||
}
|
||||
|
||||
// ScopedOIDCProvider pairs an OIDC IdentityProvider with the account it is
|
||||
// scoped to. AccountID="" means the provider is global (usable from any
|
||||
// account).
|
||||
type ScopedOIDCProvider struct {
|
||||
AccountID string
|
||||
Provider providers.IdentityProvider
|
||||
}
|
||||
|
||||
// GetTokenGenerator returns the token generator used by the STS service.
|
||||
@@ -254,6 +295,9 @@ type SessionInfo struct {
|
||||
|
||||
// Credentials are the temporary credentials for this session
|
||||
Credentials *Credentials `json:"credentials"`
|
||||
|
||||
// ParentUser is the stable hashed identity (sub+iss) derived at federation time.
|
||||
ParentUser string `json:"parentUser,omitempty"`
|
||||
}
|
||||
|
||||
// NewSTSService creates a new STS service
|
||||
@@ -421,6 +465,69 @@ func (s *STSService) GetProviders() map[string]providers.IdentityProvider {
|
||||
return s.providers
|
||||
}
|
||||
|
||||
// SetIAMManagedOIDCProviders atomically replaces the IAM-managed OIDC
|
||||
// provider map. Pass nil or an empty map to clear all managed entries. The
|
||||
// caller passes a fully-built map keyed by issuer URL; the slice value lets
|
||||
// per-account records coexist under the same issuer.
|
||||
func (s *STSService) SetIAMManagedOIDCProviders(byIssuer map[string][]ScopedOIDCProvider) {
|
||||
// Defensively copy so callers can keep mutating their map without affecting
|
||||
// in-flight lookups. A nil input becomes an empty map for cheap reads.
|
||||
cp := make(map[string][]ScopedOIDCProvider, len(byIssuer))
|
||||
for issuer, scoped := range byIssuer {
|
||||
if issuer == "" {
|
||||
continue
|
||||
}
|
||||
entries := make([]ScopedOIDCProvider, 0, len(scoped))
|
||||
for _, sp := range scoped {
|
||||
if sp.Provider == nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, sp)
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
cp[issuer] = entries
|
||||
}
|
||||
}
|
||||
s.iamManagedOIDCMu.Lock()
|
||||
s.iamManagedOIDCByIssuer = cp
|
||||
s.iamManagedOIDCMu.Unlock()
|
||||
}
|
||||
|
||||
// lookupOIDCProviderForAccount returns the provider that should validate
|
||||
// tokens from `issuer` when the caller is assuming a role in `accountID`.
|
||||
// Selection order:
|
||||
// 1. IAM-managed record exactly scoped to accountID (when accountID != "");
|
||||
// 2. IAM-managed record with empty AccountID (global);
|
||||
// 3. static-config issuerToProvider (legacy path; account-agnostic).
|
||||
//
|
||||
// Without the (issuer, account) key, two records for the same issuer (e.g.
|
||||
// account A with clientIDs=[a] and account B with clientIDs=[b]) would race
|
||||
// for one map slot, and a token destined for account B could be validated by
|
||||
// account A's record. The role-account check in
|
||||
// IAMManager.enforceProviderAccountScope blocks the cross-account assumption
|
||||
// itself, but the validation must use the right record's clientIDs and
|
||||
// thumbprints in the first place.
|
||||
func (s *STSService) lookupOIDCProviderForAccount(issuer, accountID string) (providers.IdentityProvider, bool) {
|
||||
s.iamManagedOIDCMu.RLock()
|
||||
scoped := s.iamManagedOIDCByIssuer[issuer]
|
||||
var globalMatch providers.IdentityProvider
|
||||
for _, sp := range scoped {
|
||||
if accountID != "" && sp.AccountID == accountID {
|
||||
s.iamManagedOIDCMu.RUnlock()
|
||||
return sp.Provider, true
|
||||
}
|
||||
if sp.AccountID == "" && globalMatch == nil {
|
||||
globalMatch = sp.Provider
|
||||
}
|
||||
}
|
||||
s.iamManagedOIDCMu.RUnlock()
|
||||
if globalMatch != nil {
|
||||
return globalMatch, true
|
||||
}
|
||||
p, ok := s.issuerToProvider[issuer]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// SetTrustPolicyValidator sets the trust policy validator for role assumption validation
|
||||
func (s *STSService) SetTrustPolicyValidator(validator TrustPolicyValidator) {
|
||||
s.trustPolicyValidator = validator
|
||||
@@ -451,23 +558,50 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
|
||||
sessionPolicy = normalized
|
||||
}
|
||||
|
||||
// 1. Validate the web identity token with appropriate provider
|
||||
externalIdentity, provider, err := s.validateWebIdentityToken(ctx, request.WebIdentityToken)
|
||||
// 1. Validate the web identity token with appropriate provider. The role
|
||||
// ARN's account scopes which IAM-managed record may validate the token —
|
||||
// see lookupOIDCProviderForAccount. ParseRoleARN returns "" when the
|
||||
// caller passed a legacy or claim-based ARN, in which case lookup falls
|
||||
// back to a global (account-less) record only.
|
||||
roleAccountID := utils.ParseRoleARN(request.RoleArn).AccountID
|
||||
externalIdentity, provider, err := s.validateWebIdentityToken(ctx, request.WebIdentityToken, roleAccountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to validate web identity token: %w", err)
|
||||
}
|
||||
|
||||
// 2. Check if the role exists and can be assumed (includes trust policy validation)
|
||||
if err := s.validateRoleAssumptionForWebIdentity(ctx, request.RoleArn, request.WebIdentityToken, request.DurationSeconds); err != nil {
|
||||
return nil, fmt.Errorf("role assumption denied: %w", err)
|
||||
// 2. Decide between concrete-role mode and claim-based policy mode.
|
||||
// Claim-based mode requires both the sentinel RoleArn and a non-empty
|
||||
// ClaimPolicies list — the second check guards against an IDP that just
|
||||
// happens not to emit the policy claim today.
|
||||
claimMode := IsClaimBasedPolicyRoleArn(request.RoleArn) && len(externalIdentity.ClaimPolicies) > 0
|
||||
effectiveRoleArn := request.RoleArn
|
||||
if claimMode {
|
||||
// Replace an empty RoleArn with the constant sentinel so the assumed-
|
||||
// role ARN GenerateAssumedRoleArn produces below carries a stable,
|
||||
// session-name-keyed principal that policy evaluation can log and
|
||||
// rate-limit on.
|
||||
effectiveRoleArn = ClaimBasedPolicyRoleArn
|
||||
} else if request.RoleArn == "" {
|
||||
return nil, fmt.Errorf("RoleArn is required when claim-based policy mode is not configured")
|
||||
} else if request.RoleArn == ClaimBasedPolicyRoleArn {
|
||||
return nil, fmt.Errorf("claim-based policy mode requires the IDP to emit policies via the configured policyClaim")
|
||||
}
|
||||
|
||||
// 3. Calculate session duration, capping at the source token's expiration
|
||||
// 3. Trust-policy validation only runs in concrete-role mode. In
|
||||
// claim-mode the IDP is the sole authority for both authentication and
|
||||
// authorization, so there is no role definition to consult.
|
||||
if !claimMode {
|
||||
if err := s.validateRoleAssumptionForWebIdentity(ctx, request.RoleArn, request.WebIdentityToken, request.DurationSeconds); err != nil {
|
||||
return nil, fmt.Errorf("role assumption denied: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Calculate session duration, capping at the source token's expiration
|
||||
// This ensures sessions from short-lived tokens (e.g., GitLab CI job tokens) don't outlive their source
|
||||
sessionDuration := s.calculateSessionDuration(request.DurationSeconds, externalIdentity.TokenExpiration)
|
||||
expiresAt := time.Now().Add(sessionDuration)
|
||||
|
||||
// 4. Generate session ID and credentials
|
||||
// 5. Generate session ID and credentials
|
||||
sessionId, err := GenerateSessionId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session ID: %w", err)
|
||||
@@ -479,10 +613,10 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
|
||||
return nil, fmt.Errorf("failed to generate credentials: %w", err)
|
||||
}
|
||||
|
||||
// 5. Create comprehensive JWT session token with all session information embedded
|
||||
// 6. Create comprehensive JWT session token with all session information embedded
|
||||
assumedRoleUser := &AssumedRoleUser{
|
||||
AssumedRoleId: request.RoleArn,
|
||||
Arn: GenerateAssumedRoleArn(request.RoleArn, request.RoleSessionName),
|
||||
AssumedRoleId: effectiveRoleArn,
|
||||
Arn: GenerateAssumedRoleArn(effectiveRoleArn, request.RoleSessionName),
|
||||
Subject: externalIdentity.UserID,
|
||||
}
|
||||
|
||||
@@ -506,13 +640,38 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
|
||||
// Add sub as well since it's commonly used
|
||||
requestContext["sub"] = externalIdentity.UserID
|
||||
|
||||
// Compute a stable parent-user hash from (sub, iss). Only this tuple is
|
||||
// guaranteed stable across token refresh per OIDC Core 1.0, so this is the
|
||||
// right key for any per-identity state (audit trail, future quotas).
|
||||
parentUser := ComputeParentUser(externalIdentity.UserID, externalIdentity.Issuer)
|
||||
if parentUser != "" {
|
||||
// Surface as aws:userid so policies can reference it directly without
|
||||
// caring about token-rotation churn.
|
||||
requestContext["aws:userid"] = parentUser
|
||||
}
|
||||
|
||||
// Surface principal session tags as aws:PrincipalTag/<key>. The OIDC
|
||||
// provider has already filtered the claim namespace through its
|
||||
// AllowedPrincipalTagKeys list (see filterPrincipalTags), so anything
|
||||
// reaching us here is on the operator's opt-in list. The full claim
|
||||
// is dropped if the allowlist is empty, which is the secure default.
|
||||
for k, v := range externalIdentity.PrincipalTags {
|
||||
requestContext["aws:PrincipalTag/"+k] = v
|
||||
}
|
||||
|
||||
// Create rich JWT claims with all session information
|
||||
sessionClaims := NewSTSSessionClaims(sessionId, s.Config.Issuer, expiresAt).
|
||||
WithSessionName(request.RoleSessionName).
|
||||
WithRoleInfo(request.RoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
|
||||
WithIdentityProvider(provider.Name(), externalIdentity.UserID, "").
|
||||
WithRoleInfo(effectiveRoleArn, assumedRoleUser.Arn, assumedRoleUser.Arn).
|
||||
WithIdentityProvider(provider.Name(), externalIdentity.UserID, externalIdentity.Issuer).
|
||||
WithMaxDuration(sessionDuration).
|
||||
WithRequestContext(requestContext)
|
||||
if claimMode {
|
||||
sessionClaims.WithPolicies(externalIdentity.ClaimPolicies)
|
||||
}
|
||||
if parentUser != "" {
|
||||
sessionClaims.WithParentUser(parentUser)
|
||||
}
|
||||
if sessionPolicy != "" {
|
||||
sessionClaims.WithSessionPolicy(sessionPolicy)
|
||||
}
|
||||
@@ -661,12 +820,13 @@ func (s *STSService) ValidateSessionToken(ctx context.Context, sessionToken stri
|
||||
|
||||
// Helper methods for AssumeRoleWithWebIdentity
|
||||
|
||||
// validateAssumeRoleWithWebIdentityRequest validates the request parameters
|
||||
// validateAssumeRoleWithWebIdentityRequest validates the request parameters.
|
||||
//
|
||||
// RoleArn validation lives in AssumeRoleWithWebIdentity itself rather than
|
||||
// here: once we've parsed the JWT we know whether the caller has
|
||||
// ClaimPolicies and can decide between concrete-role mode and claim-mode,
|
||||
// which in turn determines whether an empty/sentinel RoleArn is acceptable.
|
||||
func (s *STSService) validateAssumeRoleWithWebIdentityRequest(request *AssumeRoleWithWebIdentityRequest) error {
|
||||
if request.RoleArn == "" {
|
||||
return fmt.Errorf("RoleArn is required")
|
||||
}
|
||||
|
||||
if request.WebIdentityToken == "" {
|
||||
return fmt.Errorf("WebIdentityToken is required")
|
||||
}
|
||||
@@ -688,7 +848,7 @@ func (s *STSService) validateAssumeRoleWithWebIdentityRequest(request *AssumeRol
|
||||
// validateWebIdentityToken validates the web identity token with strict issuer-to-provider mapping
|
||||
// SECURITY: JWT tokens with a specific issuer claim MUST only be validated by the provider for that issuer
|
||||
// SECURITY: This method only accepts JWT tokens. Non-JWT authentication must use AssumeRoleWithCredentials with explicit ProviderName.
|
||||
func (s *STSService) validateWebIdentityToken(ctx context.Context, token string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
|
||||
func (s *STSService) validateWebIdentityToken(ctx context.Context, token, roleAccountID string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
|
||||
// Try to extract issuer from JWT token for strict validation
|
||||
issuer, err := s.extractIssuerFromJWT(token)
|
||||
if err != nil {
|
||||
@@ -699,8 +859,12 @@ func (s *STSService) validateWebIdentityToken(ctx context.Context, token string)
|
||||
return nil, nil, fmt.Errorf("web identity token must be a valid JWT token: %w", err)
|
||||
}
|
||||
|
||||
// Look up the specific provider for this issuer
|
||||
provider, exists := s.issuerToProvider[issuer]
|
||||
// Look up the specific provider for this issuer, scoped to the role's
|
||||
// account when known. IAM-managed records (admin-controlled, mutable at
|
||||
// runtime) take precedence over the static-config map so an operator's
|
||||
// CreateOpenIDConnectProvider call can shadow a bootstrap entry without
|
||||
// requiring a restart.
|
||||
provider, exists := s.lookupOIDCProviderForAccount(issuer, roleAccountID)
|
||||
if !exists {
|
||||
// SECURITY: If no provider is registered for this issuer, fail immediately
|
||||
// This prevents JWT tokens from being validated by unintended providers
|
||||
@@ -735,9 +899,19 @@ func (s *STSService) validateWebIdentityToken(ctx context.Context, token string)
|
||||
}
|
||||
|
||||
// ValidateWebIdentityToken is a public method that exposes secure token validation for external use
|
||||
// This method uses issuer-based lookup to select the correct provider, ensuring security and efficiency
|
||||
// This method uses issuer-based lookup to select the correct provider, ensuring security and efficiency.
|
||||
// External callers without role context get the account-agnostic lookup (global IAM-managed records
|
||||
// only, then static-config); call ValidateWebIdentityTokenForAccount when the assumed-role account
|
||||
// is known.
|
||||
func (s *STSService) ValidateWebIdentityToken(ctx context.Context, token string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
|
||||
return s.validateWebIdentityToken(ctx, token)
|
||||
return s.validateWebIdentityToken(ctx, token, "")
|
||||
}
|
||||
|
||||
// ValidateWebIdentityTokenForAccount mirrors ValidateWebIdentityToken but
|
||||
// scopes the IAM-managed provider lookup to roleAccountID. Pass "" for
|
||||
// callers that don't yet know the account (e.g. claim-based mode).
|
||||
func (s *STSService) ValidateWebIdentityTokenForAccount(ctx context.Context, token, roleAccountID string) (*providers.ExternalIdentity, providers.IdentityProvider, error) {
|
||||
return s.validateWebIdentityToken(ctx, token, roleAccountID)
|
||||
}
|
||||
|
||||
// extractIssuerFromJWT extracts the issuer (iss) claim from a JWT token without verification
|
||||
|
||||
@@ -193,7 +193,7 @@ func (iama *IamS3ApiConfigure) PutS3ApiConfigurationToFiler(s3cfg *iam_pb.S3ApiC
|
||||
}
|
||||
return pb.WithOneOfGrpcFilerClients(false, iama.option.Filers, iama.option.GrpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
err = util.Retry("saveIamIdentity", func() error {
|
||||
return filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamIdentityFile, buf.Bytes())
|
||||
return filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory, filer.IamIdentityFile, buf.Bytes())
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -229,7 +229,7 @@ func (iama *IamS3ApiConfigure) PutPolicies(policies *Policies) (err error) {
|
||||
return err
|
||||
}
|
||||
return pb.WithOneOfGrpcFilerClients(false, iama.option.Filers, iama.option.GrpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
|
||||
if err := filer.SaveInsideFiler(client, filer.IamConfigDirectory, filer.IamPoliciesFile, b); err != nil {
|
||||
if err := filer.SaveInsideFiler(context.Background(), client, filer.IamConfigDirectory, filer.IamPoliciesFile, b); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -86,6 +86,24 @@ func (list *ChunkWrittenIntervalList) WrittenSize() (writtenByteCount int64) {
|
||||
return
|
||||
}
|
||||
|
||||
// IsContiguouslyWritten reports whether the written bytes form one
|
||||
// unbroken run starting at offset 0. Pressure-driven sealing uses this
|
||||
// to avoid racing in-flight FUSE writeback on a gap range — sealing a
|
||||
// gappy chunk would emit volume chunks with no coverage for the gap and
|
||||
// reads would silently zero-fill it (issue #9330).
|
||||
func (list *ChunkWrittenIntervalList) IsContiguouslyWritten() bool {
|
||||
first := list.head.next
|
||||
if first == list.tail || first.StartOffset != 0 {
|
||||
return false
|
||||
}
|
||||
for t := first; t.next != list.tail; t = t.next {
|
||||
if t.stopOffset != t.next.StartOffset {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (list *ChunkWrittenIntervalList) addInterval(interval *ChunkWrittenInterval) {
|
||||
|
||||
//t := list.head
|
||||
|
||||
@@ -81,6 +81,37 @@ func hasData(usage *ChunkWrittenIntervalList, chunkStartOffset, x int64) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func TestIsContiguouslyWritten(t *testing.T) {
|
||||
const chunkSize int64 = 2 * 1024 * 1024
|
||||
cases := []struct {
|
||||
name string
|
||||
writes [][2]int64
|
||||
expected bool
|
||||
}{
|
||||
{name: "empty", writes: nil, expected: false},
|
||||
{name: "single full", writes: [][2]int64{{0, chunkSize}}, expected: true},
|
||||
{name: "single partial at start", writes: [][2]int64{{0, chunkSize / 4}}, expected: true},
|
||||
{name: "two adjacent halves in order", writes: [][2]int64{{0, chunkSize / 2}, {chunkSize / 2, chunkSize}}, expected: true},
|
||||
{name: "two adjacent halves out of order", writes: [][2]int64{{chunkSize / 2, chunkSize}, {0, chunkSize / 2}}, expected: true},
|
||||
{name: "overlapping covers everything", writes: [][2]int64{{0, chunkSize*3/4 + 1}, {chunkSize / 2, chunkSize}}, expected: true},
|
||||
{name: "three adjacent thirds", writes: [][2]int64{{0, chunkSize / 3}, {chunkSize / 3, 2 * chunkSize / 3}, {2 * chunkSize / 3, chunkSize}}, expected: true},
|
||||
{name: "single partial in middle", writes: [][2]int64{{chunkSize / 4, chunkSize / 2}}, expected: false},
|
||||
{name: "first byte missing", writes: [][2]int64{{1, chunkSize}}, expected: false},
|
||||
{name: "gap in middle", writes: [][2]int64{{0, chunkSize / 4}, {chunkSize / 2, chunkSize}}, expected: false},
|
||||
{name: "two intervals with 1-byte gap", writes: [][2]int64{{0, 100}, {101, 200}}, expected: false},
|
||||
{name: "three intervals with middle gap", writes: [][2]int64{{0, chunkSize / 4}, {chunkSize / 3, chunkSize / 2}, {chunkSize / 2, chunkSize}}, expected: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
list := newChunkWrittenIntervalList()
|
||||
for i, w := range tc.writes {
|
||||
list.MarkWritten(w[0], w[1], int64(i+1))
|
||||
}
|
||||
assert.Equal(t, tc.expected, list.IsContiguouslyWritten(), "IsContiguouslyWritten mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsComplete_AdjacentIntervals(t *testing.T) {
|
||||
// Linux FUSE delivers writes up to FUSE_MAX_PAGES_PER_REQ
|
||||
// (typically 1 MiB) per op, so a 2 MiB chunk filled by sequential
|
||||
|
||||
@@ -11,6 +11,7 @@ type PageChunk interface {
|
||||
WriteDataAt(src []byte, offset int64, tsNs int64) (n int)
|
||||
ReadDataAt(p []byte, off int64, tsNs int64) (maxStop int64)
|
||||
IsComplete() bool
|
||||
IsContiguouslyWritten() bool
|
||||
ActivityScore() int64
|
||||
WrittenSize() int64
|
||||
LastWriteTsNs() int64
|
||||
|
||||
@@ -81,6 +81,13 @@ func (mc *MemChunk) IsComplete() bool {
|
||||
return mc.usage.IsComplete(mc.chunkSize)
|
||||
}
|
||||
|
||||
func (mc *MemChunk) IsContiguouslyWritten() bool {
|
||||
mc.RLock()
|
||||
defer mc.RUnlock()
|
||||
|
||||
return mc.usage.IsContiguouslyWritten()
|
||||
}
|
||||
|
||||
func (mc *MemChunk) ActivityScore() int64 {
|
||||
return mc.activityScore.ActivityScore()
|
||||
}
|
||||
|
||||
@@ -172,6 +172,12 @@ func (sc *SwapFileChunk) IsComplete() bool {
|
||||
return sc.usage.IsComplete(sc.swapfile.chunkSize)
|
||||
}
|
||||
|
||||
func (sc *SwapFileChunk) IsContiguouslyWritten() bool {
|
||||
sc.RLock()
|
||||
defer sc.RUnlock()
|
||||
return sc.usage.IsContiguouslyWritten()
|
||||
}
|
||||
|
||||
func (sc *SwapFileChunk) ActivityScore() int64 {
|
||||
return sc.activityScore.ActivityScore()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package page_writer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
@@ -118,34 +119,23 @@ func (up *UploadPipeline) SaveDataAt(p []byte, off int64, isSequential bool, tsN
|
||||
}
|
||||
if !found {
|
||||
if len(up.writableChunks) > up.writableChunkLimit {
|
||||
// if current file chunks is over the per file buffer count limit
|
||||
// Per-pipeline soft cap. Seal the fullest gap-free chunk;
|
||||
// gappy chunks must wait so in-flight FUSE writeback can fill
|
||||
// the gap (issue #9330). If none qualifies, fall through —
|
||||
// the memChunkCounter ceiling below redirects to swap.
|
||||
candidateChunkIndex, fullness := LogicChunkIndex(-1), int64(0)
|
||||
for lci, mc := range up.writableChunks {
|
||||
chunkFullness := mc.WrittenSize()
|
||||
if fullness < chunkFullness {
|
||||
if !mc.IsContiguouslyWritten() {
|
||||
continue
|
||||
}
|
||||
if b := mc.WrittenSize(); b > fullness {
|
||||
candidateChunkIndex = lci
|
||||
fullness = chunkFullness
|
||||
fullness = b
|
||||
}
|
||||
}
|
||||
/* // this algo generates too many chunks
|
||||
candidateChunkIndex, lowestActivityScore := LogicChunkIndex(-1), int64(math.MaxInt64)
|
||||
for wci, wc := range up.writableChunks {
|
||||
activityScore := wc.ActivityScore()
|
||||
if lowestActivityScore >= activityScore {
|
||||
if lowestActivityScore == activityScore {
|
||||
chunkFullness := wc.WrittenSize()
|
||||
if fullness < chunkFullness {
|
||||
candidateChunkIndex = lci
|
||||
fullness = chunkFullness
|
||||
}
|
||||
}
|
||||
candidateChunkIndex = wci
|
||||
lowestActivityScore = activityScore
|
||||
}
|
||||
if candidateChunkIndex >= 0 {
|
||||
up.moveToSealed(up.writableChunks[candidateChunkIndex], candidateChunkIndex)
|
||||
}
|
||||
*/
|
||||
up.moveToSealed(up.writableChunks[candidateChunkIndex], candidateChunkIndex)
|
||||
// fmt.Printf("flush chunk %d with %d bytes written\n", logicChunkIndex, fullness)
|
||||
}
|
||||
// fmt.Printf("isSequential:%v len(up.writableChunks):%v memChunkCounter:%v", isSequential, len(up.writableChunks), memChunkCounter)
|
||||
if isSequential &&
|
||||
@@ -270,28 +260,52 @@ func (up *UploadPipeline) moveToSealed(memChunk PageChunk, logicChunkIndex Logic
|
||||
up.chunksLock.Lock()
|
||||
}
|
||||
|
||||
// EvictOneWritableChunk force-seals the fullest writable chunk in this
|
||||
// pipeline, submitting it for async upload. Called by the accountant's
|
||||
// evictor when Reserve would block. Returns true if a chunk was sealed.
|
||||
// The fullest-chunk heuristic matches the over-limit path in SaveDataAt:
|
||||
// sealing the chunk closest to full maximizes the upload's usefulness
|
||||
// and avoids thrashing on repeatedly re-creating the same half-empty
|
||||
// chunk. Callers must not hold up.chunksLock.
|
||||
// EvictOneWritableChunk force-seals one writable chunk so the accountant's
|
||||
// Reserve loop can make progress. Strict pass picks the fullest gap-free
|
||||
// chunk (issue #9330). Fallback picks the oldest non-empty writer when
|
||||
// nothing is gap-free; without it Reserve deadlocks (cond.Wait only wakes
|
||||
// on Release, Release only fires on upload completion). Callers must not
|
||||
// hold up.chunksLock.
|
||||
func (up *UploadPipeline) EvictOneWritableChunk() bool {
|
||||
up.chunksLock.Lock()
|
||||
defer up.chunksLock.Unlock()
|
||||
if len(up.writableChunks) == 0 {
|
||||
return false
|
||||
}
|
||||
var bestIndex LogicChunkIndex
|
||||
var bestBytes int64 = -1
|
||||
|
||||
bestIndex := LogicChunkIndex(-1)
|
||||
var bestBytes int64
|
||||
for lci, wc := range up.writableChunks {
|
||||
if !wc.IsContiguouslyWritten() {
|
||||
continue
|
||||
}
|
||||
if b := wc.WrittenSize(); b > bestBytes {
|
||||
bestIndex = lci
|
||||
bestBytes = b
|
||||
}
|
||||
}
|
||||
if bestBytes < 0 {
|
||||
|
||||
if bestIndex < 0 {
|
||||
oldestTsNs := int64(math.MaxInt64)
|
||||
var fallbackBytes int64
|
||||
for lci, wc := range up.writableChunks {
|
||||
b := wc.WrittenSize()
|
||||
if b == 0 {
|
||||
continue
|
||||
}
|
||||
ts := wc.LastWriteTsNs()
|
||||
if ts == 0 {
|
||||
continue
|
||||
}
|
||||
if ts < oldestTsNs || (ts == oldestTsNs && b > fallbackBytes) {
|
||||
bestIndex = lci
|
||||
oldestTsNs = ts
|
||||
fallbackBytes = b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestIndex < 0 {
|
||||
return false
|
||||
}
|
||||
up.moveToSealed(up.writableChunks[bestIndex], bestIndex)
|
||||
@@ -330,6 +344,13 @@ func (up *UploadPipeline) ProactiveFlush(nowNs int64, idleThresholdNs int64, max
|
||||
if age < idleThresholdNs {
|
||||
continue
|
||||
}
|
||||
// Skip gappy chunks; FUSE writeback may still be in flight for the
|
||||
// hole and SaveContent would bake it into split volume chunks
|
||||
// (issue #9330). Failing here is just a missed flush — FlushAll
|
||||
// at close still seals everything.
|
||||
if !chunk.IsContiguouslyWritten() {
|
||||
continue
|
||||
}
|
||||
written := chunk.WrittenSize()
|
||||
nearlyFull := written >= fillRatio
|
||||
behindFrontier := isSeq && int64(lci) <= frontier-int64(frontierLag)
|
||||
|
||||
@@ -46,3 +46,215 @@ func confirmRange(t *testing.T, uploadPipeline *UploadPipeline, startOff, stopOf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pressure-driven eviction must not seal a chunk with a leading or
|
||||
// internal gap (issue #9330).
|
||||
func TestEvictOneWritableChunk_SkipsGappyChunks(t *testing.T) {
|
||||
const cs int64 = 2 * 1024 * 1024
|
||||
// nil saveToStorage so the async upload SaveContent is a no-op.
|
||||
up := NewUploadPipeline(util.NewLimitedConcurrentExecutor(2), cs, nil, 16, "", nil)
|
||||
|
||||
block := make([]byte, cs/4)
|
||||
|
||||
// chunk 0: internal gap; chunk 1: leading gap; chunk 2: contiguous from 0.
|
||||
if _, err := up.SaveDataAt(block, 0, true, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, 3*cs/4, true, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs+cs/4, true, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs+cs/2, true, 4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, 2*cs, true, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, 2*cs+cs/4, true, 6); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !up.EvictOneWritableChunk() {
|
||||
t.Fatalf("EvictOneWritableChunk returned false; expected contiguous chunk 2 to be evictable")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(2)]; stillWritable {
|
||||
t.Errorf("chunk 2 should have moved to sealed")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(0)]; !stillWritable {
|
||||
t.Errorf("chunk 0 (internal gap) must remain writable")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(1)]; !stillWritable {
|
||||
t.Errorf("chunk 1 (leading gap) must remain writable")
|
||||
}
|
||||
|
||||
// Filling holes makes IsComplete true; maybeMoveToSealed auto-seals.
|
||||
if _, err := up.SaveDataAt(block, cs/4, true, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs/2, true, 8); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs, true, 9); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs+3*cs/4, true, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(0)]; stillWritable {
|
||||
t.Errorf("chunk 0 should have auto-sealed after gap was filled")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(1)]; stillWritable {
|
||||
t.Errorf("chunk 1 should have auto-sealed after leading range was filled")
|
||||
}
|
||||
}
|
||||
|
||||
// When every chunk is gappy, the fallback must still seal one so
|
||||
// accountant.Reserve can wake; oldest-LastWriteTsNs wins.
|
||||
func TestEvictOneWritableChunk_FallbackPicksOldestGappy(t *testing.T) {
|
||||
const cs int64 = 2 * 1024 * 1024
|
||||
up := NewUploadPipeline(util.NewLimitedConcurrentExecutor(2), cs, nil, 16, "", nil)
|
||||
|
||||
block := make([]byte, cs/4)
|
||||
|
||||
// Strictly increasing tsNs so chunk 0 is oldest; equal WrittenSize.
|
||||
if _, err := up.SaveDataAt(block, 0, true, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, 3*cs/4, true, 101); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs+cs/4, true, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs+cs/2, true, 201); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, 2*cs, true, 300); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, 2*cs+3*cs/4, true, 301); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !up.EvictOneWritableChunk() {
|
||||
t.Fatalf("fallback must seal a gappy chunk to free a Reserve slot")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(0)]; stillWritable {
|
||||
t.Errorf("oldest gappy chunk (0) should have been picked by the fallback")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(1)]; !stillWritable {
|
||||
t.Errorf("chunk 1 should remain writable")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(2)]; !stillWritable {
|
||||
t.Errorf("chunk 2 should remain writable")
|
||||
}
|
||||
}
|
||||
|
||||
// Strict pass preempts the fallback even when a gappy chunk is older.
|
||||
func TestEvictOneWritableChunk_PrefersStrictOverFallback(t *testing.T) {
|
||||
const cs int64 = 2 * 1024 * 1024
|
||||
up := NewUploadPipeline(util.NewLimitedConcurrentExecutor(2), cs, nil, 16, "", nil)
|
||||
|
||||
block := make([]byte, cs/4)
|
||||
|
||||
// chunk 0 (older, gappy), chunk 1 (newer, contiguous from 0).
|
||||
if _, err := up.SaveDataAt(block, 0, true, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, 3*cs/4, true, 101); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs, true, 200); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs+cs/4, true, 201); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !up.EvictOneWritableChunk() {
|
||||
t.Fatalf("EvictOneWritableChunk returned false")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(1)]; stillWritable {
|
||||
t.Errorf("contiguous chunk 1 must be picked over older gappy chunk 0")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(0)]; !stillWritable {
|
||||
t.Errorf("gappy chunk 0 must remain writable")
|
||||
}
|
||||
}
|
||||
|
||||
// ProactiveFlush also skips gappy chunks (issue #9330). No liveness
|
||||
// fallback here — unlike EvictOneWritableChunk, returning false is just
|
||||
// a missed optimization.
|
||||
func TestProactiveFlush_SkipsGappyChunks(t *testing.T) {
|
||||
const cs int64 = 2 * 1024 * 1024
|
||||
up := NewUploadPipeline(util.NewLimitedConcurrentExecutor(2), cs, nil, 16, "", nil)
|
||||
|
||||
block := make([]byte, cs/4)
|
||||
|
||||
// chunk 0: internal gap; chunk 1: leading gap; chunk 2: contiguous from 0.
|
||||
if _, err := up.SaveDataAt(block, 0, true, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, 3*cs/4, true, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs+cs/4, true, 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs+cs/2, true, 4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, 2*cs, true, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, 2*cs+cs/4, true, 6); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// nowNs >> chunk timestamps so age clears the idle/maxHold gates;
|
||||
// fillRatio < WrittenSize so nearlyFull is true. Leaves
|
||||
// IsContiguouslyWritten as the only differentiator.
|
||||
const (
|
||||
nowNs int64 = 1_000_000_000
|
||||
idleThresholdNs int64 = 100
|
||||
maxHoldNs int64 = 200
|
||||
fillRatio int64 = cs / 8
|
||||
)
|
||||
if !up.ProactiveFlush(nowNs, idleThresholdNs, maxHoldNs, fillRatio, 0, false) {
|
||||
t.Fatalf("ProactiveFlush returned false; expected contiguous chunk 2 to be sealed")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(2)]; stillWritable {
|
||||
t.Errorf("chunk 2 should have moved to sealed")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(0)]; !stillWritable {
|
||||
t.Errorf("chunk 0 (internal gap) must remain writable")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(1)]; !stillWritable {
|
||||
t.Errorf("chunk 1 (leading gap) must remain writable")
|
||||
}
|
||||
|
||||
if up.ProactiveFlush(nowNs, idleThresholdNs, maxHoldNs, fillRatio, 0, false) {
|
||||
t.Errorf("ProactiveFlush returned true with only gappy chunks left")
|
||||
}
|
||||
|
||||
if _, err := up.SaveDataAt(block, cs/4, true, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs/2, true, 8); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs, true, 9); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := up.SaveDataAt(block, cs+3*cs/4, true, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(0)]; stillWritable {
|
||||
t.Errorf("chunk 0 should have auto-sealed after gap was filled")
|
||||
}
|
||||
if _, stillWritable := up.writableChunks[LogicChunkIndex(1)]; stillWritable {
|
||||
t.Errorf("chunk 1 should have auto-sealed after leading range was filled")
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user