mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-21 14:02:35 +00:00
Compare commits
58
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 |
@@ -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
|
||||
|
||||
@@ -662,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.
|
||||
|
||||
|
||||
|
||||
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 \
|
||||
|
||||
@@ -113,7 +113,7 @@ 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
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
@@ -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=
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"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"
|
||||
@@ -25,15 +27,84 @@ const maxPoliciesForEvaluation = 1024
|
||||
|
||||
// IAMManager orchestrates all IAM components
|
||||
type IAMManager struct {
|
||||
stsService *sts.STSService
|
||||
policyEngine *policy.PolicyEngine
|
||||
roleStore RoleStore
|
||||
userStore UserStore
|
||||
oidcProviderStore OIDCProviderStore
|
||||
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
|
||||
@@ -67,6 +138,224 @@ func (m *IAMManager) ListOIDCProviders(ctx context.Context) ([]*OIDCProviderReco
|
||||
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
|
||||
type IAMConfig struct {
|
||||
// STS service configuration
|
||||
@@ -296,12 +585,15 @@ func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
|
||||
createdAt = existing.CreatedAt
|
||||
}
|
||||
rec := &OIDCProviderRecord{
|
||||
AccountID: accountID,
|
||||
ARN: arn,
|
||||
URL: issuer,
|
||||
ClientIDs: clientIDs,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: now,
|
||||
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)
|
||||
@@ -310,6 +602,73 @@ func (m *IAMManager) initOIDCProviderStore(config *IAMConfig) error {
|
||||
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) {
|
||||
@@ -345,6 +704,39 @@ func extractClientIDs(cfg map[string]interface{}) []string {
|
||||
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 {
|
||||
@@ -473,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)
|
||||
|
||||
@@ -482,6 +882,14 @@ 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)
|
||||
@@ -496,6 +904,63 @@ func (m *IAMManager) AssumeRoleWithWebIdentity(ctx context.Context, request *sts
|
||||
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
|
||||
@@ -571,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,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)
|
||||
@@ -656,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
|
||||
@@ -664,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 {
|
||||
@@ -692,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)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,71 @@ func TestStaticConfigSeedsProviderStore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -20,6 +20,15 @@ import (
|
||||
"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
|
||||
@@ -72,6 +81,7 @@ 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
|
||||
}
|
||||
@@ -109,7 +119,7 @@ func (m *MemoryOIDCProviderStore) GetProviderByARN(ctx context.Context, _ string
|
||||
defer m.mu.RUnlock()
|
||||
rec, ok := m.providers[arn]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("OIDC provider not found: %s", arn)
|
||||
return nil, fmt.Errorf("%w: %s", ErrOIDCProviderNotFound, arn)
|
||||
}
|
||||
return copyOIDCProviderRecord(rec), nil
|
||||
}
|
||||
@@ -128,6 +138,29 @@ func (m *MemoryOIDCProviderStore) GetProviderByIssuer(ctx context.Context, _ str
|
||||
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()
|
||||
@@ -239,7 +272,7 @@ func (f *FilerOIDCProviderStore) GetProviderByARN(ctx context.Context, filerAddr
|
||||
Name: f.fileName(arn),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("OIDC provider not found: %v", err)
|
||||
return fmt.Errorf("%w: %v", ErrOIDCProviderNotFound, err)
|
||||
}
|
||||
if resp.Entry == nil {
|
||||
return fmt.Errorf("OIDC provider not found: %s", arn)
|
||||
@@ -274,6 +307,25 @@ func (f *FilerOIDCProviderStore) GetProviderByIssuer(ctx context.Context, filerA
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+288
-19
@@ -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"
|
||||
@@ -48,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"`
|
||||
@@ -72,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"`
|
||||
|
||||
@@ -80,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"`
|
||||
@@ -154,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) {
|
||||
@@ -193,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")
|
||||
}
|
||||
|
||||
@@ -205,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 {
|
||||
@@ -275,13 +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,
|
||||
Issuer: claims.Issuer,
|
||||
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
|
||||
@@ -432,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,6 +54,17 @@ type ExternalIdentity struct {
|
||||
// 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"`
|
||||
|
||||
@@ -46,6 +46,52 @@ type GetOpenIDConnectProviderResponse struct {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,14 @@ func TestComputeParentUserEmptySub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// 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 == "" {
|
||||
if sub == "" || iss == "" {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256([]byte("openid:" + sub + ":" + iss))
|
||||
@@ -79,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,
|
||||
|
||||
+179
-21
@@ -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.
|
||||
@@ -424,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
|
||||
@@ -454,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)
|
||||
@@ -482,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,
|
||||
}
|
||||
|
||||
@@ -519,13 +650,25 @@ func (s *STSService) AssumeRoleWithWebIdentity(ctx context.Context, request *Ass
|
||||
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).
|
||||
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)
|
||||
}
|
||||
@@ -677,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")
|
||||
}
|
||||
@@ -704,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 {
|
||||
@@ -715,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
|
||||
@@ -751,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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,11 +201,9 @@ func (wfs *WFS) flushMetadataToFiler(fh *FileHandle, dir, name string, uid, gid
|
||||
if entry.Attributes.Gid == 0 {
|
||||
entry.Attributes.Gid = gid
|
||||
}
|
||||
flushNow := time.Now()
|
||||
entry.Attributes.Mtime = flushNow.Unix()
|
||||
entry.Attributes.MtimeNs = int32(flushNow.Nanosecond())
|
||||
entry.Attributes.Ctime = flushNow.Unix()
|
||||
entry.Attributes.CtimeNs = int32(flushNow.Nanosecond())
|
||||
// Do not stamp mtime/ctime here. Write/SetAttr already maintain
|
||||
// them on the entry; overwriting at flush time clobbered user-set
|
||||
// mtime (utimes/touch -m -d) once the deferred flush ran.
|
||||
}
|
||||
|
||||
glog.V(4).Infof("%s set chunks: %v", fileFullPath, len(entry.GetChunks()))
|
||||
|
||||
@@ -66,6 +66,15 @@ func (wfs *WFS) Write(cancel <-chan struct{}, in *fuse.WriteIn, data []byte) (wr
|
||||
newFileSize := max(offset+int64(len(data)), oldFileSize)
|
||||
entry.Attributes.FileSize = uint64(newFileSize)
|
||||
|
||||
// POSIX: writes update mtime and ctime. Stamp the entry now so the
|
||||
// flush path persists the write time rather than overwriting any
|
||||
// user-set mtime (e.g., utimes/touch -m -d) at flush time.
|
||||
writeNow := time.Unix(0, tsNs)
|
||||
entry.Attributes.Mtime = writeNow.Unix()
|
||||
entry.Attributes.MtimeNs = int32(writeNow.Nanosecond())
|
||||
entry.Attributes.Ctime = writeNow.Unix()
|
||||
entry.Attributes.CtimeNs = int32(writeNow.Nanosecond())
|
||||
|
||||
// Track uncommitted bytes for real-time quota enforcement.
|
||||
// Only count the new bytes being added beyond the current file size.
|
||||
if newFileSize > oldFileSize {
|
||||
|
||||
@@ -110,13 +110,9 @@ func (wfs *WFS) flushFileMetadata(fh *FileHandle) error {
|
||||
}
|
||||
entry.Name = name
|
||||
|
||||
if entry.Attributes != nil {
|
||||
metaNow := time.Now()
|
||||
entry.Attributes.Mtime = metaNow.Unix()
|
||||
entry.Attributes.MtimeNs = int32(metaNow.Nanosecond())
|
||||
entry.Attributes.Ctime = metaNow.Unix()
|
||||
entry.Attributes.CtimeNs = int32(metaNow.Nanosecond())
|
||||
}
|
||||
// Do not stamp mtime/ctime here. Write/SetAttr already maintain
|
||||
// them on the entry; overwriting at periodic-flush time clobbered
|
||||
// user-set mtime (utimes/touch -m -d) once the timer fired.
|
||||
|
||||
// Get current chunks - these include chunks that have been uploaded
|
||||
// but not yet persisted to filer metadata
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package mount
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
// TestFlushFileMetadataPreservesUserMtime is a regression test for issue #9363.
|
||||
//
|
||||
// Before the fix, flushFileMetadata stamped entry.Attributes.Mtime/Ctime with
|
||||
// time.Now() on every flush, clobbering the value SetAttr stored on the entry
|
||||
// when the user ran utimes()/touch -m -d while a file handle was still open.
|
||||
//
|
||||
// The flush has no business inventing timestamps: Write and SetAttr already
|
||||
// maintain mtime/ctime on the entry, and the flush should just persist them.
|
||||
//
|
||||
// The test sets a user-chosen mtime far in the past, runs flushFileMetadata
|
||||
// with no chunks (so it returns before the streamCreateEntry RPC), and asserts
|
||||
// the entry's mtime is unchanged.
|
||||
func TestFlushFileMetadataPreservesUserMtime(t *testing.T) {
|
||||
wfs := &WFS{
|
||||
inodeToPath: NewInodeToPath(util.FullPath("/"), 0),
|
||||
fhLockTable: util.NewLockTable[FileHandleId](),
|
||||
option: &Option{},
|
||||
}
|
||||
|
||||
const inode = uint64(42)
|
||||
fullPath := util.FullPath("/dir/sample.txt")
|
||||
wfs.inodeToPath.Lookup(fullPath, 1, false, false, inode, true)
|
||||
|
||||
// Use a non-zero nanosecond so the *Ns assertions below catch a regression
|
||||
// that zeroes the field instead of preserving it.
|
||||
userMtime := time.Date(2020, 1, 15, 12, 34, 56, 123456789, time.UTC)
|
||||
entry := &filer_pb.Entry{
|
||||
Name: "sample.txt",
|
||||
Attributes: &filer_pb.FuseAttributes{
|
||||
Mtime: userMtime.Unix(),
|
||||
MtimeNs: int32(userMtime.Nanosecond()),
|
||||
Ctime: userMtime.Unix(),
|
||||
CtimeNs: int32(userMtime.Nanosecond()),
|
||||
},
|
||||
}
|
||||
fh := &FileHandle{
|
||||
fh: FileHandleId(1),
|
||||
inode: inode,
|
||||
wfs: wfs,
|
||||
entry: &LockedEntry{Entry: entry},
|
||||
dirtyMetadata: true,
|
||||
}
|
||||
|
||||
if err := wfs.flushFileMetadata(fh); err != nil {
|
||||
t.Fatalf("flushFileMetadata returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := entry.Attributes.Mtime; got != userMtime.Unix() {
|
||||
t.Errorf("mtime sec changed: got %d, want %d", got, userMtime.Unix())
|
||||
}
|
||||
if got := entry.Attributes.MtimeNs; got != int32(userMtime.Nanosecond()) {
|
||||
t.Errorf("mtime ns changed: got %d, want %d", got, userMtime.Nanosecond())
|
||||
}
|
||||
if got := entry.Attributes.Ctime; got != userMtime.Unix() {
|
||||
t.Errorf("ctime sec changed: got %d, want %d", got, userMtime.Unix())
|
||||
}
|
||||
if got := entry.Attributes.CtimeNs; got != int32(userMtime.Nanosecond()) {
|
||||
t.Errorf("ctime ns changed: got %d, want %d", got, userMtime.Nanosecond())
|
||||
}
|
||||
}
|
||||
@@ -662,7 +662,7 @@ func (cr *CoordinatorRegistry) saveCoordinatorAssignment(consumerGroup string, a
|
||||
|
||||
// Save to individual file: /topics/kafka/.meta/coordinators/<consumer-group>_assignments.json
|
||||
fileName := fmt.Sprintf("%s_assignments.json", consumerGroup)
|
||||
return filer.SaveInsideFiler(client, CoordinatorAssignmentsDir, fileName, assignmentData)
|
||||
return filer.SaveInsideFiler(context.Background(), client, CoordinatorAssignmentsDir, fileName, assignmentData)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ func (f *FilerConsumerGroupOffsetStorage) SaveConsumerGroupPosition(t topic.Topi
|
||||
}
|
||||
|
||||
return f.filerClientAccessor.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
return filer.SaveInsideFiler(client, consumersDir, offsetFileName, jsonBytes)
|
||||
return filer.SaveInsideFiler(context.Background(), client, consumersDir, offsetFileName, jsonBytes)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ func (f *FilerOffsetStorage) SaveCheckpoint(namespace, topicName string, partiti
|
||||
util.Uint64toBytes(offsetBytes, uint64(offset))
|
||||
|
||||
return f.filerClientAccessor.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
return filer.SaveInsideFiler(client, partitionDir, fileName, offsetBytes)
|
||||
return filer.SaveInsideFiler(context.Background(), client, partitionDir, fileName, offsetBytes)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ func (t Topic) ReadConfFileWithMetadata(client filer_pb.SeaweedFilerClient) (*mq
|
||||
func (t Topic) WriteConfFile(client filer_pb.SeaweedFilerClient, conf *mq_pb.ConfigureTopicResponse) error {
|
||||
var buf bytes.Buffer
|
||||
filer.ProtoToText(&buf, conf)
|
||||
if err := filer.SaveInsideFiler(client, t.Dir(), filer.TopicConfFile, buf.Bytes()); err != nil {
|
||||
if err := filer.SaveInsideFiler(context.Background(), client, t.Dir(), filer.TopicConfFile, buf.Bytes()); err != nil {
|
||||
return fmt.Errorf("save topic %v conf: %w", t, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -12,6 +12,8 @@ gen:
|
||||
mkdir -p ./mount_peer_pb
|
||||
protoc mount_peer.proto --go_out=./mount_peer_pb --go-grpc_out=./mount_peer_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
|
||||
protoc s3.proto --go_out=./s3_pb --go-grpc_out=./s3_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
|
||||
mkdir -p ./s3_lifecycle_pb
|
||||
protoc s3_lifecycle.proto --go_out=./s3_lifecycle_pb --go-grpc_out=./s3_lifecycle_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
|
||||
protoc mq_broker.proto --go_out=./mq_pb --go-grpc_out=./mq_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
|
||||
protoc mq_schema.proto --go_out=./schema_pb --go-grpc_out=./schema_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
|
||||
protoc mq_agent.proto --go_out=./mq_agent_pb --go-grpc_out=./mq_agent_pb --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative
|
||||
|
||||
@@ -225,7 +225,7 @@ message ValueMap {
|
||||
|
||||
message AdminRuntimeDefaults {
|
||||
bool enabled = 1;
|
||||
int32 detection_interval_seconds = 2;
|
||||
int32 detection_interval_minutes = 2;
|
||||
int32 detection_timeout_seconds = 3;
|
||||
int32 max_jobs_per_detection = 4;
|
||||
int32 global_execution_concurrency = 5;
|
||||
@@ -241,7 +241,7 @@ message AdminRuntimeDefaults {
|
||||
|
||||
message AdminRuntimeConfig {
|
||||
bool enabled = 1;
|
||||
int32 detection_interval_seconds = 2;
|
||||
int32 detection_interval_minutes = 2;
|
||||
int32 detection_timeout_seconds = 3;
|
||||
int32 max_jobs_per_detection = 4;
|
||||
int32 global_execution_concurrency = 5;
|
||||
@@ -351,6 +351,7 @@ message ClusterContext {
|
||||
repeated string filer_grpc_addresses = 2;
|
||||
repeated string volume_grpc_addresses = 3;
|
||||
map<string, string> metadata = 4;
|
||||
repeated string s3_grpc_addresses = 5;
|
||||
}
|
||||
|
||||
message ActivityEvent {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user