Compare commits

..
Author SHA1 Message Date
Chris LuandCopilot 8500a3bc56 ci: remove s3tests skip - test will be fixed in s3-tests repo
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-16 03:23:36 -08:00
Chris LuandCopilot 678c52e74f ci: skip s3tests test expecting incorrect delete behavior
Skip test_object_lock_delete_object_with_retention_and_marker in s3tests because it expects SeaweedFS incorrect behavior (allowing delete under COMPLIANCE retention). SeaweedFS now correctly implements AWS S3 behavior: delete of objects under COMPLIANCE retention returns AccessDenied and does not create a delete marker.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-16 03:20:05 -08:00
Chris LuandCopilot 8fac0cdab8 test: fix retention tests to expect correct AWS S3 behavior
Tests were expecting SeaweedFS old (incorrect) behavior where DeleteObject succeeded under COMPLIANCE retention. Updated to expect correct AWS S3 behavior: simple DELETE (without versionId) is blocked by active COMPLIANCE/legal-hold, returning AccessDenied instead of creating a delete marker.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-16 02:57:31 -08:00
Chris LuandCopilot 09bfb28db9 s3api: simplify objectLockVersionToCheckForDelete to single return value
Since all branches now return true (fail-closed), eliminate the boolean return value and the dead-code if guards. The function now clearly indicates that object lock checks always execute, with no opt-out path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-16 02:08:32 -08:00
Chris LuandCopilot 3bd990d2ea s3api: address object lock delete review comments
- Remove versioningConfigured guard: object lock protections must apply to all buckets
- Combine identical switch cases for clarity
- Change default case to fail-closed (true) for safety
- Add test case for suspended versioning with specific versionId
- Update test expectations to reflect fail-closed default

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-16 02:00:30 -08:00
Chris LuandCopilot 5e040ba03d s3api: enforce object lock protections on delete (COMPLIANCE/GOVERNANCE)
Enforce object lock protections before creating delete markers; active COMPLIANCE retention denies deletes and active GOVERNANCE retention denies deletes unless bypass is authorized. Includes unit tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-16 01:52:08 -08:00
Chris LuandGitHub 703d5e27b3 Fix S3 ListObjectsV2 recursion issue (#8347)
* Fix S3 ListObjectsV2 recursion issue (#8346)

Removed aggressive Limit=1 optimization in doListFilerEntries that caused missed directory entries when prefix ended with a delimiter. Added regression tests to verify deep directory traversal.

* Address PR comments: condense test comments
2026-02-15 10:52:10 -08:00
Chris Lu e863767ac7 cleanup(iam): final removal of temporary debug logging from STS and S3 API 2026-02-14 22:15:06 -08:00
Chris Lu e29a7f1741 cleanup(iam): remove temporary debug logging from STS and S3 API (redo) 2026-02-14 22:14:33 -08:00
Chris LuandGitHub cf8e383e1e STS: Fallback to Caller Identity when RoleArn is missing in AssumeRole (#8345)
* s3api: make RoleArn optional in AssumeRole

* s3api: address PR feedback for optional RoleArn

* iam: add configurable default role for AssumeRole

* S3 STS: Use caller identity when RoleArn is missing

- Fallback to PrincipalArn/Context in AssumeRole if RoleArn is empty

- Handle User ARNs in prepareSTSCredentials

- Fix PrincipalArn generation for env var credentials

* Test: Add unit test for AssumeRole caller identity fallback

* fix(s3api): propagate admin permissions to assumed role session when using caller identity fallback

* STS: Fix is_admin propagation and optimize IAM policy evaluation for assumed roles

- Restore is_admin propagation via JWT req_ctx
- Optimize IsActionAllowed to skip role lookups for admin sessions
- Ensure session policies are still applied for downscoping
- Remove debug logging
- Fix syntax errors in cleanup

* fix(iam): resolve STS policy bypass for admin sessions

- Fixed IsActionAllowed in iam_manager.go to correctly identify and validate internal STS tokens, ensuring session policies are enforced.
- Refactored VerifyActionPermission in auth_credentials.go to properly handle session tokens and avoid legacy authorization short-circuits.
- Added debug logging for better tracing of policy evaluation and session validation.
2026-02-14 22:00:59 -08:00
f49f6c6876 FUSE mount: fix failed git clone (#8344)
tests: reset MemoryStore to avoid test pollution; fix port reservation to prevent duplicate ports in mini

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-14 00:28:20 -08:00
7799915e50 Fix IAM identity loss on S3 restart migration (#8343)
* Fix IAM reload after legacy config migration

Handle legacy identity.json metadata events by reloading from the credential manager instead of parsing event content, and watch the correct /etc/iam multi-file directories so identity changes are applied.

Add regression tests for legacy deletion and /etc/iam/identities change events.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix auth_credentials_subscribe_test helper to not pollute global memory store

The SaveConfiguration call was affecting other tests. Use local credential manager and ReplaceS3ApiConfiguration instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix IAM event watching: subscribe to IAM directories and improve directory matching

- Add /etc/iam and its subdirectories (identities, policies, service_accounts) to directoriesToWatch
- Fix directory matching to avoid false positives from sibling directories
  - Use exact match or prefix with trailing slash instead of plain HasPrefix
  - Prevents matching hypothetical /etc/iam/identities_backup directory

This ensures IAM config change events are actually delivered to the handler.

* fix tests

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-13 22:49:27 -08:00
Chris LuandGitHub c090604143 Add UpdateAccessKey support to IAM API (#8342)
* Add UpdateAccessKey support to IAM API

* simplify
2026-02-13 21:11:07 -08:00
Chris LuandGitHub f44e25b422 fix(iam): ensure access key status is persisted and defaulted to Active (#8341)
* Fix master leader election startup issue

Fixes #error-log-leader-not-selected-yet

* not useful test

* fix(iam): ensure access key status is persisted and defaulted to Active

* make pb

* update tests

* using constants
2026-02-13 20:28:41 -08:00
Lisandro PinandGitHub fbe7dd32c2 Implement full scrubbing for regular volumes (#8254)
Implement full scrubbing for regular volumes.
2026-02-13 15:47:29 -08:00
Lisandro PinandGitHub 1ebc9dd530 Have local EC volume scrubbing check needle integrity whenever possible. (#8334)
If local EC scrubbing hits needles whose chunk location reside entirely
in local shards, we can fully reconstruct them, and check CRCs for
data integrity.
2026-02-13 15:43:17 -08:00
Chris LuandGitHub b08bb8237c Fix master leader election startup issue (#8340)
* Fix master leader election startup issue

Fixes #error-log-leader-not-selected-yet

* Fix master leader election startup issue

This change improves server address comparison using the 'Equals' method and handles recursion in topology leader lookup, resolving the 'leader not selected yet' error during master startup.

* Merge user improvements: use MaybeLeader for non-blocking checks

* not useful test

* Address code review: optimize Equals, fix deadlock in IsLeader, safe access in Leader
2026-02-13 15:39:39 -08:00
Chris Lu f1bf60d288 faster 2026-02-13 14:09:30 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
35b6e895cc build(deps): bump org.apache.avro:avro from 1.11.4 to 1.11.5 in /test/kafka/kafka-client-loadtest/tools (#8339)
build(deps): bump org.apache.avro:avro

Bumps org.apache.avro:avro from 1.11.4 to 1.11.5.

---
updated-dependencies:
- dependency-name: org.apache.avro:avro
  dependency-version: 1.11.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 13:58:42 -08:00
Chris LuandGitHub 49a64f50f1 Add session policy support to IAM (#8338)
* Add session policy support to IAM

- Implement policy evaluation for session tokens in policy_engine.go
- Add session_policy field to session claims for tracking applied policies
- Update STS service to include session policies in token generation
- Add IAM integration tests for session policy validation
- Update IAM manager to support policy attachment to sessions
- Extend S3 API STS endpoint to handle session policy restrictions

* fix: optimize session policy evaluation and add documentation

* sts: add NormalizeSessionPolicy helper for inline session policies

* sts: support inline session policies for AssumeRoleWithWebIdentity and credential-based flows

* s3api: parse and normalize Policy parameter for STS HTTP handlers

* tests: add session policy unit tests and integration tests for inline policy downscoping

* tests: add s3tables STS inline policy integration

* iam: handle user principals and validate tokens

* sts: enforce inline session policy size limit

* tests: harden s3tables STS integration config

* iam: clarify principal policy resolution errors

* tests: improve STS integration endpoint selection
2026-02-13 13:58:22 -08:00
Chris LuandGitHub beeb375a88 Add volume server integration test suite and CI workflow (#8322)
* docs(volume_server): add integration test development plan

* test(volume_server): add integration harness and profile matrix

* test(volume_server/http): add admin and options integration coverage

* test(volume_server/grpc): add state and status integration coverage

* test(volume_server): auto-build weed binary and harden cluster startup

* test(volume_server/http): add upload read range head delete coverage

* test(volume_server/grpc): expand admin lifecycle and state coverage

* docs(volume_server): update progress tracker for implemented tests

* test(volume_server/http): cover if-none-match and invalid-range branches

* test(volume_server/grpc): add batch delete integration coverage

* docs(volume_server): log latest HTTP and gRPC test coverage

* ci(volume_server): run volume server integration tests in github actions

* test(volume_server/grpc): add needle status configure ping and leave coverage

* docs(volume_server): record additional grpc coverage progress

* test(volume_server/grpc): add vacuum integration coverage

* docs(volume_server): record vacuum test coverage progress

* test(volume_server/grpc): add read and write needle blob error-path coverage

* docs(volume_server): record data rw grpc coverage progress

* test(volume_server/http): add jwt auth integration coverage

* test(volume_server/grpc): add sync copy and stream error-path coverage

* docs(volume_server): record jwt and sync/copy test coverage

* test(volume_server/grpc): add scrub and query integration coverage

* test(volume_server/grpc): add volume tail sender and receiver coverage

* docs(volume_server): record scrub query and tail test progress

* test(volume_server/grpc): add readonly writable and collection lifecycle coverage

* test(volume_server/http): add public-port cors and method parity coverage

* test(volume_server/grpc): add blob meta and read-all success path coverage

* test(volume_server/grpc): expand scrub and query variation coverage

* test(volume_server/grpc): add tiering and remote fetch error-path coverage

* test(volume_server/http): add unchanged write and delete edge-case coverage

* test(volume_server/grpc): add ping unknown and unreachable target coverage

* test(volume_server/grpc): add volume delete only-empty variation coverage

* test(volume_server/http): add jwt fid-mismatch auth coverage

* test(volume_server/grpc): add scrub ec auto-select empty coverage

* test(volume_server/grpc): stabilize ping timestamp assertion

* docs(volume_server): update integration coverage progress log

* test(volume_server/grpc): add tier remote backend and config variation coverage

* docs(volume_server): record tier remote variation progress

* test(volume_server/grpc): add incremental copy and receive-file protocol coverage

* test(volume_server/http): add read path shape and if-modified-since coverage

* test(volume_server/grpc): add copy-file compaction and receive-file success coverage

* test(volume_server/http): add passthrough headers and static asset coverage

* test(volume_server/grpc): add ping filer unreachable coverage

* docs(volume_server): record copy receive and http variant progress

* test(volume_server/grpc): add erasure coding maintenance and missing-path coverage

* docs(volume_server): record initial erasure coding rpc coverage

* test(volume_server/http): add multi-range multipart response coverage

* docs(volume_server): record multi-range http coverage progress

* test(volume_server/grpc): add query empty-stripe no-match coverage

* docs(volume_server): record query no-match stream behavior coverage

* test(volume_server/http): add upload throttling timeout and replicate bypass coverage

* docs(volume_server): record upload throttling coverage progress

* test(volume_server/http): add download throttling timeout coverage

* docs(volume_server): record download throttling coverage progress

* test(volume_server/http): add jwt wrong-cookie fid mismatch coverage

* docs(volume_server): record jwt wrong-cookie mismatch coverage

* test(volume_server/http): add jwt expired-token rejection coverage

* docs(volume_server): record jwt expired-token coverage

* test(volume_server/http): add jwt query and cookie transport coverage

* docs(volume_server): record jwt token transport coverage

* test(volume_server/http): add jwt token-source precedence coverage

* docs(volume_server): record jwt token-source precedence coverage

* test(volume_server/http): add jwt header-over-cookie precedence coverage

* docs(volume_server): record jwt header cookie precedence coverage

* test(volume_server/http): add jwt query-over-cookie precedence coverage

* docs(volume_server): record jwt query cookie precedence coverage

* test(volume_server/grpc): add setstate version mismatch and nil-state coverage

* docs(volume_server): record setstate validation coverage

* test(volume_server/grpc): add readonly persist-true lifecycle coverage

* docs(volume_server): record readonly persist variation coverage

* test(volume_server/http): add options origin cors header coverage

* docs(volume_server): record options origin cors coverage

* test(volume_server/http): add trace unsupported-method parity coverage

* docs(volume_server): record trace method parity coverage

* test(volume_server/grpc): add batch delete cookie-check variation coverage

* docs(volume_server): record batch delete cookie-check coverage

* test(volume_server/grpc): add admin lifecycle missing and maintenance variants

* docs(volume_server): record admin lifecycle edge-case coverage

* test(volume_server/grpc): add mixed batch delete status matrix coverage

* docs(volume_server): record mixed batch delete matrix coverage

* test(volume_server/http): add jwt-profile ui access gating coverage

* docs(volume_server): record jwt ui-gating http coverage

* test(volume_server/http): add propfind unsupported-method parity coverage

* docs(volume_server): record propfind method parity coverage

* test(volume_server/grpc): add volume configure success and rollback-path coverage

* docs(volume_server): record volume configure branch coverage

* test(volume_server/grpc): add volume needle status missing-path coverage

* docs(volume_server): record volume needle status error-path coverage

* test(volume_server/http): add readDeleted query behavior coverage

* docs(volume_server): record readDeleted http behavior coverage

* test(volume_server/http): add delete ts override parity coverage

* docs(volume_server): record delete ts parity coverage

* test(volume_server/grpc): add invalid blob/meta offset coverage

* docs(volume_server): record invalid blob/meta offset coverage

* test(volume_server/grpc): add read-all mixed volume abort coverage

* docs(volume_server): record read-all mixed-volume abort coverage

* test(volume_server/http): assert head response body parity

* docs(volume_server): record head body parity assertion

* test(volume_server/grpc): assert status state and memory payload completeness

* docs(volume_server): record volume server status payload coverage

* test(volume_server/grpc): add batch delete chunk-manifest rejection coverage

* docs(volume_server): record batch delete chunk-manifest coverage

* test(volume_server/grpc): add query cookie-mismatch eof parity coverage

* docs(volume_server): record query cookie-mismatch parity coverage

* test(volume_server/grpc): add ping master success target coverage

* docs(volume_server): record ping master success coverage

* test(volume_server/http): add head if-none-match conditional parity

* docs(volume_server): record head if-none-match parity coverage

* test(volume_server/http): add head if-modified-since parity coverage

* docs(volume_server): record head if-modified-since parity coverage

* test(volume_server/http): add connect unsupported-method parity coverage

* docs(volume_server): record connect method parity coverage

* test(volume_server/http): assert options allow-headers cors parity

* docs(volume_server): record options allow-headers coverage

* test(volume_server/framework): add dual volume cluster integration harness

* test(volume_server/http): add missing-local read mode proxy redirect local coverage

* docs(volume_server): record read mode missing-local matrix coverage

* test(volume_server/http): add download over-limit replica proxy fallback coverage

* docs(volume_server): record download replica fallback coverage

* test(volume_server/http): add missing-local readDeleted proxy redirect parity coverage

* docs(volume_server): record missing-local readDeleted mode coverage

* test(volume_server/framework): add single-volume cluster with filer harness

* test(volume_server/grpc): add ping filer success target coverage

* docs(volume_server): record ping filer success coverage

* test(volume_server/http): add proxied-loop guard download timeout coverage

* docs(volume_server): record proxied-loop download coverage

* test(volume_server/http): add disabled upload and download limit coverage

* docs(volume_server): record disabled throttling path coverage

* test(volume_server/grpc): add idempotent volume server leave coverage

* docs(volume_server): record leave idempotence coverage

* test(volume_server/http): add redirect collection query preservation coverage

* docs(volume_server): record redirect collection query coverage

* test(volume_server/http): assert admin server headers on status and health

* docs(volume_server): record admin server header coverage

* test(volume_server/http): assert healthz request-id echo parity

* docs(volume_server): record healthz request-id parity coverage

* test(volume_server/http): add over-limit invalid-vid download branch coverage

* docs(volume_server): record over-limit invalid-vid branch coverage

* test(volume_server/http): add public-port static asset coverage

* docs(volume_server): record public static endpoint coverage

* test(volume_server/http): add public head method parity coverage

* docs(volume_server): record public head parity coverage

* test(volume_server/http): add throttling wait-then-proceed path coverage

* docs(volume_server): record throttling wait-then-proceed coverage

* test(volume_server/http): add read cookie-mismatch not-found coverage

* docs(volume_server): record read cookie-mismatch coverage

* test(volume_server/http): add throttling timeout-recovery coverage

* docs(volume_server): record throttling timeout-recovery coverage

* test(volume_server/grpc): add ec generate mount info unmount lifecycle coverage

* docs(volume_server): record ec positive lifecycle coverage

* test(volume_server/grpc): add ec shard read and blob delete lifecycle coverage

* docs(volume_server): record ec shard read/blob delete lifecycle coverage

* test(volume_server/grpc): add ec rebuild and to-volume error branch coverage

* docs(volume_server): record ec rebuild and to-volume branch coverage

* test(volume_server/grpc): add ec shards-to-volume success roundtrip coverage

* docs(volume_server): record ec shards-to-volume success coverage

* test(volume_server/grpc): add ec receive and copy-file missing-source coverage

* docs(volume_server): record ec receive and copy-file coverage

* test(volume_server/grpc): add ec last-shard delete cleanup coverage

* docs(volume_server): record ec last-shard delete cleanup coverage

* test(volume_server/grpc): add volume copy success path coverage

* docs(volume_server): record volume copy success coverage

* test(volume_server/grpc): add volume copy overwrite-destination coverage

* docs(volume_server): record volume copy overwrite coverage

* test(volume_server/http): add write error-path variant coverage

* docs(volume_server): record http write error-path coverage

* test(volume_server/http): add conditional header precedence coverage

* docs(volume_server): record conditional header precedence coverage

* test(volume_server/http): add oversized combined range guard coverage

* docs(volume_server): record oversized range guard coverage

* test(volume_server/http): add image resize and crop read coverage

* docs(volume_server): record image transform coverage

* test(volume_server/http): add chunk-manifest expansion and bypass coverage

* docs(volume_server): record chunk-manifest read coverage

* test(volume_server/http): add compressed read encoding matrix coverage

* docs(volume_server): record compressed read matrix coverage

* test(volume_server/grpc): add tail receiver source replication coverage

* docs(volume_server): record tail receiver replication coverage

* test(volume_server/grpc): add tail sender large-needle chunking coverage

* docs(volume_server): record tail sender chunking coverage

* test(volume_server/grpc): add ec-backed volume needle status coverage

* docs(volume_server): record ec-backed needle status coverage

* test(volume_server/grpc): add ec shard copy from peer success coverage

* docs(volume_server): record ec shard copy success coverage

* test(volume_server/http): add chunk-manifest delete child cleanup coverage

* docs(volume_server): record chunk-manifest delete cleanup coverage

* test(volume_server/http): add chunk-manifest delete failure-path coverage

* docs(volume_server): record chunk-manifest delete failure coverage

* test(volume_server/grpc): add ec shard copy source-unavailable coverage

* docs(volume_server): record ec shard copy source-unavailable coverage

* parallel
2026-02-13 00:40:56 -08:00
Chris LuandGitHub c433fee36a s3api: fix AccessDenied by correctly propagating principal ARN in vended tokens (#8330)
* s3api: fix AccessDenied by correctly propagating principal ARN in vended tokens

* s3api: update TestLoadS3ApiConfiguration to match standardized ARN format

* s3api: address PR review comments (nil-safety and cleanup)

* s3api: address second round of PR review comments (cleanups and naming conventions)

* s3api: address third round of PR review comments (unify default account ID and duplicate log)

* s3api: address fourth round of PR review comments (define defaultAccountID as constant)
2026-02-12 23:11:41 -08:00
Chris LuandGitHub 1e4f30c56f pb: fix IPv6 double brackets in ServerAddress formatting (#8329)
* pb: fix IPv6 double brackets in ServerAddress formatting

* pb: refactor IPv6 tests into table-driven test

* util: add JoinHostPortStr and use it in pb to avoid unsafe port parsing
2026-02-12 18:11:03 -08:00
796f23f68a Fix STS InvalidAccessKeyId and request body consumption issues (#8328)
* Fix STS InvalidAccessKeyId and request body consumption in Lakekeeper integration test

* Remove debug prints

* Add Lakekeeper integration tests to CI

* Fix connection refused in CI by binding to 0.0.0.0

* Add timeout to docker run in Lakekeeper integration test

* Update weed/s3api/auth_credentials.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-12 17:37:07 -08:00
951eeefb76 fix(s3): lifecycle TTL rules inherit replication and volumeGrowthCount from filer config (#8321)
* fix(s3): lifecycle TTL rules inherit replication from parent path and filer config

PutBucketLifecycleConfiguration wrote filer.conf entries with empty replication,
so effective replication could differ from operator default. Now we resolve
replication from parent path rule (MatchStorageRule) then filer global config;
only Replication is set on the rule (no DataCenter/Rack/DataNode for S3).

* add volumeGrowthCount

* review

---------

Co-authored-by: Dmitiy Gushchin <dag@fivegen.ru>
2026-02-12 16:46:05 -08:00
Chris LuandGitHub 25ea48227f Fix STS temporary credentials to use ASIA prefix instead of AKIA (#8326)
Temporary credentials from STS AssumeRole were using "AKIA" prefix
(permanent IAM user credentials) instead of "ASIA" prefix (temporary
security credentials). This violates AWS conventions and may cause
compatibility issues with AWS SDKs that validate credential types.

Changes:
- Rename generateAccessKeyId to generateTemporaryAccessKeyId for clarity
- Update function to use ASIA prefix for temporary credentials
- Add unit tests to verify ASIA prefix format (weed/iam/sts/credential_prefix_test.go)
- Add integration test to verify ASIA prefix in S3 API (test/s3/iam/s3_sts_credential_prefix_test.go)
- Ensure AWS-compatible credential format (ASIA + 16 hex chars)

The credentials are already deterministic (SHA256-based from session ID)
and the SessionToken is correctly set to the JWT token, so this is just
a prefix fix to follow AWS standards.

Fixes #8312
2026-02-12 14:47:20 -08:00
Chris LuandGitHub 0082c47e04 Test: Add RisingWave DML verification test (#8317)
* Test: Verify RisingWave DML operations (INSERT, UPDATE, DELETE) support

* Test: Refine RisingWave DML test (remove sleeps, use polling)
2026-02-12 14:24:04 -08:00
abd681b54b Fix service name in the worker deployment (seaweedfs#8314) (#8315)
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-02-12 14:22:42 -08:00
Chris LuandGitHub 4e1065e485 Fix: preserve request body for STS signature verification (#8324)
* Fix: preserve request body for STS signature verification

- Save and restore request body in UnifiedPostHandler after ParseForm()
- This allows STS handler to verify signatures correctly
- Fixes 'invalid AWS signature: 53' error (ErrContentSHA256Mismatch)
- ParseForm() consumes the body, so we need to restore it for downstream handlers

* Improve error handling in UnifiedPostHandler

- Add http.MaxBytesReader to limit body size to 10 MiB (iamRequestBodyLimit)
- Add proper error handling for io.ReadAll failures
- Log errors when body reading fails
- Prevents DoS attacks from oversized request bodies
- Addresses code review feedback
2026-02-12 13:28:12 -08:00
Chris LuandGitHub c1a9263e37 Fix STS AssumeRole with POST body param (#8320)
* Fix STS AssumeRole with POST body param and add integration test

* Add STS integration test to CI workflow

* Address code review feedback: fix HPP vulnerability and style issues

* Refactor: address code review feedback

- Fix HTTP Parameter Pollution vulnerability in UnifiedPostHandler
- Refactor permission check logic for better readability
- Extract test helpers to testutil/docker.go to reduce duplication
- Clean up imports and simplify context setting

* Add SigV4-style test variant for AssumeRole POST body routing

- Added ActionInBodyWithSigV4Style test case to validate real-world scenario
- Test confirms routing works correctly for AWS SigV4-signed requests
- Addresses code review feedback about testing with SigV4 signatures

* Fix: always set identity in context when non-nil

- Ensure UnifiedPostHandler always calls SetIdentityInContext when identity is non-nil
- Only call SetIdentityNameInContext when identity.Name is non-empty
- This ensures downstream handlers (embeddedIam.DoActions) always have access to identity
- Addresses potential issue where empty identity.Name would skip context setting
2026-02-12 12:04:07 -08:00
Chris LuandGitHub 6bd6bba594 Fix inconsistent admin argument in worker pods (#8316)
* Fix inconsistent admin argument in worker pods

* Use seaweedfs.componentName for admin service naming
2026-02-12 09:50:53 -08:00
Chris LuandGitHub b8ef48c8f1 Add RisingWave catalog tests (#8308)
* Add RisingWave catalog tests for S3 tables

* Add RisingWave catalog integration tests to CI workflow

* Refactor RisingWave catalog tests based on PR feedback

* Address PR feedback: optimize checks, cleanup logs

* fix tests

* consistent
2026-02-11 22:00:06 -08:00
Chris LuandGitHub 75faf826d4 Fix LevelDB panic on lazy reload (#8269) (#8307)
* fix LevelDB panic on lazy reload

Implemented a thread-safe reload mechanism using double-checked
locking and a retry loop in Get, Put, and Delete. Added a concurrency
test to verify the fix and prevent regressions.

Fixes #8269

* refactor: use helper for leveldb fix and remove deprecated ioutil

* fix: prevent deadlock by using getFromDb helper

Extracted DB lookup to internal helper to avoid recursive RLock in Put/Delete methods.
Updated Get to use the helper as well.

* fix: resolve syntax error and commit deadlock prevention

Fixed a duplicate function declaration syntax error.
Verified that getFromDb helper correctly prevents recursive RLock scenarios.

* refactor: remove redundant timeout checks

Removed nested `if m.ldbTimeout > 0` checks in Get, Put, and Delete
methods as suggested in PR review.
2026-02-11 14:17:21 -08:00
Lisandro PinandGitHub 221bd237c4 Fix file stat collection metric bug for the cluster.status command. (#8302)
When the `--files` flag is present, `cluster.status` will scrape file metrics
from volume servers to provide detailed stats on those. The progress indicator
was not being updated properly though, so the command would complete before
it read 100%.
2026-02-11 13:34:20 -08:00
Chris LuandGitHub a3136c523f Fix volume.fsck 401 Unauthorized by adding JWT to HTTP delete requests (#8306)
* Fix volume.fsck 401 Unauthorized by adding JWT to HTTP delete requests

* Additionally, for performance, consider fetching the jwt.filer_signing.key once before any loops that call httpDelete, rather than inside httpDelete itself, to avoid repeated configuration lookups.
2026-02-11 13:32:56 -08:00
Chris Lu ac242d04ee one time manual run 2026-02-11 13:20:52 -08:00
Chris Lu 21543134c8 fix manual build process 2026-02-11 13:20:41 -08:00
8b5d31e5eb s3api/policy_engine: use forwarded client IP for aws:SourceIp (#8304)
* s3api: honor forwarded source IP for policy conditions

Prefer X-Forwarded-For/X-Real-Ip before RemoteAddr when populating aws:SourceIp in policy condition evaluation. Also avoid noisy parsing behavior for unix socket markers and add coverage for precedence/fallback paths.\n\nFixes #8301.

* s3api: simplify remote addr parsing

* s3api: guard aws:SourceIp against DNS hosts

* s3api: simplify remote addr fallback

* s3api: simplify remote addr parsing

* Update weed/s3api/policy_engine/engine.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix TestExtractConditionValuesFromRequestSourceIPPrecedence using trusted private IP

* Refactor extractSourceIP to use R-to-L XFF parsing and net.IP.IsPrivate

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-11 12:47:03 -08:00
Chris Lu 7151181d54 fix flaky tests 2026-02-11 12:23:35 -08:00
Lisandro PinandGitHub e657e7d827 Implement local scrubbing for EC volumes. (#8283) 2026-02-11 11:04:08 -08:00
Lisandro PinandGitHub 2a73219397 Add weed shell command volumeServer.state to query/update volume server state settings. (#8271)
Add weed shell command `volumeServer.state` to query/update volume server states.
2026-02-11 11:02:37 -08:00
Chris LuandGitHub 7fcbffed7f filer.sync: support manifest chunks (#8299)
* filer.sync support manifest chunks

* filersink: address manifest sync review feedback
2026-02-10 20:18:35 -08:00
Chris LuandGitHub be0379f6fd Fix filer.sync retry on stale chunk (#8298)
* Fix filer.sync stale chunk uploads

* Tweak filersink stale logging
2026-02-10 19:06:35 -08:00
Chris LuandGitHub b57429ef2e Switch empty-folder cleanup to bucket policy (#8292)
* Fix Spark _temporary cleanup and add issue #8285 regression test

* Generalize empty folder cleanup for Spark temp artifacts

* Revert synchronous folder pruning and add cleanup diagnostics

* Add actionable empty-folder cleanup diagnostics

* Fix Spark temp marker cleanup in async folder cleaner

* Fix Spark temp cleanup with implicit directory markers

* Keep explicit directory markers non-implicit

* logging

* more logs

* Switch empty-folder cleanup to bucket policy

* Seaweed-X-Amz-Allow-Empty-Folders

* less logs

* go vet

* less logs

* refactoring
2026-02-10 18:38:38 -08:00
Chris LuandGitHub 5c365e7090 s3api: return 400 for invalid namespace query in REST table routes (#8296)
* s3api: reject invalid namespace query in REST table routes

* s3api: expand namespace validation REST tests
2026-02-10 17:57:08 -08:00
Chris LuandGitHub 822dbed552 s3api: fix ListObjectsV2 NextContinuationToken duplication for nested prefix (#8294)
* s3api: fix duplicate ListObjectsV2 continuation token for nested prefix

* s3api: include prefix in common-prefix continuation token
2026-02-10 14:17:41 -08:00
Chris LuandGitHub 2d97685390 ci: fix container_release_unified manual dispatch and workflow parsing (#8293)
ci: fix unified release workflow dispatch matrix filtering
2026-02-10 13:15:52 -08:00
Chris LuandGitHub 1b2f719d7c admin: fix file browser items-per-page selector (#8291)
* admin: fix file browser page size selector

Fix file browser pagination page-size selectors to use explicit select IDs instead of this.value in templ-generated handlers, which could resolve to undefined and produce limit=undefined in requests.

Add a focused template render regression test to prevent this from recurring.

Fixes #8284

* revert file browser template regression test
2026-02-10 12:56:34 -08:00
Chris LuandGitHub b73bd08470 ci: move manual container builds to unified release workflow (#8290)
* ci: move manual dev container build into unified release workflow

* ci: make unified manual container build release-tag based
2026-02-10 12:47:39 -08:00
Chris LuandGitHub 17f85361e9 Remove unsupported iceberg rest signing-region from tests and docs (#8289) 2026-02-10 12:23:31 -08:00
Chris LuandGitHub b261c89675 Fix RocksDB container build compatibility and add manual rocksdb dispatch (#8288)
* Fix RocksDB build compatibility and add manual rocksdb trigger

* Upgrade RocksDB defaults and keep grocksdb v1.10.7

* Add manual latest-image trigger inputs for ref and variant

* Allow manual latest build to set image tag and source ref

* Fix manual variant selection using setup job matrix output
2026-02-10 11:48:42 -08:00
Chris LuGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
0385acba02 s3tables: fix shared table-location bucket mapping collisions (#8286)
* s3tables: prevent shared table-location bucket mapping overwrite

* Update weed/s3api/bucket_paths.go

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-10 11:28:29 -08:00
Chris LuandGitHub d6825ffce2 Iceberg: implement stage-create finalize flow (phase 1) (#8279)
* iceberg: implement stage-create and create-on-commit finalize

* iceberg: add create validation error typing and stage-create integration test

* tests: merge stage-create integration check into catalog suite

* tests: cover stage-create finalize lifecycle in catalog integration

* iceberg: persist and cleanup stage-create markers

* iceberg: add stage-create rollout flag and marker pruning

* docs: add stage-create support design and rollout plan

* docs: drop stage-create design draft from PR

* iceberg: use conservative 72h stage-marker retention

* iceberg: address review comments on create-on-commit and tests

* iceberg: keep stage-create metadata out of table location

* refactor(iceberg): split iceberg.go into focused files
2026-02-10 09:46:09 -08:00
Chris LuandGitHub d88f6ed0af Iceberg commit reliability: preserve statistics updates and return 409 conflicts (#8277)
* iceberg: harden table commit updates and conflict handling

* iceberg: refine commit retry and statistics patching

* iceberg: cleanup metadata on non-conflict commit errors
2026-02-09 23:00:03 -08:00
Chris LuandGitHub 5ae3be44d1 iceberg: persist namespace properties for create/get (#8276)
* iceberg: persist namespace properties via s3tables metadata

* iceberg: simplify namespace properties normalization

* s3tables: broaden namespace properties round-trip test

* adjust logs

* adjust logs
2026-02-09 22:20:45 -08:00
Chris LuGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1c62808c0e iceberg: wire pagination for list namespaces/tables REST APIs (#8275)
* s3api/iceberg: wire list pagination tokens and page size

* fmt

* Update weed/s3api/iceberg/iceberg.go

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-09 21:46:55 -08:00
Chris Lu db76eb26e7 compile 2026-02-09 21:06:07 -08:00
Chris Lu 4ccc7668ce admin: resolve merge conflicts 2026-02-09 20:56:20 -08:00
Chris LuandGitHub aef2de3109 s3tables: support multi-level namespaces in parser/admin paths (#8273)
* s3tables: support multi-level namespace normalization

* admin: handle namespace parsing errors centrally

* admin: clean namespace validation duplication
2026-02-09 20:20:05 -08:00
Chris Lu be26ce74ce s3tables: support multi-level namespace normalization 2026-02-09 19:42:31 -08:00
194 changed files with 19223 additions and 2860 deletions
-1
View File
@@ -3,7 +3,6 @@ name: "docker: build dev containers"
on:
push:
branches: [ master ]
workflow_dispatch: {}
permissions:
contents: read
+59 -20
View File
@@ -4,22 +4,58 @@ on:
push:
tags:
- '*'
workflow_dispatch: {}
workflow_dispatch:
inputs:
source_ref:
description: 'Git ref to build (branch, tag, or commit SHA)'
required: true
default: 'master'
image_tag:
description: 'Docker tag to publish (without variant suffix)'
required: true
default: 'latest'
variant:
description: 'Variant to build manually'
required: true
type: choice
default: all
options:
- all
- standard
- large_disk
permissions:
contents: read
jobs:
setup:
runs-on: ubuntu-latest
outputs:
variants: ${{ steps.set-variants.outputs.variants }}
steps:
- name: Select variants for this run
id: set-variants
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.variant }}" != "all" ]; then
variants="[\"${{ github.event.inputs.variant }}\"]"
else
variants='["standard","large_disk"]'
fi
echo "variants=$variants" >> "$GITHUB_OUTPUT"
build:
needs: [setup]
runs-on: ubuntu-latest
strategy:
matrix:
platform: [amd64, arm64, arm, 386]
variant: [standard, large_disk]
variant: ${{ fromJSON(needs.setup.outputs.variants) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source_ref || github.ref }}
- name: Free Disk Space
run: |
echo "Available disk space before cleanup:"
@@ -55,7 +91,7 @@ jobs:
images: |
chrislusf/seaweedfs
ghcr.io/chrislusf/seaweedfs
tags: type=raw,value=latest,suffix=${{ steps.config.outputs.tag_suffix }}
tags: type=raw,value=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.image_tag || 'latest' }},suffix=${{ steps.config.outputs.tag_suffix }}
labels: |
org.opencontainers.image.title=seaweedfs
org.opencontainers.image.description=SeaweedFS is a distributed storage system for blobs, objects, files, and data lake, to store and serve billions of files fast!
@@ -97,13 +133,13 @@ jobs:
file: ./docker/Dockerfile.go_build
platforms: linux/${{ matrix.platform }}
# Push to GHCR only during build to avoid Docker Hub rate limits
tags: ghcr.io/chrislusf/seaweedfs:latest${{ steps.config.outputs.tag_suffix }}-${{ matrix.platform }}
tags: ghcr.io/chrislusf/seaweedfs:${{ github.event_name == 'workflow_dispatch' && github.event.inputs.image_tag || 'latest' }}${{ steps.config.outputs.tag_suffix }}-${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.variant }}-${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}-${{ matrix.platform }}
build-args: |
BUILDKIT_INLINE_CACHE=1
BRANCH=${{ github.sha }}
BRANCH=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source_ref || github.sha }}
${{ steps.config.outputs.build_args }}
- name: Clean up build artifacts
if: always()
@@ -115,14 +151,16 @@ jobs:
create-manifest:
runs-on: ubuntu-latest
needs: [build]
needs: [setup, build]
if: github.event_name != 'pull_request'
strategy:
matrix:
variant: [standard, large_disk]
variant: ${{ fromJSON(needs.setup.outputs.variants) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.source_ref || github.ref }}
- name: Configure variant
id: config
@@ -140,7 +178,7 @@ jobs:
images: |
chrislusf/seaweedfs
ghcr.io/chrislusf/seaweedfs
tags: type=raw,value=latest,suffix=${{ steps.config.outputs.tag_suffix }}
tags: type=raw,value=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.image_tag || 'latest' }},suffix=${{ steps.config.outputs.tag_suffix }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
@@ -162,14 +200,15 @@ jobs:
- name: Create and push manifest
run: |
SUFFIX="${{ steps.config.outputs.tag_suffix }}"
BASE_TAG="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.image_tag || 'latest' }}"
# Create manifest on GHCR first (no rate limits)
echo "Creating GHCR manifest (no rate limits)..."
docker buildx imagetools create -t ghcr.io/chrislusf/seaweedfs:latest${SUFFIX} \
ghcr.io/chrislusf/seaweedfs:latest${SUFFIX}-amd64 \
ghcr.io/chrislusf/seaweedfs:latest${SUFFIX}-arm64 \
ghcr.io/chrislusf/seaweedfs:latest${SUFFIX}-arm \
ghcr.io/chrislusf/seaweedfs:latest${SUFFIX}-386
docker buildx imagetools create -t ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-amd64 \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-arm64 \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-arm \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-386
# Copy the complete multi-arch image from GHCR to Docker Hub
# This only requires one pull from GHCR (no rate limit) and one push to Docker Hub
@@ -205,16 +244,16 @@ jobs:
# Use crane or skopeo to copy, fallback to docker if not available
if command -v crane &> /dev/null; then
echo "Using crane to copy..."
retry_with_backoff crane copy ghcr.io/chrislusf/seaweedfs:latest${SUFFIX} chrislusf/seaweedfs:latest${SUFFIX}
retry_with_backoff crane copy ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}
elif command -v skopeo &> /dev/null; then
echo "Using skopeo to copy..."
retry_with_backoff skopeo copy --all docker://ghcr.io/chrislusf/seaweedfs:latest${SUFFIX} docker://chrislusf/seaweedfs:latest${SUFFIX}
retry_with_backoff skopeo copy --all docker://ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} docker://chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}
else
echo "Using docker buildx imagetools (pulling 4 images from Docker Hub)..."
# Fallback: create manifest directly on Docker Hub (pulls from Docker Hub - rate limited)
retry_with_backoff docker buildx imagetools create -t chrislusf/seaweedfs:latest${SUFFIX} \
ghcr.io/chrislusf/seaweedfs:latest${SUFFIX}-amd64 \
ghcr.io/chrislusf/seaweedfs:latest${SUFFIX}-arm64 \
ghcr.io/chrislusf/seaweedfs:latest${SUFFIX}-arm \
ghcr.io/chrislusf/seaweedfs:latest${SUFFIX}-386
retry_with_backoff docker buildx imagetools create -t chrislusf/seaweedfs:${BASE_TAG}${SUFFIX} \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-amd64 \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-arm64 \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-arm \
ghcr.io/chrislusf/seaweedfs:${BASE_TAG}${SUFFIX}-386
fi
-152
View File
@@ -1,152 +0,0 @@
name: "docker: build release containers for normal volume"
# DISABLED: Merged into container_release_unified.yml
on:
workflow_dispatch:
inputs:
force_run:
description: 'This workflow is disabled. Use container_release_unified.yml instead'
required: true
default: 'disabled'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
platform: [amd64, arm64, arm, 386]
include:
- platform: amd64
qemu: false
- platform: arm64
qemu: true
- platform: arm
qemu: true
- platform: 386
qemu: true
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Free Disk Space
run: |
echo "Available disk space before cleanup:"
df -h
# Remove pre-installed tools
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
# Clean package managers
sudo apt-get clean
sudo rm -rf /var/lib/apt/lists/*
# Clean Docker aggressively
sudo docker system prune -af --volumes
# Clean Go cache if it exists
[ -d ~/.cache/go-build ] && rm -rf ~/.cache/go-build || true
[ -d /go/pkg ] && rm -rf /go/pkg || true
echo "Available disk space after cleanup:"
df -h
- name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: chrislusf/seaweedfs
tags: type=ref,event=tag
flavor: latest=false
- name: Set up QEMU
if: matrix.qemu
uses: docker/setup-qemu-action@v3
- name: Create BuildKit config
run: |
cat > /tmp/buildkitd.toml <<EOF
[registry."docker.io"]
mirrors = ["https://mirror.gcr.io"]
EOF
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config: /tmp/buildkitd.toml
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build ${{ matrix.platform }}
uses: docker/build-push-action@v6
env:
DOCKER_BUILDKIT: 1
with:
context: ./docker
push: ${{ github.event_name != 'pull_request' }}
file: ./docker/Dockerfile.go_build
platforms: linux/${{ matrix.platform }}
tags: ${{ steps.docker_meta.outputs.tags }}-${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
BUILDKIT_INLINE_CACHE=1
BRANCH=${{ github.sha }}
- name: Clean up build artifacts
if: always()
run: |
# Clean up Docker build cache and temporary files
sudo docker system prune -f
# Remove Go build cache
sudo rm -rf /tmp/go-build*
create-manifest:
runs-on: ubuntu-latest
needs: [build]
if: github.event_name != 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: chrislusf/seaweedfs
tags: type=ref,event=tag
flavor: latest=false
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create and push manifest
run: |
# Function to retry command with exponential backoff
retry_with_backoff() {
local max_attempts=5
local timeout=1
local attempt=1
local exit_code=0
while [ $attempt -le $max_attempts ]; do
if "$@"; then
return 0
else
exit_code=$?
fi
if [ $attempt -lt $max_attempts ]; then
echo "Attempt $attempt failed. Retrying in ${timeout}s..." >&2
sleep $timeout
timeout=$((timeout * 2))
fi
attempt=$((attempt + 1))
done
echo "Command failed after $max_attempts attempts" >&2
return $exit_code
}
# Create manifest with retry
retry_with_backoff docker buildx imagetools create -t ${{ steps.docker_meta.outputs.tags }} \
${{ steps.docker_meta.outputs.tags }}-amd64 \
${{ steps.docker_meta.outputs.tags }}-arm64 \
${{ steps.docker_meta.outputs.tags }}-arm \
${{ steps.docker_meta.outputs.tags }}-386
-153
View File
@@ -1,153 +0,0 @@
name: "docker: build release containers for large volume"
# DISABLED: Merged into container_release_unified.yml
on:
workflow_dispatch:
inputs:
force_run:
description: 'This workflow is disabled. Use container_release_unified.yml instead'
required: true
default: 'disabled'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
platform: [amd64, arm64, arm, 386]
include:
- platform: amd64
qemu: false
- platform: arm64
qemu: true
- platform: arm
qemu: true
- platform: 386
qemu: true
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Free Disk Space
run: |
echo "Available disk space before cleanup:"
df -h
# Remove pre-installed tools
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
# Clean package managers
sudo apt-get clean
sudo rm -rf /var/lib/apt/lists/*
# Clean Docker aggressively
sudo docker system prune -af --volumes
# Clean Go cache if it exists
[ -d ~/.cache/go-build ] && rm -rf ~/.cache/go-build || true
[ -d /go/pkg ] && rm -rf /go/pkg || true
echo "Available disk space after cleanup:"
df -h
- name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: chrislusf/seaweedfs
tags: type=ref,event=tag,suffix=_large_disk
flavor: latest=false
- name: Set up QEMU
if: matrix.qemu
uses: docker/setup-qemu-action@v3
- name: Create BuildKit config
run: |
cat > /tmp/buildkitd.toml <<EOF
[registry."docker.io"]
mirrors = ["https://mirror.gcr.io"]
EOF
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config: /tmp/buildkitd.toml
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build ${{ matrix.platform }}
uses: docker/build-push-action@v6
env:
DOCKER_BUILDKIT: 1
with:
context: ./docker
push: ${{ github.event_name != 'pull_request' }}
file: ./docker/Dockerfile.go_build
build-args: |
TAGS=5BytesOffset
BUILDKIT_INLINE_CACHE=1
BRANCH=${{ github.sha }}
platforms: linux/${{ matrix.platform }}
tags: ${{ steps.docker_meta.outputs.tags }}-${{ matrix.platform }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Clean up build artifacts
if: always()
run: |
# Clean up Docker build cache and temporary files
sudo docker system prune -f
# Remove Go build cache
sudo rm -rf /tmp/go-build*
create-manifest:
runs-on: ubuntu-latest
needs: [build]
if: github.event_name != 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: chrislusf/seaweedfs
tags: type=ref,event=tag,suffix=_large_disk
flavor: latest=false
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create and push manifest
run: |
# Function to retry command with exponential backoff
retry_with_backoff() {
local max_attempts=5
local timeout=1
local attempt=1
local exit_code=0
while [ $attempt -le $max_attempts ]; do
if "$@"; then
return 0
else
exit_code=$?
fi
if [ $attempt -lt $max_attempts ]; then
echo "Attempt $attempt failed. Retrying in ${timeout}s..." >&2
sleep $timeout
timeout=$((timeout * 2))
fi
attempt=$((attempt + 1))
done
echo "Command failed after $max_attempts attempts" >&2
return $exit_code
}
# Create manifest with retry
retry_with_backoff docker buildx imagetools create -t ${{ steps.docker_meta.outputs.tags }} \
${{ steps.docker_meta.outputs.tags }}-amd64 \
${{ steps.docker_meta.outputs.tags }}-arm64 \
${{ steps.docker_meta.outputs.tags }}-arm \
${{ steps.docker_meta.outputs.tags }}-386
-73
View File
@@ -1,73 +0,0 @@
name: "docker: build release containers for rocksdb"
# DISABLED: Merged into container_release_unified.yml
on:
workflow_dispatch:
inputs:
force_run:
description: 'This workflow is disabled. Use container_release_unified.yml instead'
required: true
default: 'disabled'
permissions:
contents: read
jobs:
build-large-release-container_rocksdb:
runs-on: [ubuntu-latest]
steps:
-
name: Checkout
uses: actions/checkout@v6
-
name: Free Disk Space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo docker system prune -af
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: chrislusf/seaweedfs
tags: type=ref,event=tag,suffix=_large_disk_rocksdb
flavor: latest=false
labels: |
org.opencontainers.image.title=seaweedfs
org.opencontainers.image.description=SeaweedFS is a distributed storage system for blobs, objects, files, and data lake, to store and serve billions of files fast!
org.opencontainers.image.vendor=Chris Lu
-
name: Create BuildKit config
run: |
cat > /tmp/buildkitd.toml <<EOF
[registry."docker.io"]
mirrors = ["https://mirror.gcr.io"]
EOF
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config: /tmp/buildkitd.toml
-
name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
-
name: Build
uses: docker/build-push-action@v6
with:
context: ./docker
push: ${{ github.event_name != 'pull_request' }}
file: ./docker/Dockerfile.rocksdb_large
build-args: |
BRANCH=${{ github.sha }}
platforms: linux/amd64
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
-72
View File
@@ -1,72 +0,0 @@
name: "docker: build release containers for all tags"
# DISABLED: Merged into container_release_unified.yml
on:
workflow_dispatch:
inputs:
force_run:
description: 'This workflow is disabled. Use container_release_unified.yml instead'
required: true
default: 'disabled'
permissions:
contents: read
jobs:
build-default-release-container:
runs-on: [ubuntu-latest]
steps:
-
name: Checkout
uses: actions/checkout@v6
-
name: Free Disk Space
run: |
echo "Before cleanup:"
df -h
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
sudo docker system prune -af
echo "After cleanup:"
df -h
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
chrislusf/seaweedfs
tags: |
type=ref,event=tag,suffix=_full
flavor: |
latest=false
labels: |
org.opencontainers.image.title=seaweedfs
org.opencontainers.image.description=SeaweedFS is a distributed storage system for blobs, objects, files, and data lake, to store and serve billions of files fast!
org.opencontainers.image.vendor=Chris Lu
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
-
name: Build
uses: docker/build-push-action@v6
with:
context: ./docker
push: ${{ github.event_name != 'pull_request' }}
file: ./docker/Dockerfile.go_build
build-args: TAGS=elastic,gocdk,rclone,sqlite,tarantool,tikv,ydb
platforms: linux/amd64
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
-62
View File
@@ -1,62 +0,0 @@
name: "docker: build release containers for all tags and large volume"
# DISABLED: Merged into container_release_unified.yml
on:
workflow_dispatch:
inputs:
force_run:
description: 'This workflow is disabled. Use container_release_unified.yml instead'
required: true
default: 'disabled'
permissions:
contents: read
jobs:
build-default-release-container:
runs-on: [ubuntu-latest]
steps:
-
name: Checkout
uses: actions/checkout@v6
-
name: Free Disk Space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo docker system prune -af
-
name: Docker meta
id: docker_meta
uses: docker/metadata-action@v5
with:
images: chrislusf/seaweedfs
tags: type=ref,event=tag,suffix=_large_disk_full
flavor: latest=false
labels: |
org.opencontainers.image.title=seaweedfs
org.opencontainers.image.description=SeaweedFS is a distributed storage system for blobs, objects, files, and data lake, to store and serve billions of files fast!
org.opencontainers.image.vendor=Chris Lu
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
-
name: Build
uses: docker/build-push-action@v6
with:
context: ./docker
push: ${{ github.event_name != 'pull_request' }}
file: ./docker/Dockerfile.go_build
build-args: TAGS=5BytesOffset,elastic,gocdk,rclone,sqlite,tarantool,tikv,ydb
platforms: linux/amd64
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+45 -13
View File
@@ -4,11 +4,35 @@ on:
push:
tags:
- '*'
workflow_dispatch: {}
workflow_dispatch:
inputs:
variant:
description: 'Variant to build manually'
required: true
type: choice
default: all
options:
- all
- normal
- large_disk
- full
- large_disk_full
- rocksdb
release_tag:
description: 'Release tag to publish (e.g. 3.93)'
required: true
default: ''
rocksdb_version:
description: 'RocksDB git tag to use when variant=rocksdb'
required: false
default: 'v10.10.1'
permissions:
contents: read
env:
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_tag || github.ref_name }}
# Limit concurrent builds to avoid rate limits
concurrency:
group: release-${{ github.ref }}
@@ -59,9 +83,11 @@ jobs:
steps:
- name: Checkout
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
uses: actions/checkout@v6
- name: Free Disk Space
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
run: |
echo "Available disk space before cleanup:"
df -h
@@ -75,13 +101,14 @@ jobs:
df -h
- name: Docker meta
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
id: docker_meta
uses: docker/metadata-action@v5
with:
images: |
chrislusf/seaweedfs
ghcr.io/chrislusf/seaweedfs
tags: type=ref,event=tag,suffix=${{ matrix.tag_suffix }}
tags: type=raw,value=${{ env.RELEASE_TAG }}${{ matrix.tag_suffix }}
flavor: latest=false
labels: |
org.opencontainers.image.title=seaweedfs
@@ -89,10 +116,11 @@ jobs:
org.opencontainers.image.vendor=Chris Lu
- name: Set up QEMU
if: contains(matrix.platforms, 'arm')
if: (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant) && contains(matrix.platforms, 'arm')
uses: docker/setup-qemu-action@v3
- name: Create BuildKit config
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
run: |
cat > /tmp/buildkitd.toml <<EOF
[registry."docker.io"]
@@ -100,19 +128,20 @@ jobs:
EOF
- name: Set up Docker Buildx
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
uses: docker/setup-buildx-action@v3
with:
buildkitd-config: /tmp/buildkitd.toml
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
if: (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant) && github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
if: github.event_name != 'pull_request'
if: (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant) && github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -120,6 +149,7 @@ jobs:
password: ${{ secrets.GHCR_TOKEN }}
- name: Build and push ${{ matrix.variant }}
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
uses: docker/build-push-action@v6
env:
DOCKER_BUILDKIT: 1
@@ -130,7 +160,7 @@ jobs:
platforms: ${{ matrix.platforms }}
# Push to GHCR to avoid Docker Hub rate limits on pulls
tags: |
ghcr.io/chrislusf/seaweedfs:${{ github.ref_name }}${{ matrix.tag_suffix }}
ghcr.io/chrislusf/seaweedfs:${{ env.RELEASE_TAG }}${{ matrix.tag_suffix }}
labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.variant }}
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
@@ -138,9 +168,10 @@ jobs:
${{ matrix.build_args }}
BUILDKIT_INLINE_CACHE=1
BRANCH=${{ github.sha }}
${{ matrix.variant == 'rocksdb' && format('ROCKSDB_VERSION={0}', github.event.inputs.rocksdb_version || 'v10.10.1') || '' }}
- name: Clean up build artifacts
if: always()
if: always() && (github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant)
run: |
sudo docker system prune -f
sudo rm -rf /tmp/go-build*
@@ -166,12 +197,14 @@ jobs:
steps:
- name: Login to Docker Hub
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GHCR
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -179,6 +212,7 @@ jobs:
password: ${{ secrets.GHCR_TOKEN }}
- name: Install crane
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
run: |
cd $(mktemp -d)
curl -sL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz" | tar xz
@@ -186,6 +220,7 @@ jobs:
crane version
- name: Copy ${{ matrix.variant }} from GHCR to Docker Hub
if: github.event_name != 'workflow_dispatch' || github.event.inputs.variant == 'all' || github.event.inputs.variant == matrix.variant
run: |
# Function to retry with exponential backoff
retry_with_backoff() {
@@ -218,14 +253,15 @@ jobs:
# This is much more efficient than pulling/pushing individual arch images
echo "Copying ${{ matrix.variant }} from GHCR to Docker Hub..."
retry_with_backoff crane copy \
ghcr.io/chrislusf/seaweedfs:${{ github.ref_name }}${{ matrix.tag_suffix }} \
chrislusf/seaweedfs:${{ github.ref_name }}${{ matrix.tag_suffix }}
ghcr.io/chrislusf/seaweedfs:${{ env.RELEASE_TAG }}${{ matrix.tag_suffix }} \
chrislusf/seaweedfs:${{ env.RELEASE_TAG }}${{ matrix.tag_suffix }}
echo "✓ Successfully copied ${{ matrix.variant }} to Docker Hub"
helm-release:
runs-on: ubuntu-latest
needs: [copy-to-dockerhub]
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
permissions:
contents: write
pages: write
@@ -239,7 +275,3 @@ jobs:
target_dir: helm
branch: gh-pages
helm_version: "3.18.4"
@@ -4,9 +4,9 @@ on:
workflow_dispatch:
inputs:
rocksdb_version:
description: 'RocksDB git tag or branch to build (e.g. v10.5.1)'
description: 'RocksDB git tag or branch to build (e.g. v10.10.1)'
required: true
default: 'v10.5.1'
default: 'v10.10.1'
seaweedfs_ref:
description: 'SeaweedFS git tag, branch, or commit to build'
required: true
+24 -6
View File
@@ -15,24 +15,20 @@ permissions:
jobs:
build:
name: Build
vet:
name: Go Vet
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@a5f9b05d2d216f63e13859e0d847461041025775 # v2
with:
go-version: ^1.13
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
- name: Get dependencies
run: |
cd weed; go get -v -t -d ./...
- name: Go Vet (excluding protobuf lock copying)
run: |
cd weed
@@ -42,8 +38,30 @@ jobs:
# Fail only if there are actual vet errors (not counting the filtered lock warnings)
if grep -q "vet:" vet-output.txt; then exit 1; fi
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@a5f9b05d2d216f63e13859e0d847461041025775 # v2
with:
go-version: ^1.13
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
- name: Build
run: cd weed; go build -tags "elastic gocdk sqlite ydb tarantool tikv rclone" -v .
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@a5f9b05d2d216f63e13859e0d847461041025775 # v2
with:
go-version: ^1.13
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v2
- name: Test
run: cd weed; go test -tags "elastic gocdk sqlite ydb tarantool tikv rclone" -v ./...
+25
View File
@@ -0,0 +1,25 @@
name: "helm: manual release"
on:
workflow_dispatch:
permissions:
contents: write
pages: write
jobs:
helm-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Publish Helm charts
uses: stefanprodan/helm-gh-pages@v1.7.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: k8s/charts
target_dir: helm
branch: gh-pages
helm_version: "3.18.4"
+203
View File
@@ -260,6 +260,209 @@ jobs:
path: test/s3tables/catalog_spark/test-output.log
retention-days: 3
risingwave-catalog-tests:
name: RisingWave 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@v3
- name: Pre-pull RisingWave image
run: |
docker pull risingwavelabs/risingwave:v2.5.0
docker pull postgres:16-alpine
- name: Run go mod tidy
run: go mod tidy
- name: Install SeaweedFS
run: |
go install -buildvcs=false ./weed
- name: Run RisingWave Catalog Integration Tests
timeout-minutes: 25
working-directory: test/s3tables/catalog_risingwave
run: |
set -x
set -o pipefail
echo "=== System Information ==="
uname -a
free -h
df -h
echo "=== Starting RisingWave Catalog Tests ==="
# Run RisingWave catalog integration tests
go test -v -timeout 20m . 2>&1 | tee test-output.log || {
echo "RisingWave catalog integration tests failed"
exit 1
}
- name: Show test output on failure
if: failure()
working-directory: test/s3tables/catalog_risingwave
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)" || true
- name: Upload test logs on failure
if: failure()
uses: actions/upload-artifact@v6
with:
name: risingwave-catalog-test-logs
path: test/s3tables/catalog_risingwave/test-output.log
retention-days: 3
sts-integration-tests:
name: STS 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@v3
- name: Pre-pull Python image
run: docker pull python:3
- name: Run go mod tidy
run: go mod tidy
- name: Install SeaweedFS
run: |
go install -buildvcs=false ./weed
- name: Run STS Integration Tests
timeout-minutes: 25
working-directory: test/s3tables/sts_integration
run: |
set -x
set -o pipefail
echo "=== System Information ==="
uname -a
free -h
df -h
echo "=== Starting STS Integration Tests ==="
# Run STS integration tests
go test -v -timeout 20m . 2>&1 | tee test-output.log || {
echo "STS integration tests failed"
exit 1
}
- name: Show test output on failure
if: failure()
working-directory: test/s3tables/sts_integration
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)" || true
- name: Upload test logs on failure
if: failure()
uses: actions/upload-artifact@v6
with:
name: sts-integration-test-logs
path: test/s3tables/sts_integration/test-output.log
retention-days: 3
lakekeeper-integration-tests:
name: Lakekeeper 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@v3
- name: Pre-pull Python image
run: docker pull python:3
- name: Pre-pull LocalStack image (if needed)
run: docker pull localstack/localstack:latest || true
- name: Run go mod tidy
run: go mod tidy
- name: Install SeaweedFS
run: |
go install -buildvcs=false ./weed
- name: Run Lakekeeper Integration Tests
timeout-minutes: 25
working-directory: test/s3tables/lakekeeper
run: |
set -x
set -o pipefail
echo "=== System Information ==="
uname -a
free -h
df -h
echo "=== Starting Lakekeeper Integration Tests ==="
# Run Lakekeeper integration tests
go test -v -timeout 20m . 2>&1 | tee test-output.log || {
echo "Lakekeeper integration tests failed"
exit 1
}
- name: Show test output on failure
if: failure()
working-directory: test/s3tables/lakekeeper
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)" || true
- name: Upload test logs on failure
if: failure()
uses: actions/upload-artifact@v6
with:
name: lakekeeper-integration-test-logs
path: test/s3tables/lakekeeper/test-output.log
retention-days: 3
s3-tables-build-verification:
name: S3 Tables Build Verification
runs-on: ubuntu-22.04
@@ -0,0 +1,122 @@
name: "Volume Server Integration Tests"
on:
pull_request:
branches: [ master ]
paths:
- 'test/volume_server/**'
- 'weed/server/**'
- 'weed/storage/**'
- 'weed/pb/volume_server.proto'
- 'weed/pb/volume_server_pb/**'
- '.github/workflows/volume-server-integration-tests.yml'
push:
branches: [ master, main ]
paths:
- 'test/volume_server/**'
- 'weed/server/**'
- 'weed/storage/**'
- 'weed/pb/volume_server.proto'
- 'weed/pb/volume_server_pb/**'
- '.github/workflows/volume-server-integration-tests.yml'
concurrency:
group: ${{ github.head_ref || github.ref }}/volume-server-integration-tests
cancel-in-progress: true
permissions:
contents: read
env:
GO_VERSION: '1.24'
TEST_TIMEOUT: '30m'
jobs:
volume-server-integration-tests:
name: Volume Server Integration Tests (${{ matrix.test-type }} - Shard ${{ matrix.shard }})
runs-on: ubuntu-22.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
test-type: [grpc, http]
shard: [1, 2, 3]
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Go ${{ env.GO_VERSION }}
uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
- name: Build SeaweedFS binary
run: |
cd weed
go build -o weed .
chmod +x weed
./weed version
- name: Run volume server integration tests
env:
WEED_BINARY: ${{ github.workspace }}/weed/weed
run: |
if [ "${{ matrix.test-type }}" == "grpc" ]; then
if [ "${{ matrix.shard }}" == "1" ]; then
TEST_PATTERN="^Test[A-H]"
elif [ "${{ matrix.shard }}" == "2" ]; then
TEST_PATTERN="^Test[I-S]"
else
TEST_PATTERN="^Test[T-Z]"
fi
else
if [ "${{ matrix.shard }}" == "1" ]; then
TEST_PATTERN="^Test[A-G]"
elif [ "${{ matrix.shard }}" == "2" ]; then
TEST_PATTERN="^Test[H-R]"
else
TEST_PATTERN="^Test[S-Z]"
fi
fi
echo "Running volume server integration tests for ${{ matrix.test-type }} (Shard ${{ matrix.shard }}, pattern: ${TEST_PATTERN})..."
go test -v -count=1 -timeout=${{ env.TEST_TIMEOUT }} ./test/volume_server/${{ matrix.test-type }}/... -run "${TEST_PATTERN}"
- name: Collect logs on failure
if: failure()
run: |
mkdir -p /tmp/volume-server-it-logs
find /tmp -maxdepth 1 -type d -name "seaweedfs_volume_server_it_*" -print -exec cp -r {} /tmp/volume-server-it-logs/ \; || true
- name: Archive logs on failure
if: failure()
uses: actions/upload-artifact@v6
with:
name: volume-server-integration-test-logs
path: /tmp/volume-server-it-logs/
if-no-files-found: warn
retention-days: 7
- name: Test summary
if: always()
run: |
if [ "${{ matrix.test-type }}" == "grpc" ]; then
if [ "${{ matrix.shard }}" == "1" ]; then
TEST_PATTERN="^Test[A-H]"
elif [ "${{ matrix.shard }}" == "2" ]; then
TEST_PATTERN="^Test[I-S]"
else
TEST_PATTERN="^Test[T-Z]"
fi
else
if [ "${{ matrix.shard }}" == "1" ]; then
TEST_PATTERN="^Test[A-G]"
elif [ "${{ matrix.shard }}" == "2" ]; then
TEST_PATTERN="^Test[H-R]"
else
TEST_PATTERN="^Test[S-Z]"
fi
fi
echo "## Volume Server Integration Test Summary (${{ matrix.test-type }} - Shard ${{ matrix.shard }})" >> "$GITHUB_STEP_SUMMARY"
echo "- Suite: test/volume_server/${{ matrix.test-type }} (Pattern: ${TEST_PATTERN})" >> "$GITHUB_STEP_SUMMARY"
echo "- Command: go test -v -count=1 -timeout=${{ env.TEST_TIMEOUT }} ./test/volume_server/${{ matrix.test-type }}/... -run \"${TEST_PATTERN}\"" >> "$GITHUB_STEP_SUMMARY"
+1 -1
View File
@@ -3,7 +3,7 @@ FROM golang:1.24 AS builder
RUN apt-get update
RUN apt-get install -y build-essential libsnappy-dev zlib1g-dev libbz2-dev libgflags-dev liblz4-dev libzstd-dev
ARG ROCKSDB_VERSION=v10.5.1
ARG ROCKSDB_VERSION=v10.10.1
ENV ROCKSDB_VERSION=${ROCKSDB_VERSION}
# build RocksDB
+1 -1
View File
@@ -3,7 +3,7 @@ FROM golang:1.24 AS builder
RUN apt-get update
RUN apt-get install -y build-essential libsnappy-dev zlib1g-dev libbz2-dev libgflags-dev liblz4-dev libzstd-dev
ARG ROCKSDB_VERSION=v10.5.1
ARG ROCKSDB_VERSION=v10.10.1
ENV ROCKSDB_VERSION=${ROCKSDB_VERSION}
# build RocksDB
@@ -2,7 +2,7 @@
apiVersion: v1
kind: Service
metadata:
name: {{ printf "%s-admin" (include "seaweedfs.fullname" .) | trunc 63 | trimSuffix "-" }}
name: {{ include "seaweedfs.componentName" (list . "admin") }}
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: {{ template "seaweedfs.name" . }}
@@ -134,7 +134,7 @@ spec:
{{- if .Values.worker.adminServer }}
-admin={{ .Values.worker.adminServer }} \
{{- else }}
-admin={{ template "seaweedfs.name" . }}-admin.{{ .Release.Namespace }}:{{ .Values.admin.port }}{{ if .Values.admin.grpcPort }}.{{ .Values.admin.grpcPort }}{{ end }} \
-admin={{ template "seaweedfs.fullname" . }}-admin.{{ .Release.Namespace }}:{{ .Values.admin.port }}{{ if .Values.admin.grpcPort }}.{{ .Values.admin.grpcPort }}{{ end }} \
{{- end }}
-capabilities={{ .Values.worker.capabilities }} \
-maxConcurrent={{ .Values.worker.maxConcurrent }} \
@@ -41,7 +41,7 @@
<dependency>
<groupId>org.apache.avro</groupId>
<artifactId>avro</artifactId>
<version>1.11.4</version>
<version>1.11.5</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
@@ -0,0 +1,82 @@
package iam
import (
"encoding/xml"
"io"
"net/http"
"net/url"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSTSTemporaryCredentialPrefix verifies that STS temporary credentials use ASIA prefix
// This test ensures AWS compatibility - temporary credentials should use ASIA, not AKIA
func TestSTSTemporaryCredentialPrefix(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !isSTSEndpointRunning(t) {
t.Skip("SeaweedFS STS endpoint is not running at", TestSTSEndpoint)
}
// Use test credentials from environment or fall back to defaults
accessKey := os.Getenv("STS_TEST_ACCESS_KEY")
if accessKey == "" {
accessKey = "admin"
}
secretKey := os.Getenv("STS_TEST_SECRET_KEY")
if secretKey == "" {
secretKey = "admin"
}
t.Run("assume_role_returns_asia_prefix", func(t *testing.T) {
resp, err := callSTSAPIWithSigV4(t, url.Values{
"Action": {"AssumeRole"},
"Version": {"2011-06-15"},
"RoleArn": {"arn:aws:iam::role/admin"},
"RoleSessionName": {"asia-prefix-test"},
}, accessKey, secretKey)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
if resp.StatusCode != http.StatusOK {
t.Logf("Response status: %d, body: %s", resp.StatusCode, string(body))
t.Skip("AssumeRole not fully implemented yet")
}
var stsResp AssumeRoleTestResponse
err = xml.Unmarshal(body, &stsResp)
require.NoError(t, err, "Failed to parse response: %s", string(body))
creds := stsResp.Result.Credentials
require.NotEmpty(t, creds.AccessKeyId, "AccessKeyId should not be empty")
// Verify ASIA prefix for temporary credentials
assert.True(t, strings.HasPrefix(creds.AccessKeyId, "ASIA"),
"Temporary credentials must use ASIA prefix (not AKIA for permanent keys), got: %s", creds.AccessKeyId)
// Verify it's NOT using AKIA (permanent credentials)
assert.False(t, strings.HasPrefix(creds.AccessKeyId, "AKIA"),
"Temporary credentials must NOT use AKIA prefix (that's for permanent IAM keys), got: %s", creds.AccessKeyId)
// Verify format: ASIA + 16 hex characters = 20 chars total
assert.Equal(t, 20, len(creds.AccessKeyId),
"Access key ID should be 20 characters (ASIA + 16 hex chars), got: %s", creds.AccessKeyId)
t.Logf("✓ Temporary credentials correctly use ASIA prefix: %s", creds.AccessKeyId)
})
t.Run("assume_role_with_web_identity_returns_asia_prefix", func(t *testing.T) {
// This test would require OIDC setup, so we'll skip it for now
// but the same ASIA prefix validation should apply
t.Skip("AssumeRoleWithWebIdentity requires OIDC provider setup")
})
}
@@ -77,14 +77,14 @@ func TestObjectLockValidation(t *testing.T) {
require.NoError(t, err, "Setting Object Lock retention should succeed")
t.Log(" Object Lock retention applied successfully")
// Verify retention allows simple DELETE (creates delete marker) but blocks version deletion
// AWS S3 behavior: Simple DELETE (without version ID) is ALWAYS allowed and creates delete marker
// Verify retention blocks simple DELETE (COMPLIANCE mode is strict WORM)
// AWS S3 behavior: Simple DELETE (without version ID) is blocked by COMPLIANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker (AWS S3 behavior)")
t.Log(" Simple DELETE succeeded (creates delete marker - correct AWS behavior)")
require.Error(t, err, "Simple DELETE should be blocked by COMPLIANCE retention (AWS S3 behavior)")
t.Log(" Simple DELETE correctly blocked by COMPLIANCE retention")
// Now verify that DELETE with version ID is properly blocked by retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
+7 -7
View File
@@ -329,12 +329,12 @@ func TestRetentionModeCompliance(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, types.ObjectLockRetentionModeCompliance, retentionResp.Retention.Mode)
// Try simple DELETE - should succeed and create delete marker (AWS S3 behavior)
// Try simple DELETE - should fail for COMPLIANCE mode (strict WORM)
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker")
require.Error(t, err, "Simple DELETE should be blocked by COMPLIANCE retention")
// Try DELETE with version ID - should fail for COMPLIANCE mode
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
@@ -388,12 +388,12 @@ func TestLegalHoldWorkflow(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, types.ObjectLockLegalHoldStatusOn, legalHoldResp.LegalHold.Status)
// Try simple DELETE - should succeed and create delete marker (AWS S3 behavior)
// Try simple DELETE - should fail due to legal hold
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker")
require.Error(t, err, "Simple DELETE should be blocked by legal hold")
// Try DELETE with version ID - should fail due to legal hold
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
@@ -591,12 +591,12 @@ func TestRetentionAndLegalHoldCombination(t *testing.T) {
})
require.NoError(t, err)
// Try simple DELETE - should succeed and create delete marker (AWS S3 behavior)
// Try simple DELETE - should fail due to legal hold + COMPLIANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker")
require.Error(t, err, "Simple DELETE should be blocked by legal hold")
// Try DELETE with version ID and bypass - should still fail due to legal hold
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
@@ -607,7 +607,7 @@ func TestRetentionAndLegalHoldCombination(t *testing.T) {
})
require.Error(t, err, "Legal hold should prevent deletion even with governance bypass")
// Remove legal hold (must specify version ID since latest version is now delete marker)
// Remove legal hold (must specify version ID)
_, err = client.PutObjectLegalHold(context.TODO(), &s3.PutObjectLegalHoldInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
@@ -42,12 +42,12 @@ func TestWORMRetentionIntegration(t *testing.T) {
})
require.NoError(t, err)
// Try simple DELETE - should succeed and create delete marker (AWS S3 behavior)
// Try simple DELETE - should fail due to GOVERNANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker")
require.Error(t, err, "Simple DELETE should be blocked by GOVERNANCE retention")
// Try DELETE with version ID - should fail due to GOVERNANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
@@ -325,12 +325,12 @@ func TestRetentionWithMultipartUpload(t *testing.T) {
})
require.NoError(t, err)
// Try simple DELETE - should succeed and create delete marker (AWS S3 behavior)
// Try simple DELETE - should fail due to GOVERNANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
require.NoError(t, err, "Simple DELETE should succeed and create delete marker")
require.Error(t, err, "Simple DELETE should be blocked by GOVERNANCE retention")
// Try DELETE with version ID - should fail due to GOVERNANCE retention
_, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
+1 -1
View File
@@ -14,7 +14,7 @@ func setupSparkIssue8234Env(t *testing.T) *TestEnvironment {
}
env.StartSeaweedFS(t)
t.Cleanup(func() { env.Cleanup() })
t.Cleanup(func() { env.Cleanup(t) })
createObjectBucket(t, env, "test")
env.startSparkContainer(t)
+180
View File
@@ -0,0 +1,180 @@
package spark
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
)
func TestSparkS3TemporaryDirectoryCleanupIssue8285Regression(t *testing.T) {
if testing.Short() {
t.Skip("Skipping Spark integration test in short mode")
}
env := setupSparkIssue8234Env(t)
script := `
import pyspark.sql.functions as F
target = "s3a://test/issue-8285/output"
spark.conf.set("spark.hadoop.fs.s3a.committer.name", "directory")
spark.conf.set("spark.hadoop.fs.s3a.committer.magic.enabled", "false")
spark.conf.set("spark.hadoop.fs.s3a.committer.staging.abort.pending.uploads", "true")
spark.conf.set("spark.hadoop.fs.s3a.committer.staging.conflict-mode", "append")
spark.conf.set("spark.hadoop.fs.s3a.committer.staging.tmp.path", "/tmp")
spark.conf.set("spark.hadoop.fs.s3a.directory.marker.retention", "keep")
df = spark.range(0, 200).repartition(12).withColumn("value", F.col("id") * 2)
df.write.format("parquet").mode("overwrite").save(target)
count = spark.read.parquet(target).count()
print("WRITE_COUNT=" + str(count))
`
code, output := runSparkPyScript(t, env.sparkContainer, script, env.s3Port)
if code != 0 {
t.Fatalf("Spark script exited with code %d; output:\n%s", code, output)
}
if !strings.Contains(output, "WRITE_COUNT=200") {
t.Fatalf("expected write/read success marker in output, got:\n%s", output)
}
keys := listObjectKeysByPrefix(t, env, "test", "issue-8285/")
var temporaryKeys []string
for _, key := range keys {
if hasTemporaryPathSegment(key) {
temporaryKeys = append(temporaryKeys, key)
}
}
if len(temporaryKeys) > 0 {
t.Fatalf("issue #8285 regression detected: found lingering _temporary artifacts: %v\nall keys: %v", temporaryKeys, keys)
}
temporaryCandidates := []string{
"issue-8285/output/_temporary/",
"issue-8285/output/_temporary/0/",
"issue-8285/output/_temporary/0/_temporary/",
}
lingering := waitForObjectsToDisappear(t, env, "test", temporaryCandidates, 35*time.Second)
if len(lingering) > 0 {
t.Fatalf("issue #8285 regression detected: lingering temporary directories: %v", lingering)
}
}
func listObjectKeysByPrefix(t *testing.T, env *TestEnvironment, bucketName, prefix string) []string {
t.Helper()
client := newS3Client(env)
pager := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
Bucket: aws.String(bucketName),
Prefix: aws.String(prefix),
})
var keys []string
for pager.HasMorePages() {
page, err := pager.NextPage(context.Background())
if err != nil {
t.Fatalf("failed listing objects for prefix %q: %v", prefix, err)
}
for _, object := range page.Contents {
keys = append(keys, aws.ToString(object.Key))
}
}
return keys
}
func headObjectInfo(t *testing.T, env *TestEnvironment, bucketName, key string) (bool, string, error) {
t.Helper()
client := newS3Client(env)
output, err := client.HeadObject(context.Background(), &s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(key),
})
if err == nil {
return true, aws.ToString(output.ContentType), nil
}
var notFound *s3types.NotFound
if strings.Contains(err.Error(), "NotFound") || strings.Contains(err.Error(), "NoSuchKey") || errors.As(err, &notFound) {
return false, "", nil
}
return false, "", err
}
func waitForObjectsToDisappear(t *testing.T, env *TestEnvironment, bucketName string, keys []string, timeout time.Duration) []string {
t.Helper()
deadline := time.Now().Add(timeout)
pending := make(map[string]struct{}, len(keys))
details := make(map[string]string, len(keys))
for _, key := range keys {
pending[key] = struct{}{}
}
for len(pending) > 0 && time.Now().Before(deadline) {
for key := range pending {
exists, contentType, err := headObjectInfo(t, env, bucketName, key)
if err != nil {
details[key] = fmt.Sprintf("%s (head_error=%v)", key, err)
continue
}
if !exists {
delete(pending, key)
delete(details, key)
continue
}
details[key] = fmt.Sprintf("%s (exists=true, contentType=%q)", key, contentType)
}
if len(pending) > 0 {
time.Sleep(2 * time.Second)
}
}
if len(pending) == 0 {
return nil
}
var lingering []string
for _, key := range keys {
if _, ok := pending[key]; !ok {
continue
}
if detail, hasDetail := details[key]; hasDetail {
lingering = append(lingering, detail)
} else {
lingering = append(lingering, key)
}
}
return lingering
}
func newS3Client(env *TestEnvironment) *s3.Client {
cfg := aws.Config{
Region: "us-east-1",
Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(env.accessKey, env.secretKey, "")),
BaseEndpoint: aws.String(fmt.Sprintf("http://localhost:%d", env.s3Port)),
}
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
}
func hasTemporaryPathSegment(key string) bool {
for _, segment := range strings.Split(strings.TrimSuffix(key, "/"), "/") {
if segment == "_temporary" {
return true
}
}
return false
}
+31 -1
View File
@@ -8,6 +8,7 @@ import (
"net"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
@@ -53,6 +54,8 @@ type TestEnvironment struct {
dockerAvailable bool
weedBinary string
seaweedfsDataDir string
weedLogPath string
weedLogFile *os.File
masterPort int
filerPort int
s3Port int
@@ -113,6 +116,15 @@ func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
"-s3.config", iamConfigPath,
"-dir", env.seaweedfsDataDir,
)
weedLogPath := filepath.Join(env.seaweedfsDataDir, "weed-mini.log")
weedLogFile, err := os.Create(weedLogPath)
if err != nil {
t.Fatalf("failed to create weed log file: %v", err)
}
env.weedLogPath = weedLogPath
env.weedLogFile = weedLogFile
env.masterProcess.Stdout = weedLogFile
env.masterProcess.Stderr = weedLogFile
env.masterProcess.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
@@ -160,12 +172,30 @@ func (env *TestEnvironment) startSparkContainer(t *testing.T) {
env.sparkContainer = container
}
func (env *TestEnvironment) Cleanup() {
func (env *TestEnvironment) Cleanup(t *testing.T) {
if env.masterProcess != nil && env.masterProcess.Process != nil {
_ = env.masterProcess.Process.Kill()
_ = env.masterProcess.Wait()
}
clearMiniProcess(env.masterProcess)
if env.weedLogFile != nil {
_ = env.weedLogFile.Close()
}
if t.Failed() && os.Getenv("CI") != "" && env.weedLogPath != "" {
logData, err := os.ReadFile(env.weedLogPath)
if err != nil {
t.Logf("failed to read weed mini log file %s: %v", env.weedLogPath, err)
} else {
// Print the tail to keep CI output manageable while preserving failure context.
const maxTailBytes = 64 * 1024
start := 0
if len(logData) > maxTailBytes {
start = len(logData) - maxTailBytes
}
t.Logf("weed mini logs (tail, %d bytes):\n%s", len(logData)-start, string(logData[start:]))
}
}
if env.sparkContainer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-2
View File
@@ -300,7 +300,6 @@ populate-spark: ensure-table-bucket ensure-work-dir ## Populate sample data via
--conf "spark.sql.catalog.iceberg.s3.region=$(AWS_REGION)" \
--conf "spark.sql.catalog.iceberg.rest.sigv4-enabled=true" \
--conf "spark.sql.catalog.iceberg.rest.signing-name=s3tables" \
--conf "spark.sql.catalog.iceberg.rest.signing-region=$(AWS_REGION)" \
--conf "spark.sql.catalog.spark_catalog=org.apache.iceberg.spark.SparkCatalog" \
--conf "spark.sql.catalog.spark_catalog.type=rest" \
--conf "spark.sql.catalog.spark_catalog.uri=$(CATALOG_ENDPOINT_DOCKER)" \
@@ -314,7 +313,6 @@ populate-spark: ensure-table-bucket ensure-work-dir ## Populate sample data via
--conf "spark.sql.catalog.spark_catalog.s3.region=$(AWS_REGION)" \
--conf "spark.sql.catalog.spark_catalog.rest.sigv4-enabled=true" \
--conf "spark.sql.catalog.spark_catalog.rest.signing-name=s3tables" \
--conf "spark.sql.catalog.spark_catalog.rest.signing-region=$(AWS_REGION)" \
-f /work/spark_seed.sql'
populate: populate-trino populate-spark ## Populate sample data through Trino and Spark
+215 -14
View File
@@ -3,7 +3,9 @@
package catalog
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
@@ -41,16 +43,15 @@ func hasDocker() bool {
return cmd.Run() == nil
}
// getFreePort returns an available ephemeral port
func getFreePort() (int, error) {
// getFreePort returns an available ephemeral port and its listener
func getFreePort() (int, net.Listener, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
return 0, nil, err
}
defer listener.Close()
addr := listener.Addr().(*net.TCPAddr)
return addr.Port, nil
return addr.Port, listener, nil
}
// NewTestEnvironment creates a new test environment
@@ -89,43 +90,67 @@ func NewTestEnvironment(t *testing.T) *TestEnvironment {
}
// Allocate free ephemeral ports for each service
s3Port, err := getFreePort()
var listeners []net.Listener
defer func() {
for _, l := range listeners {
l.Close()
}
}()
var l net.Listener
s3Port, l, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for S3: %v", err)
}
icebergPort, err := getFreePort()
listeners = append(listeners, l)
icebergPort, l, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for Iceberg: %v", err)
}
s3GrpcPort, err := getFreePort()
listeners = append(listeners, l)
s3GrpcPort, l, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for S3 gRPC: %v", err)
}
masterPort, err := getFreePort()
listeners = append(listeners, l)
masterPort, l, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for Master: %v", err)
}
masterGrpcPort, err := getFreePort()
listeners = append(listeners, l)
masterGrpcPort, l, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for Master gRPC: %v", err)
}
filerPort, err := getFreePort()
listeners = append(listeners, l)
filerPort, l, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for Filer: %v", err)
}
filerGrpcPort, err := getFreePort()
listeners = append(listeners, l)
filerGrpcPort, l, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for Filer gRPC: %v", err)
}
volumePort, err := getFreePort()
listeners = append(listeners, l)
volumePort, l, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for Volume: %v", err)
}
listeners = append(listeners, l)
volumeGrpcPort, err := getFreePort()
volumeGrpcPort, l, err := getFreePort()
if err != nil {
t.Fatalf("Failed to get free port for Volume gRPC: %v", err)
}
listeners = append(listeners, l)
return &TestEnvironment{
seaweedDir: seaweedDir,
@@ -290,6 +315,182 @@ func TestIcebergNamespaces(t *testing.T) {
}
}
// TestStageCreateAndFinalizeFlow verifies staged create remains invisible until assert-create commit finalizes table creation.
func TestStageCreateAndFinalizeFlow(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
env.StartSeaweedFS(t)
createTableBucket(t, env, "warehouse")
namespace := "stage_ns"
tableName := "orders"
status, _, err := doIcebergJSONRequest(env, http.MethodPost, "/v1/namespaces", map[string]any{
"namespace": []string{namespace},
})
if err != nil {
t.Fatalf("Create namespace request failed: %v", err)
}
if status != http.StatusOK && status != http.StatusConflict {
t.Fatalf("Create namespace status = %d, want 200 or 409", status)
}
status, badReqResp, err := doIcebergJSONRequest(env, http.MethodPost, fmt.Sprintf("/v1/namespaces/%s/tables", namespace), map[string]any{
"stage-create": true,
})
if err != nil {
t.Fatalf("Stage create missing-name request failed: %v", err)
}
if status != http.StatusBadRequest {
t.Fatalf("Stage create missing-name status = %d, want 400", status)
}
errorObj, _ := badReqResp["error"].(map[string]any)
if got := errorObj["type"]; got != "BadRequestException" {
t.Fatalf("error.type = %v, want BadRequestException", got)
}
msg, _ := errorObj["message"].(string)
if !strings.Contains(strings.ToLower(msg), "table name is required") {
t.Fatalf("error.message = %v, want it to include %q", errorObj["message"], "table name is required")
}
status, stageResp, err := doIcebergJSONRequest(env, http.MethodPost, fmt.Sprintf("/v1/namespaces/%s/tables", namespace), map[string]any{
"name": tableName,
"stage-create": true,
})
if err != nil {
t.Fatalf("Stage create request failed: %v", err)
}
if status != http.StatusOK {
t.Fatalf("Stage create status = %d, want 200", status)
}
stageLocation, _ := stageResp["metadata-location"].(string)
if !strings.HasSuffix(stageLocation, "/metadata/v1.metadata.json") {
t.Fatalf("stage metadata-location = %q, want suffix /metadata/v1.metadata.json", stageLocation)
}
status, _, err = doIcebergJSONRequest(env, http.MethodGet, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName), nil)
if err != nil {
t.Fatalf("Load staged table request failed: %v", err)
}
if status != http.StatusNotFound {
t.Fatalf("Load staged table status = %d, want 404", status)
}
status, commitResp, err := doIcebergJSONRequest(env, http.MethodPost, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName), map[string]any{
"requirements": []map[string]any{
{"type": "assert-create"},
},
"updates": []any{},
})
if err != nil {
t.Fatalf("Finalize commit request failed: %v", err)
}
if status != http.StatusOK {
t.Fatalf("Finalize commit status = %d, want 200", status)
}
commitLocation, _ := commitResp["metadata-location"].(string)
if !strings.HasSuffix(commitLocation, "/metadata/v1.metadata.json") {
t.Fatalf("final metadata-location = %q, want suffix /metadata/v1.metadata.json", commitLocation)
}
status, loadResp, err := doIcebergJSONRequest(env, http.MethodGet, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName), nil)
if err != nil {
t.Fatalf("Load finalized table request failed: %v", err)
}
if status != http.StatusOK {
t.Fatalf("Load finalized table status = %d, want 200", status)
}
loadLocation, _ := loadResp["metadata-location"].(string)
if loadLocation != commitLocation {
t.Fatalf("loaded metadata-location = %q, want %q", loadLocation, commitLocation)
}
}
// TestCommitMissingTableWithoutAssertCreate ensures missing-table commits still require assert-create for creation.
func TestCommitMissingTableWithoutAssertCreate(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
env.StartSeaweedFS(t)
createTableBucket(t, env, "warehouse")
namespace := "stage_missing_assert_ns"
tableName := "missing_table"
status, _, err := doIcebergJSONRequest(env, http.MethodPost, "/v1/namespaces", map[string]any{
"namespace": []string{namespace},
})
if err != nil {
t.Fatalf("Create namespace request failed: %v", err)
}
if status != http.StatusOK && status != http.StatusConflict {
t.Fatalf("Create namespace status = %d, want 200 or 409", status)
}
status, _, err = doIcebergJSONRequest(env, http.MethodPost, fmt.Sprintf("/v1/namespaces/%s/tables/%s", namespace, tableName), map[string]any{
"requirements": []any{},
"updates": []any{},
})
if err != nil {
t.Fatalf("Commit missing table request failed: %v", err)
}
if status != http.StatusNotFound {
t.Fatalf("Commit missing table status = %d, want 404", status)
}
}
// doIcebergJSONRequest decodes JSON object responses used by catalog tests.
func doIcebergJSONRequest(env *TestEnvironment, method, path string, payload any) (int, map[string]any, error) {
url := env.IcebergURL() + path
var bodyReader io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return 0, nil, err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return 0, nil, err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return resp.StatusCode, nil, err
}
if len(data) == 0 {
return resp.StatusCode, nil, nil
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
return resp.StatusCode, nil, fmt.Errorf("failed to decode %s %s response: %w body=%s", method, path, err, string(data))
}
return resp.StatusCode, decoded, nil
}
// createTableBucket creates a table bucket via the S3Tables REST API
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
@@ -0,0 +1,79 @@
package catalog_risingwave
import (
"fmt"
"strings"
"testing"
)
func TestRisingWaveIcebergCatalog(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping RisingWave integration test")
}
t.Log(">>> Starting SeaweedFS...")
env.StartSeaweedFS(t)
t.Log(">>> SeaweedFS started.")
tableBucket := "iceberg-tables"
t.Logf(">>> Creating table bucket: %s", tableBucket)
createTableBucket(t, env, tableBucket)
t.Log(">>> Starting RisingWave...")
env.StartRisingWave(t)
t.Log(">>> RisingWave started.")
// Create Iceberg namespace
createIcebergNamespace(t, env, "default")
// Create a catalog in RisingWave that points to SeaweedFS Iceberg REST API
icebergUri := env.dockerIcebergEndpoint()
s3Endpoint := env.dockerS3Endpoint()
tableName := "test_table_" + randomString(6)
createIcebergTable(t, env, tableBucket, "default", tableName)
sourceName := "test_source_" + randomString(6)
createSourceSql := fmt.Sprintf(`
CREATE SOURCE %s WITH (
connector = 'iceberg',
catalog.type = 'rest',
catalog.uri = '%s',
catalog.name = 'default',
database.name = 'default',
table.name = '%s',
warehouse.path = 's3://%s',
s3.endpoint = '%s',
s3.region = 'us-east-1',
s3.access.key = '%s',
s3.secret.key = '%s',
s3.path.style.access = 'true',
catalog.rest.sigv4_enabled = 'true',
catalog.rest.signing_region = 'us-east-1',
catalog.rest.signing_name = 's3'
);`, sourceName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
t.Logf(">>> Creating source %s...", sourceName)
runRisingWaveSQL(t, env.postgresSidecar, createSourceSql)
showSourcesOutput := runRisingWaveSQL(t, env.postgresSidecar, "SHOW SOURCES;")
if !strings.Contains(showSourcesOutput, sourceName) {
t.Fatalf("Expected source %s in SHOW SOURCES output:\n%s", sourceName, showSourcesOutput)
}
describeOutput := runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("DESCRIBE %s;", sourceName))
if !strings.Contains(describeOutput, "id") || !strings.Contains(describeOutput, "name") {
t.Fatalf("Expected id/name columns in DESCRIBE output:\n%s", describeOutput)
}
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("SELECT * FROM %s LIMIT 0;", sourceName))
t.Log(">>> RisingWave Iceberg Catalog test passed!")
}
@@ -0,0 +1,219 @@
package catalog_risingwave
import (
"fmt"
"strings"
"testing"
"time"
)
func TestRisingWaveIcebergDML(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping RisingWave integration test")
}
t.Log(">>> Starting SeaweedFS...")
env.StartSeaweedFS(t)
t.Log(">>> SeaweedFS started.")
tableBucket := "iceberg-tables"
t.Logf(">>> Creating table bucket: %s", tableBucket)
createTableBucket(t, env, tableBucket)
t.Log(">>> Starting RisingWave...")
env.StartRisingWave(t)
t.Log(">>> RisingWave started.")
// Create Iceberg namespace
createIcebergNamespace(t, env, "default")
icebergUri := env.dockerIcebergEndpoint()
s3Endpoint := env.dockerS3Endpoint()
// 1. Test INSERT (Append-only)
t.Run("TestInsert", func(t *testing.T) {
tableName := "test_insert_" + randomString(6)
createIcebergTable(t, env, tableBucket, "default", tableName)
rwTableName := "rw_insert_" + randomString(6)
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("CREATE TABLE %s (id int, name varchar);", rwTableName))
sinkName := "test_sink_insert_" + randomString(6)
createSinkSql := fmt.Sprintf(`
CREATE SINK %s FROM %s
WITH (
connector = 'iceberg',
catalog.type = 'rest',
catalog.uri = '%s',
catalog.name = 'default',
database.name = 'default',
table.name = '%s',
warehouse.path = 's3://%s',
s3.endpoint = '%s',
s3.region = 'us-east-1',
s3.access.key = '%s',
s3.secret.key = '%s',
s3.path.style.access = 'true',
catalog.rest.sigv4_enabled = 'true',
catalog.rest.signing_region = 'us-east-1',
catalog.rest.signing_name = 's3',
type = 'append-only',
force_append_only = 'true'
);`, sinkName, rwTableName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
t.Logf(">>> Creating sink %s...", sinkName)
runRisingWaveSQL(t, env.postgresSidecar, createSinkSql)
t.Log(">>> Inserting into RisingWave table...")
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("INSERT INTO %s VALUES (1, 'Alice'), (2, 'Bob');", rwTableName))
runRisingWaveSQL(t, env.postgresSidecar, "FLUSH;")
// Verify with Source
sourceName := "test_source_insert_" + randomString(6)
createSourceSql := fmt.Sprintf(`
CREATE SOURCE %s WITH (
connector = 'iceberg',
catalog.type = 'rest',
catalog.uri = '%s',
catalog.name = 'default',
database.name = 'default',
table.name = '%s',
warehouse.path = 's3://%s',
s3.endpoint = '%s',
s3.region = 'us-east-1',
s3.access.key = '%s',
s3.secret.key = '%s',
s3.path.style.access = 'true',
catalog.rest.sigv4_enabled = 'true',
catalog.rest.signing_region = 'us-east-1',
catalog.rest.signing_name = 's3'
);`, sourceName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
runRisingWaveSQL(t, env.postgresSidecar, createSourceSql)
t.Log(">>> Selecting from source to verify INSERT...")
verifyQuery(t, env, sourceName, "1 | Alice", "2 | Bob")
})
// 2. Test UPSERT (Update/Delete)
t.Run("TestUpsert", func(t *testing.T) {
tableName := "test_upsert_" + randomString(6)
// We need a table with PK for upsert to work effectively in RW logic,
// effectively maps to Iceberg v2 table.
createIcebergTable(t, env, tableBucket, "default", tableName)
rwTableName := "rw_upsert_" + randomString(6)
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("CREATE TABLE %s (id int PRIMARY KEY, name varchar);", rwTableName))
sinkName := "test_sink_upsert_" + randomString(6)
createSinkSql := fmt.Sprintf(`
CREATE SINK %s FROM %s
WITH (
connector = 'iceberg',
catalog.type = 'rest',
catalog.uri = '%s',
catalog.name = 'default',
database.name = 'default',
table.name = '%s',
warehouse.path = 's3://%s',
s3.endpoint = '%s',
s3.region = 'us-east-1',
s3.access.key = '%s',
s3.secret.key = '%s',
s3.path.style.access = 'true',
catalog.rest.sigv4_enabled = 'true',
catalog.rest.signing_region = 'us-east-1',
catalog.rest.signing_name = 's3',
type = 'upsert', -- Upsert mode
primary_key = 'id'
);`, sinkName, rwTableName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
t.Logf(">>> Creating upsert sink %s...", sinkName)
runRisingWaveSQL(t, env.postgresSidecar, createSinkSql)
t.Log(">>> Inserting initial data...")
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("INSERT INTO %s VALUES (1, 'Charlie'), (2, 'Dave');", rwTableName))
runRisingWaveSQL(t, env.postgresSidecar, "FLUSH;")
// Update 1, Delete 2
t.Log(">>> Updating and Deleting data...")
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("UPDATE %s SET name = 'Charles' WHERE id = 1;", rwTableName))
runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("DELETE FROM %s WHERE id = 2;", rwTableName))
runRisingWaveSQL(t, env.postgresSidecar, "FLUSH;")
// Verify with Source
sourceName := "test_source_upsert_" + randomString(6)
createSourceSql := fmt.Sprintf(`
CREATE SOURCE %s WITH (
connector = 'iceberg',
catalog.type = 'rest',
catalog.uri = '%s',
catalog.name = 'default',
database.name = 'default',
table.name = '%s',
warehouse.path = 's3://%s',
s3.endpoint = '%s',
s3.region = 'us-east-1',
s3.access.key = '%s',
s3.secret.key = '%s',
s3.path.style.access = 'true',
catalog.rest.sigv4_enabled = 'true',
catalog.rest.signing_region = 'us-east-1',
catalog.rest.signing_name = 's3'
);`, sourceName, icebergUri, tableName, tableBucket, s3Endpoint, env.accessKey, env.secretKey)
runRisingWaveSQL(t, env.postgresSidecar, createSourceSql)
t.Log(">>> Selecting from source to verify UPSERT...")
// Should see (1, 'Charles') and NOT (2, 'Dave')
verifyQuery(t, env, sourceName, "1 | Charles")
verifyQueryAbsence(t, env, sourceName, "2 | Dave")
})
}
func verifyQuery(t *testing.T, env *TestEnvironment, sourceName string, expectedSubstrings ...string) {
t.Helper()
var output string
for i := 0; i < 15; i++ {
output = runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("SELECT * FROM %s ORDER BY id;", sourceName))
allFound := true
for _, s := range expectedSubstrings {
if !strings.Contains(output, s) {
allFound = false
break
}
}
if allFound {
return
}
time.Sleep(2 * time.Second)
}
t.Fatalf("Failed to find expected data %v in output:\n%s", expectedSubstrings, output)
}
func verifyQueryAbsence(t *testing.T, env *TestEnvironment, sourceName string, unexpectedSubstrings ...string) {
t.Helper()
var output string
for i := 0; i < 15; i++ {
output = runRisingWaveSQL(t, env.postgresSidecar, fmt.Sprintf("SELECT * FROM %s ORDER BY id;", sourceName))
noneFound := true
for _, s := range unexpectedSubstrings {
if strings.Contains(output, s) {
noneFound = false
break
}
}
if noneFound {
return
}
time.Sleep(2 * time.Second)
}
t.Fatalf("Found unexpected data %v in output:\n%s", unexpectedSubstrings, output)
}
@@ -0,0 +1,481 @@
package catalog_risingwave
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/seaweedfs/seaweedfs/test/s3tables/testutil"
)
var (
miniProcessMu sync.Mutex
lastMiniProcess *exec.Cmd
)
func stopPreviousMini() {
miniProcessMu.Lock()
defer miniProcessMu.Unlock()
if lastMiniProcess != nil && lastMiniProcess.Process != nil {
_ = lastMiniProcess.Process.Kill()
_ = lastMiniProcess.Wait()
}
lastMiniProcess = nil
}
func registerMiniProcess(cmd *exec.Cmd) {
miniProcessMu.Lock()
lastMiniProcess = cmd
miniProcessMu.Unlock()
}
func clearMiniProcess(cmd *exec.Cmd) {
miniProcessMu.Lock()
if lastMiniProcess == cmd {
lastMiniProcess = nil
}
miniProcessMu.Unlock()
}
type TestEnvironment struct {
t *testing.T
dockerAvailable bool
seaweedfsDataDir string
masterPort int
filerPort int
s3Port int
icebergRestPort int
risingwavePort int
bindIP string
accessKey string
secretKey string
risingwaveContainer string
postgresSidecar string
masterProcess *exec.Cmd
logFile *os.File
}
func NewTestEnvironment(t *testing.T) *TestEnvironment {
env := &TestEnvironment{
t: t,
accessKey: "AKIAIOSFODNN7EXAMPLE",
secretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
// Check if Docker is available
cmd := exec.Command("docker", "version")
env.dockerAvailable = cmd.Run() == nil
return env
}
func (env *TestEnvironment) hostMasterAddress() string {
return fmt.Sprintf("127.0.0.1:%d", env.masterPort)
}
func (env *TestEnvironment) hostS3Endpoint() string {
return fmt.Sprintf("http://127.0.0.1:%d", env.s3Port)
}
func (env *TestEnvironment) hostIcebergEndpoint() string {
return fmt.Sprintf("http://127.0.0.1:%d", env.icebergRestPort)
}
func (env *TestEnvironment) dockerS3Endpoint() string {
return fmt.Sprintf("http://host.docker.internal:%d", env.s3Port)
}
func (env *TestEnvironment) dockerIcebergEndpoint() string {
return fmt.Sprintf("http://host.docker.internal:%d", env.icebergRestPort)
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
stopPreviousMini()
var err error
env.seaweedfsDataDir, err = os.MkdirTemp("", "seaweed-risingwave-test-")
if err != nil {
t.Fatalf("failed to create temp directory: %v", err)
}
env.masterPort = mustFreePort(t, "Master")
env.filerPort = mustFreePort(t, "Filer")
env.s3Port = mustFreePort(t, "S3")
env.icebergRestPort = mustFreePort(t, "Iceberg")
env.risingwavePort = mustFreePort(t, "RisingWave")
env.bindIP = testutil.FindBindIP()
iamConfigPath, err := testutil.WriteIAMConfig(env.seaweedfsDataDir, env.accessKey, env.secretKey)
if err != nil {
t.Fatalf("failed to create IAM config: %v", err)
}
// Create log file for SeaweedFS
logFile, err := os.Create(filepath.Join(env.seaweedfsDataDir, "seaweedfs.log"))
if err != nil {
t.Fatalf("failed to create log file: %v", err)
}
env.logFile = logFile
// Start SeaweedFS using weed mini (all-in-one including Iceberg REST)
env.masterProcess = exec.Command(
"weed", "mini",
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.iceberg", fmt.Sprintf("%d", env.icebergRestPort),
"-s3.config", iamConfigPath,
"-dir", env.seaweedfsDataDir,
)
env.masterProcess.Stdout = logFile
env.masterProcess.Stderr = logFile
env.masterProcess.Env = append(os.Environ(),
"AWS_ACCESS_KEY_ID="+env.accessKey,
"AWS_SECRET_ACCESS_KEY="+env.secretKey,
"ICEBERG_WAREHOUSE=s3://iceberg-tables",
"S3TABLES_DEFAULT_BUCKET=iceberg-tables",
)
if err := env.masterProcess.Start(); err != nil {
t.Fatalf("failed to start weed mini: %v", err)
}
registerMiniProcess(env.masterProcess)
// Wait for all services to be ready
if !waitForPort(env.masterPort, 15*time.Second) {
t.Fatalf("weed mini failed to start - master port %d not listening", env.masterPort)
}
if !waitForPort(env.filerPort, 15*time.Second) {
t.Fatalf("weed mini failed to start - filer port %d not listening", env.filerPort)
}
if !waitForPort(env.s3Port, 15*time.Second) {
t.Fatalf("weed mini failed to start - s3 port %d not listening", env.s3Port)
}
if !waitForPort(env.icebergRestPort, 15*time.Second) {
t.Fatalf("weed mini failed to start - iceberg rest port %d not listening", env.icebergRestPort)
}
}
func mustFreePort(t *testing.T, name string) int {
t.Helper()
minPort := 10000
maxPort := 55000 // Ensure port+10000 < 65535
r := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < 1000; i++ {
port := minPort + r.Intn(maxPort-minPort)
// Check http port
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
continue
}
ln.Close()
// Check grpc port (weed mini uses port+10000)
ln2, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port+10000))
if err != nil {
continue
}
ln2.Close()
return port
}
t.Fatalf("failed to find a free port < %d for %s after 1000 attempts", maxPort, name)
return 0
}
func waitForPort(port int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), 500*time.Millisecond)
if err == nil {
conn.Close()
return true
}
time.Sleep(100 * time.Millisecond)
}
return false
}
func (env *TestEnvironment) StartRisingWave(t *testing.T) {
t.Helper()
containerName := "seaweed-risingwave-" + randomString(8)
env.risingwaveContainer = containerName
cmd := exec.Command("docker", "run", "-d",
"--name", containerName,
"-p", fmt.Sprintf("%d:4566", env.risingwavePort),
"--add-host", "host.docker.internal:host-gateway",
"-e", "AWS_ACCESS_KEY_ID="+env.accessKey,
"-e", "AWS_SECRET_ACCESS_KEY="+env.secretKey,
"-e", "AWS_REGION=us-east-1",
"-e", "AWS_S3_PATH_STYLE_ACCESS=true",
"-e", "AWS_S3_FORCE_PATH_STYLE=true",
"risingwavelabs/risingwave:v2.5.0",
"playground",
)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to start RisingWave container: %v\n%s", err, string(output))
}
// Start a sidecar postgres container for running psql commands
sidecarName := "seaweed-risingwave-sidecar-" + randomString(8)
env.postgresSidecar = sidecarName
sidecarCmd := exec.Command("docker", "run", "-d", "--rm",
"--name", sidecarName,
"--network", fmt.Sprintf("container:%s", containerName),
"postgres:16-alpine",
"sleep", "infinity",
)
if output, err := sidecarCmd.CombinedOutput(); err != nil {
t.Fatalf("failed to start postgres sidecar: %v\n%s", err, string(output))
}
// Wait for RisingWave port to be open on host
if !waitForPort(env.risingwavePort, 120*time.Second) {
t.Fatalf("timed out waiting for RisingWave port %d to be open", env.risingwavePort)
}
// Wait for RisingWave to be truly ready via psql in the sidecar.
if !env.waitForRisingWave(120 * time.Second) {
t.Fatalf("timed out waiting for RisingWave to be ready via psql")
}
}
func (env *TestEnvironment) waitForRisingWave(timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
env.t.Logf(">>> Waiting for RisingWave to be ready (timeout %v)...\n", timeout)
for time.Now().Before(deadline) {
if output, err := runPostgresClientSQL(env.postgresSidecar, "SELECT 1;"); err == nil {
env.t.Logf(">>> RisingWave is ready.\n")
return true
} else {
env.t.Logf(">>> RisingWave not ready yet: %v (Output: %s)\n", err, string(output))
}
time.Sleep(5 * time.Second)
}
return false
}
func runPostgresClientSQL(containerName, sql string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "exec",
containerName,
"psql",
"-h", "127.0.0.1",
"-p", "4566",
"-U", "root",
"-d", "dev",
"-v", "ON_ERROR_STOP=1",
"-c", sql,
)
return cmd.CombinedOutput()
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.risingwaveContainer != "" {
if t.Failed() {
logs, err := exec.Command("docker", "logs", env.risingwaveContainer).CombinedOutput()
if err == nil {
env.t.Logf(">>> RisingWave Logs:\n%s\n", string(logs))
} else {
env.t.Logf(">>> Failed to get RisingWave logs: %v\n", err)
}
}
_ = exec.Command("docker", "rm", "-f", env.risingwaveContainer).Run()
}
if env.postgresSidecar != "" {
_ = exec.Command("docker", "rm", "-f", env.postgresSidecar).Run()
}
if env.seaweedfsDataDir != "" && t.Failed() {
logPath := filepath.Join(env.seaweedfsDataDir, "seaweedfs.log")
if content, err := os.ReadFile(logPath); err == nil {
env.t.Logf(">>> SeaweedFS Logs:\n%s\n", string(content))
}
env.t.Logf(">>> Filer Contents:\n")
listFilerContents(t, env, "/")
}
if env.masterProcess != nil && env.masterProcess.Process != nil {
_ = env.masterProcess.Process.Kill()
_ = env.masterProcess.Wait()
}
clearMiniProcess(env.masterProcess)
if env.seaweedfsDataDir != "" {
if env.logFile != nil {
env.logFile.Close()
}
_ = os.RemoveAll(env.seaweedfsDataDir)
}
}
func runRisingWaveSQL(t *testing.T, containerName, sql string) string {
t.Helper()
output, err := runPostgresClientSQL(containerName, sql)
if err != nil {
t.Fatalf("RisingWave command failed: %v\nSQL: %s\nOutput:\n%s", err, sql, string(output))
}
return string(output)
}
func createTableBucket(t *testing.T, env *TestEnvironment, bucketName string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "weed", "shell",
fmt.Sprintf("-master=%s", env.hostMasterAddress()),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf("s3tables.bucket -create -name %s -account 000000000000\nexit\n", bucketName))
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("failed to create table bucket %s via weed shell: %v\nOutput: %s", bucketName, err, string(output))
}
}
func doIcebergSignedJSONRequest(env *TestEnvironment, method, path string, payload any) (int, string, error) {
url := env.hostIcebergEndpoint() + path
var body io.Reader
var payloadHash string
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return 0, "", err
}
body = bytes.NewReader(data)
// hash := sha256.Sum256(data)
// payloadHash = hex.EncodeToString(hash[:])
payloadHash = "UNSIGNED-PAYLOAD"
} else {
payloadHash = "UNSIGNED-PAYLOAD"
}
req, err := http.NewRequest(method, url, body)
if err != nil {
return 0, "", fmt.Errorf("failed to create request: %w", err)
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
// Sign the request
credsProvider := credentials.NewStaticCredentialsProvider(env.accessKey, env.secretKey, "")
creds, err := credsProvider.Retrieve(context.Background())
if err != nil {
return 0, "", fmt.Errorf("failed to retrieve credentials: %w", err)
}
signer := v4.NewSigner()
if err := signer.SignHTTP(context.Background(), creds, req, payloadHash, "s3", "us-east-1", time.Now()); err != nil {
return 0, "", fmt.Errorf("failed to sign request: %w", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return 0, "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return 0, "", fmt.Errorf("failed to read response body: %w", err)
}
return resp.StatusCode, string(respBody), nil
}
func createIcebergNamespace(t *testing.T, env *TestEnvironment, namespace string) {
t.Helper()
status, raw, err := doIcebergSignedJSONRequest(env, "POST", "/v1/namespaces", map[string]any{
"namespace": []string{namespace},
})
if err != nil {
t.Fatalf("failed to create Iceberg namespace %s: %v", namespace, err)
}
if status != 200 && status != 409 {
t.Fatalf("failed to create Iceberg namespace %s: status %d body: %s", namespace, status, raw)
}
}
func createIcebergTable(t *testing.T, env *TestEnvironment, bucketName, namespace, tableName string) {
t.Helper()
createPath := fmt.Sprintf("/v1/namespaces/%s/tables", namespace)
status, raw, err := doIcebergSignedJSONRequest(env, "POST", createPath, map[string]any{
"name": tableName,
"location": fmt.Sprintf("s3://%s/%s/%s", bucketName, namespace, tableName),
"schema": map[string]any{
"type": "struct",
"fields": []map[string]any{
{"id": 1, "name": "id", "required": false, "type": "int"},
{"id": 2, "name": "name", "required": false, "type": "string"},
},
},
})
if err != nil {
t.Fatalf("failed to create Iceberg table %s.%s in bucket %s: %v", namespace, tableName, bucketName, err)
}
if status != 200 && status != 409 {
t.Fatalf("failed to create Iceberg table %s.%s in bucket %s: status %d body: %s", namespace, tableName, bucketName, status, raw)
}
}
func listFilerContents(t *testing.T, env *TestEnvironment, path string) {
t.Helper()
cmd := exec.Command("weed", "shell",
fmt.Sprintf("-master=%s", env.hostMasterAddress()),
)
cmd.Stdin = strings.NewReader(fmt.Sprintf("fs.ls -R %s\nexit\n", path))
output, err := cmd.CombinedOutput()
if err != nil {
env.t.Logf(">>> Warning: failed to list filer contents: %v\nOutput: %s\n", err, string(output))
} else {
env.t.Logf("%s\n", string(output))
}
}
func randomString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
@@ -326,7 +326,6 @@ spark = (SparkSession.builder
.config("spark.sql.catalog.iceberg.rest.auth.type", "sigv4")
.config("spark.sql.catalog.iceberg.rest.auth.sigv4.delegate-auth-type", "none")
.config("spark.sql.catalog.iceberg.rest.sigv4-enabled", "true")
.config("spark.sql.catalog.iceberg.rest.signing-region", "us-west-2")
.config("spark.sql.catalog.iceberg.rest.signing-name", "s3")
.config("spark.sql.catalog.iceberg.rest.access-key-id", "test")
.config("spark.sql.catalog.iceberg.rest.secret-access-key", "test")
+341
View File
@@ -0,0 +1,341 @@
package lakekeeper
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/s3tables/testutil"
)
type TestEnvironment 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
}
func TestLakekeeperIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
if !testutil.HasDocker() {
t.Skip("Docker not available, skipping Lakekeeper integration test")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
fmt.Printf(">>> Starting SeaweedFS with Lakekeeper configuration...\n")
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
// Run python script in docker to test STS and S3 operations
runLakekeeperRepro(t, env)
}
func NewTestEnvironment(t *testing.T) *TestEnvironment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
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-lakekeeper-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
bindIP := testutil.FindBindIP()
masterPort, masterGrpcPort := testutil.MustFreePortPair(t, "Master")
volumePort, volumeGrpcPort := testutil.MustFreePortPair(t, "Volume")
filerPort, filerGrpcPort := testutil.MustFreePortPair(t, "Filer")
s3Port, s3GrpcPort := testutil.MustFreePortPair(t, "S3")
return &TestEnvironment{
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",
}
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
iamConfigPath := filepath.Join(env.dataDir, "iam.json")
// Note: signingKey must be base64 encoded for []byte JSON unmarshaling
iamConfig := fmt.Sprintf(`{
"identities": [
{
"name": "admin",
"credentials": [
{
"accessKey": "%s",
"secretKey": "%s"
}
],
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
}
],
"sts": {
"tokenDuration": "12h",
"maxSessionLength": "24h",
"issuer": "seaweedfs-sts",
"signingKey": "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz"
},
"roles": [
{
"roleName": "LakekeeperVendedRole",
"roleArn": "arn:aws:iam::000000000000:role/LakekeeperVendedRole",
"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": "*"
}
]
}
}
]
}`, env.accessKey, env.secretKey)
if err := os.WriteFile(iamConfigPath, []byte(iamConfig), 0644); err != nil {
t.Fatalf("Failed to create IAM config: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
// Start weed mini with both S3 config (standard IAM) and IAM config (advanced IAM/STS)
cmd := exec.CommandContext(ctx, env.weedBinary, "-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,
"-s3.iam.config", iamConfigPath,
"-s3.iam.readOnly=false",
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("Failed to start SeaweedFS: %v", err)
}
env.weedProcess = cmd
if !testutil.WaitForService(fmt.Sprintf("http://localhost:%d/status", env.s3Port), 30*time.Second) {
t.Fatalf("S3 API failed to become ready")
}
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
time.Sleep(1 * time.Second)
_ = env.weedProcess.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
func runLakekeeperRepro(t *testing.T, env *TestEnvironment) {
t.Helper()
scriptContent := fmt.Sprintf(`
import boto3
import botocore.config
import botocore
from botocore.exceptions import ClientError
import os
import sys
import time
import logging
# Enable botocore debug logging to see signature calculation
logging.basicConfig(level=logging.DEBUG)
botocore.session.get_session().set_debug_logger()
print("Starting Lakekeeper repro test...")
endpoint_url = "http://host.docker.internal:%d"
access_key = "%s"
secret_key = "%s"
region = "us-east-1"
print(f"Connecting to {endpoint_url}")
try:
config = botocore.config.Config(
retries={'max_attempts': 3}
)
sts = boto3.client(
'sts',
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
config=config
)
role_arn = "arn:aws:iam::000000000000:role/LakekeeperVendedRole"
session_name = "lakekeeper-session"
print(f"Calling AssumeRole on {role_arn} with POST body...")
# Standard boto3 call sends parameters in POST body
response = sts.assume_role(
RoleArn=role_arn,
RoleSessionName=session_name
)
creds = response['Credentials']
access_key_id = creds['AccessKeyId']
secret_access_key = creds['SecretAccessKey']
session_token = creds['SessionToken']
print(f"Success! Got credentials with prefix: {access_key_id[:4]}")
if not access_key_id.startswith("ASIA"):
print(f"FAILED: Expected ASIA prefix, got {access_key_id}")
sys.exit(1)
print("Verifying S3 operations with vended credentials...")
s3 = boto3.client(
's3',
endpoint_url=endpoint_url,
aws_access_key_id=access_key_id,
aws_secret_access_key=secret_access_key,
aws_session_token=session_token,
region_name=region,
config=config
)
bucket = "lakekeeper-vended-bucket"
print(f"Creating bucket {bucket}...")
s3.create_bucket(Bucket=bucket)
print("Listing buckets...")
response = s3.list_buckets()
buckets = [b['Name'] for b in response['Buckets']]
print(f"Found buckets: {buckets}")
if bucket not in buckets:
print(f"FAILED: Bucket {bucket} not found in list")
sys.exit(1)
print("SUCCESS: Lakekeeper flow verified!")
sys.exit(0)
except Exception as e:
print(f"FAILED: {e}")
# Print more details if it is a ClientError
if hasattr(e, 'response'):
print(f"Response: {e.response}")
sys.exit(1)
`, env.s3Port, env.accessKey, env.secretKey)
scriptPath := filepath.Join(env.dataDir, "lakekeeper_repro.py")
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
t.Fatalf("Failed to write python script: %v", err)
}
containerName := "seaweed-lakekeeper-client-" + fmt.Sprintf("%d", time.Now().UnixNano())
// Create a context with timeout for the docker run command
dockerCtx, dockerCancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer dockerCancel()
cmd := exec.CommandContext(dockerCtx, "docker", "run", "--rm",
"--name", containerName,
"--add-host", "host.docker.internal:host-gateway",
"-v", fmt.Sprintf("%s:/work", env.dataDir),
"python:3",
"/bin/bash", "-c", "pip install boto3 && python /work/lakekeeper_repro.py",
)
output, err := cmd.CombinedOutput()
if err != nil {
if dockerCtx.Err() == context.DeadlineExceeded {
t.Fatalf("Lakekeeper repro client timed out after 5 minutes\nOutput:\n%s", string(output))
}
t.Fatalf("Lakekeeper repro client failed: %v\nOutput:\n%s", err, string(output))
}
t.Logf("Lakekeeper repro client output:\n%s", string(output))
}
@@ -0,0 +1,409 @@
package sts_integration
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/s3tables/testutil"
)
// TestEnvironment mirrors the one in trino_catalog_test.go but simplified
type TestEnvironment 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
dockerAvailable bool
accessKey string
secretKey string
}
const testSTSIntegrationSigningKey = "dGVzdC1zaWduaW5nLWtleS1mb3Itc3RzLWludGVncmF0aW9uLXRlc3Rz" // gitleaks:allow - test-signing-key-for-sts-integration-tests
func TestSTSIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
env := NewTestEnvironment(t)
defer env.Cleanup(t)
if !env.dockerAvailable {
t.Skip("Docker not available, skipping STS integration test")
}
fmt.Printf(">>> Starting SeaweedFS...\n")
env.StartSeaweedFS(t)
fmt.Printf(">>> SeaweedFS started.\n")
// Run python script in docker to test STS
runPythonSTSClient(t, env)
}
func NewTestEnvironment(t *testing.T) *TestEnvironment {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
seaweedDir := wd
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(seaweedDir, "go.mod")); err == nil {
break
}
seaweedDir = filepath.Dir(seaweedDir)
}
weedBinary := filepath.Join(seaweedDir, "weed", "weed")
info, err := os.Stat(weedBinary)
if err != nil || info.IsDir() {
weedBinary = "weed"
if _, err := exec.LookPath(weedBinary); err != nil {
t.Skip("weed binary not found, skipping integration test")
}
}
if !testutil.HasDocker() {
t.Skip("Docker not available, skipping integration test")
}
// Create a unique temporary directory for this test run
dataDir, err := os.MkdirTemp("", "seaweed-sts-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
// The Cleanup method will remove this directory, so no need for defer here.
bindIP := testutil.FindBindIP()
masterPort, masterGrpcPort := testutil.MustFreePortPair(t, "Master")
volumePort, volumeGrpcPort := testutil.MustFreePortPair(t, "Volume")
filerPort, filerGrpcPort := testutil.MustFreePortPair(t, "Filer")
s3Port, s3GrpcPort := testutil.MustFreePortPair(t, "S3") // Changed to use testutil.MustFreePortPair
return &TestEnvironment{
seaweedDir: seaweedDir,
weedBinary: weedBinary,
dataDir: dataDir,
bindIP: bindIP,
s3Port: s3Port,
s3GrpcPort: s3GrpcPort,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
filerPort: filerPort,
filerGrpcPort: filerGrpcPort,
volumePort: volumePort,
volumeGrpcPort: volumeGrpcPort,
dockerAvailable: testutil.HasDocker(),
accessKey: "admin",
secretKey: "adminadmin",
}
}
func (env *TestEnvironment) StartSeaweedFS(t *testing.T) {
t.Helper()
iamConfigPath := filepath.Join(env.dataDir, "iam.json")
// Note: signingKey must be base64 encoded for []byte JSON unmarshaling
iamConfig := fmt.Sprintf(`{
"identities": [
{
"name": "admin",
"credentials": [
{ "accessKey": "%s", "secretKey": "%s" }
],
"actions": ["Admin", "Read", "Write", "List", "Tagging"]
}
],
"sts": {
"tokenDuration": "1h",
"maxSessionLength": "12h",
"issuer": "seaweedfs-sts",
"signingKey": "%s"
},
"policy": {
"defaultEffect": "Deny",
"storeType": "memory"
},
"policies": [
{
"name": "S3FullAccessPolicy",
"document": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": ["*"]
}
]
}
}
],
"roles": [
{
"roleName": "TestRole",
"roleArn": "arn:aws:iam::role/TestRole",
"attachedPolicies": ["S3FullAccessPolicy"],
"trustPolicy": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["sts:AssumeRole"]
}
]
}
}
]
}`, env.accessKey, env.secretKey, testSTSIntegrationSigningKey)
if err := os.WriteFile(iamConfigPath, []byte(iamConfig), 0644); err != nil {
t.Fatalf("Failed to create IAM config: %v", err)
}
// Create empty security.toml
securityToml := filepath.Join(env.dataDir, "security.toml")
if err := os.WriteFile(securityToml, []byte("# Empty security config for testing\n"), 0644); err != nil {
t.Fatalf("Failed to create security.toml: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
env.weedCancel = cancel
cmd := exec.CommandContext(ctx, env.weedBinary, "mini",
"-master.port", fmt.Sprintf("%d", env.masterPort),
"-master.port.grpc", fmt.Sprintf("%d", env.masterGrpcPort),
"-volume.port", fmt.Sprintf("%d", env.volumePort),
"-volume.port.grpc", fmt.Sprintf("%d", env.volumeGrpcPort),
"-filer.port", fmt.Sprintf("%d", env.filerPort),
"-filer.port.grpc", fmt.Sprintf("%d", env.filerGrpcPort),
"-s3.port", fmt.Sprintf("%d", env.s3Port),
"-s3.port.grpc", fmt.Sprintf("%d", env.s3GrpcPort),
"-s3.config", iamConfigPath,
"-s3.iam.config", iamConfigPath,
"-s3.iam.readOnly", "false",
"-ip", env.bindIP,
"-ip.bind", "0.0.0.0",
"-dir", env.dataDir,
)
cmd.Dir = env.dataDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("Failed to start SeaweedFS: %v", err)
}
env.weedProcess = cmd
// Wait for S3 API to be ready
if !testutil.WaitForService(fmt.Sprintf("http://localhost:%d/status", env.s3Port), 30*time.Second) {
t.Fatalf("S3 API failed to become ready")
}
}
func (env *TestEnvironment) Start(t *testing.T) {
if !testutil.HasDocker() {
t.Skip("Docker not available")
}
}
func (env *TestEnvironment) Cleanup(t *testing.T) {
t.Helper()
if env.weedCancel != nil {
env.weedCancel()
}
if env.weedProcess != nil {
time.Sleep(1 * time.Second)
_ = env.weedProcess.Wait()
}
if env.dataDir != "" {
_ = os.RemoveAll(env.dataDir)
}
}
func runPythonSTSClient(t *testing.T, env *TestEnvironment) {
t.Helper()
// Write python script to temp dir
scriptContent := fmt.Sprintf(`
import boto3
import botocore.config
from botocore.exceptions import ClientError
import json
import sys
import time
import urllib.error
import urllib.request
print("Starting STS inline session policy test...")
primary_endpoint = "http://host.docker.internal:%d"
fallback_endpoint = "http://%s:%d"
access_key = "%s"
secret_key = "%s"
region = "us-east-1"
try:
def wait_for_endpoint(url, timeout=30):
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2):
return True
except urllib.error.HTTPError:
return True
except Exception:
time.sleep(1)
return False
def select_endpoint(urls):
for url in urls:
if wait_for_endpoint(url):
return url
raise Exception("No reachable S3 endpoint from container")
endpoint_url = select_endpoint([primary_endpoint, fallback_endpoint])
print(f"Using endpoint {endpoint_url}")
config = botocore.config.Config(
retries={'max_attempts': 0},
s3={'addressing_style': 'path'}
)
admin_s3 = boto3.client(
's3',
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
config=config
)
bucket = f"sts-inline-policy-{int(time.time() * 1000)}"
key = "allowed.txt"
print(f"Creating bucket {bucket} with admin credentials")
admin_s3.create_bucket(Bucket=bucket)
admin_s3.put_object(Bucket=bucket, Key=key, Body=b"ok")
sts = boto3.client(
'sts',
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
config=config
)
role_arn = "arn:aws:iam::role/TestRole"
session_name = "test-session"
session_policy = json.dumps({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": [f"arn:aws:s3:::{bucket}"]
},
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": [f"arn:aws:s3:::{bucket}/*"]
}
]
})
print(f"Calling AssumeRole on {role_arn} with inline session policy")
response = sts.assume_role(
RoleArn=role_arn,
RoleSessionName=session_name,
Policy=session_policy
)
creds = response['Credentials']
vended_s3 = boto3.client(
's3',
endpoint_url=endpoint_url,
aws_access_key_id=creds['AccessKeyId'],
aws_secret_access_key=creds['SecretAccessKey'],
aws_session_token=creds['SessionToken'],
region_name=region,
config=config
)
print("Listing objects (allowed)")
list_resp = vended_s3.list_objects_v2(Bucket=bucket)
keys = [obj.get('Key') for obj in list_resp.get('Contents', [])]
if key not in keys:
print(f"FAILED: Expected to see {key} in list_objects_v2 results")
sys.exit(1)
print("Getting object (allowed)")
body = vended_s3.get_object(Bucket=bucket, Key=key)['Body'].read()
if body != b"ok":
print("FAILED: Unexpected object content")
sys.exit(1)
print("Putting object (expected to be denied)")
try:
vended_s3.put_object(Bucket=bucket, Key="denied.txt", Body=b"no")
print("FAILED: PutObject unexpectedly succeeded")
sys.exit(1)
except ClientError as e:
error_code = e.response.get('Error', {}).get('Code', '')
if error_code != 'AccessDenied':
print(f"FAILED: Expected AccessDenied, got {error_code}")
sys.exit(1)
print("PutObject correctly denied by inline session policy")
print("SUCCESS: Inline session policy downscoping verified")
sys.exit(0)
except Exception as e:
print(f"FAILED: {e}")
if hasattr(e, 'response'):
print(f"Response: {e.response}")
import traceback
traceback.print_exc()
sys.exit(1)
`, env.s3Port, env.bindIP, env.s3Port, env.accessKey, env.secretKey)
scriptPath := filepath.Join(env.dataDir, "sts_test.py")
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0644); err != nil {
t.Fatalf("Failed to write python script: %v", err)
}
containerName := "seaweed-sts-client-" + fmt.Sprintf("%d", time.Now().UnixNano())
cmd := exec.Command("docker", "run", "--rm",
"--name", containerName,
"--add-host", "host.docker.internal:host-gateway",
"-v", fmt.Sprintf("%s:/work", env.dataDir),
"python:3",
"/bin/bash", "-c", "pip install boto3 && python /work/sts_test.py",
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Python STS client failed: %v\nOutput:\n%s", err, string(output))
}
t.Logf("Python STS client output:\n%s", string(output))
}
// Helpers copied from trino_catalog_test.go
+66
View File
@@ -0,0 +1,66 @@
package testutil
import (
"context"
"net"
"net/http"
"os/exec"
"testing"
"time"
)
func HasDocker() bool {
cmd := exec.Command("docker", "version")
return cmd.Run() == nil
}
func MustFreePortPair(t *testing.T, name string) (int, int) {
httpPort, grpcPort, err := findAvailablePortPair()
if err != nil {
t.Fatalf("Failed to get free port pair for %s: %v", name, err)
}
return httpPort, grpcPort
}
func findAvailablePortPair() (int, int, error) {
httpPort, err := GetFreePort()
if err != nil {
return 0, 0, err
}
grpcPort, err := GetFreePort()
if err != nil {
return 0, 0, err
}
return httpPort, grpcPort, nil
}
func GetFreePort() (int, error) {
listener, err := net.Listen("tcp", "0.0.0.0:0")
if err != nil {
return 0, err
}
defer listener.Close()
return listener.Addr().(*net.TCPAddr).Port, nil
}
func WaitForService(url string, timeout time.Duration) bool {
client := &http.Client{Timeout: 2 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return false
case <-ticker.C:
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
return true
}
}
}
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
.PHONY: test-volume-server test-volume-server-short
test-volume-server:
go test ./test/volume_server/... -v
test-volume-server-short:
go test ./test/volume_server/... -short -v
+27
View File
@@ -0,0 +1,27 @@
# Volume Server Integration Tests
This package contains integration tests for SeaweedFS volume server HTTP and gRPC APIs.
## Run Tests
Run tests from repo root:
```bash
go test ./test/volume_server/... -v
```
If a `weed` binary is not found, the harness will build one automatically.
## Optional environment variables
- `WEED_BINARY`: explicit path to the `weed` executable (disables auto-build).
- `VOLUME_SERVER_IT_KEEP_LOGS=1`: keep temporary test directories and process logs.
## Current scope (Phase 0)
- Shared cluster/framework utilities
- Matrix profile definitions
- Initial HTTP admin endpoint checks
- Initial gRPC state/status checks
More API coverage is tracked in `/Users/chris/dev/seaweedfs2/test/volume_server/DEV_PLAN.md`.
+442
View File
@@ -0,0 +1,442 @@
package framework
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
const (
defaultWaitTimeout = 30 * time.Second
defaultWaitTick = 200 * time.Millisecond
testVolumeSizeLimitMB = 32
)
// Cluster is a lightweight SeaweedFS master + one volume server test harness.
type Cluster struct {
testingTB testing.TB
profile matrix.Profile
weedBinary string
baseDir string
configDir string
logsDir string
keepLogs bool
masterPort int
masterGrpcPort int
volumePort int
volumeGrpcPort int
volumePubPort int
masterCmd *exec.Cmd
volumeCmd *exec.Cmd
cleanupOnce sync.Once
}
// StartSingleVolumeCluster boots one master and one volume server.
func StartSingleVolumeCluster(t testing.TB, profile matrix.Profile) *Cluster {
t.Helper()
weedBinary, err := FindOrBuildWeedBinary()
if err != nil {
t.Fatalf("resolve weed binary: %v", err)
}
baseDir, keepLogs, err := newWorkDir()
if err != nil {
t.Fatalf("create temp test directory: %v", err)
}
configDir := filepath.Join(baseDir, "config")
logsDir := filepath.Join(baseDir, "logs")
masterDataDir := filepath.Join(baseDir, "master")
volumeDataDir := filepath.Join(baseDir, "volume")
for _, dir := range []string{configDir, logsDir, masterDataDir, volumeDataDir} {
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
t.Fatalf("create %s: %v", dir, mkErr)
}
}
if err = writeSecurityConfig(configDir, profile); err != nil {
t.Fatalf("write security config: %v", err)
}
masterPort, masterGrpcPort, err := allocateMasterPortPair()
if err != nil {
t.Fatalf("allocate master port pair: %v", err)
}
ports, err := allocatePorts(3)
if err != nil {
t.Fatalf("allocate ports: %v", err)
}
c := &Cluster{
testingTB: t,
profile: profile,
weedBinary: weedBinary,
baseDir: baseDir,
configDir: configDir,
logsDir: logsDir,
keepLogs: keepLogs,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
volumePort: ports[0],
volumeGrpcPort: ports[1],
volumePubPort: ports[0],
}
if profile.SplitPublicPort {
c.volumePubPort = ports[2]
}
if err = c.startMaster(masterDataDir); err != nil {
c.Stop()
t.Fatalf("start master: %v", err)
}
if err = c.waitForHTTP(c.MasterURL() + "/dir/status"); err != nil {
masterLog := c.tailLog("master.log")
c.Stop()
t.Fatalf("wait for master readiness: %v\nmaster log tail:\n%s", err, masterLog)
}
if err = c.startVolume(volumeDataDir); err != nil {
masterLog := c.tailLog("master.log")
c.Stop()
t.Fatalf("start volume: %v\nmaster log tail:\n%s", err, masterLog)
}
if err = c.waitForHTTP(c.VolumeAdminURL() + "/status"); err != nil {
volumeLog := c.tailLog("volume.log")
c.Stop()
t.Fatalf("wait for volume readiness: %v\nvolume log tail:\n%s", err, volumeLog)
}
if err = c.waitForTCP(c.VolumeGRPCAddress()); err != nil {
volumeLog := c.tailLog("volume.log")
c.Stop()
t.Fatalf("wait for volume grpc readiness: %v\nvolume log tail:\n%s", err, volumeLog)
}
t.Cleanup(func() {
c.Stop()
})
return c
}
// Stop terminates all processes and cleans temporary files.
func (c *Cluster) Stop() {
if c == nil {
return
}
c.cleanupOnce.Do(func() {
stopProcess(c.volumeCmd)
stopProcess(c.masterCmd)
if !c.keepLogs && !c.testingTB.Failed() {
_ = os.RemoveAll(c.baseDir)
} else if c.baseDir != "" {
c.testingTB.Logf("volume server integration logs kept at %s", c.baseDir)
}
})
}
func (c *Cluster) startMaster(dataDir string) error {
logFile, err := os.Create(filepath.Join(c.logsDir, "master.log"))
if err != nil {
return err
}
args := []string{
"-config_dir=" + c.configDir,
"master",
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(c.masterPort),
"-port.grpc=" + strconv.Itoa(c.masterGrpcPort),
"-mdir=" + dataDir,
"-peers=none",
"-volumeSizeLimitMB=" + strconv.Itoa(testVolumeSizeLimitMB),
"-defaultReplication=000",
}
c.masterCmd = exec.Command(c.weedBinary, args...)
c.masterCmd.Dir = c.baseDir
c.masterCmd.Stdout = logFile
c.masterCmd.Stderr = logFile
return c.masterCmd.Start()
}
func (c *Cluster) startVolume(dataDir string) error {
logFile, err := os.Create(filepath.Join(c.logsDir, "volume.log"))
if err != nil {
return err
}
args := []string{
"-config_dir=" + c.configDir,
"volume",
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(c.volumePort),
"-port.grpc=" + strconv.Itoa(c.volumeGrpcPort),
"-port.public=" + strconv.Itoa(c.volumePubPort),
"-dir=" + dataDir,
"-max=16",
"-master=127.0.0.1:" + strconv.Itoa(c.masterPort),
"-readMode=" + c.profile.ReadMode,
"-concurrentUploadLimitMB=" + strconv.Itoa(c.profile.ConcurrentUploadLimitMB),
"-concurrentDownloadLimitMB=" + strconv.Itoa(c.profile.ConcurrentDownloadLimitMB),
}
if c.profile.InflightUploadTimeout > 0 {
args = append(args, "-inflightUploadDataTimeout="+c.profile.InflightUploadTimeout.String())
}
if c.profile.InflightDownloadTimeout > 0 {
args = append(args, "-inflightDownloadDataTimeout="+c.profile.InflightDownloadTimeout.String())
}
c.volumeCmd = exec.Command(c.weedBinary, args...)
c.volumeCmd.Dir = c.baseDir
c.volumeCmd.Stdout = logFile
c.volumeCmd.Stderr = logFile
return c.volumeCmd.Start()
}
func (c *Cluster) waitForHTTP(url string) error {
client := &http.Client{Timeout: 1 * time.Second}
deadline := time.Now().Add(defaultWaitTimeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode < 500 {
return nil
}
}
time.Sleep(defaultWaitTick)
}
return fmt.Errorf("timed out waiting for %s", url)
}
func (c *Cluster) waitForTCP(addr string) error {
deadline := time.Now().Add(defaultWaitTimeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err == nil {
_ = conn.Close()
return nil
}
time.Sleep(defaultWaitTick)
}
return fmt.Errorf("timed out waiting for tcp %s", addr)
}
func stopProcess(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Signal(os.Interrupt)
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-time.After(10 * time.Second):
_ = cmd.Process.Kill()
<-done
case <-done:
}
}
func allocatePorts(count int) ([]int, error) {
listeners := make([]net.Listener, 0, count)
ports := make([]int, 0, count)
for i := 0; i < count; i++ {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
for _, ll := range listeners {
_ = ll.Close()
}
return nil, err
}
listeners = append(listeners, l)
ports = append(ports, l.Addr().(*net.TCPAddr).Port)
}
for _, l := range listeners {
_ = l.Close()
}
return ports, nil
}
func allocateMasterPortPair() (int, int, error) {
for masterPort := 10000; masterPort <= 55535; masterPort++ {
masterGrpcPort := masterPort + 10000
l1, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(masterPort)))
if err != nil {
continue
}
l2, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(masterGrpcPort)))
if err != nil {
_ = l1.Close()
continue
}
_ = l2.Close()
_ = l1.Close()
return masterPort, masterGrpcPort, nil
}
return 0, 0, errors.New("unable to find available master port pair")
}
func newWorkDir() (dir string, keepLogs bool, err error) {
keepLogs = os.Getenv("VOLUME_SERVER_IT_KEEP_LOGS") == "1"
dir, err = os.MkdirTemp("", "seaweedfs_volume_server_it_")
return dir, keepLogs, err
}
func writeSecurityConfig(configDir string, profile matrix.Profile) error {
var b strings.Builder
if profile.EnableJWT {
if profile.JWTSigningKey == "" || profile.JWTReadKey == "" {
return errors.New("jwt profile requires both write and read keys")
}
b.WriteString("[jwt.signing]\n")
b.WriteString("key = \"")
b.WriteString(profile.JWTSigningKey)
b.WriteString("\"\n")
b.WriteString("expires_after_seconds = 60\n\n")
b.WriteString("[jwt.signing.read]\n")
b.WriteString("key = \"")
b.WriteString(profile.JWTReadKey)
b.WriteString("\"\n")
b.WriteString("expires_after_seconds = 60\n")
}
if b.Len() == 0 {
b.WriteString("# optional security config generated for integration tests\n")
}
return os.WriteFile(filepath.Join(configDir, "security.toml"), []byte(b.String()), 0o644)
}
// FindOrBuildWeedBinary returns an executable weed binary, building one when needed.
func FindOrBuildWeedBinary() (string, error) {
if fromEnv := os.Getenv("WEED_BINARY"); fromEnv != "" {
if isExecutableFile(fromEnv) {
return fromEnv, nil
}
return "", fmt.Errorf("WEED_BINARY is set but not executable: %s", fromEnv)
}
repoRoot := ""
if _, file, _, ok := runtime.Caller(0); ok {
repoRoot = filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
candidate := filepath.Join(repoRoot, "weed", "weed")
if isExecutableFile(candidate) {
return candidate, nil
}
}
if repoRoot == "" {
return "", errors.New("unable to detect repository root")
}
binDir := filepath.Join(os.TempDir(), "seaweedfs_volume_server_it_bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
return "", fmt.Errorf("create binary directory %s: %w", binDir, err)
}
binPath := filepath.Join(binDir, "weed")
if isExecutableFile(binPath) {
return binPath, nil
}
cmd := exec.Command("go", "build", "-o", binPath, ".")
cmd.Dir = filepath.Join(repoRoot, "weed")
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("build weed binary: %w\n%s", err, out.String())
}
if !isExecutableFile(binPath) {
return "", fmt.Errorf("built weed binary is not executable: %s", binPath)
}
return binPath, nil
}
func isExecutableFile(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return false
}
mode := info.Mode().Perm()
return mode&0o111 != 0
}
func (c *Cluster) tailLog(logName string) string {
f, err := os.Open(filepath.Join(c.logsDir, logName))
if err != nil {
return ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
lines := make([]string, 0, 40)
for scanner.Scan() {
lines = append(lines, scanner.Text())
if len(lines) > 40 {
lines = lines[1:]
}
}
return strings.Join(lines, "\n")
}
func (c *Cluster) MasterAddress() string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.masterPort))
}
func (c *Cluster) VolumeAdminAddress() string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePort))
}
func (c *Cluster) VolumePublicAddress() string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePubPort))
}
func (c *Cluster) VolumeGRPCAddress() string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumeGrpcPort))
}
// VolumeServerAddress returns SeaweedFS server address format: ip:httpPort.grpcPort
func (c *Cluster) VolumeServerAddress() string {
return fmt.Sprintf("%s.%d", c.VolumeAdminAddress(), c.volumeGrpcPort)
}
func (c *Cluster) MasterURL() string {
return "http://" + c.MasterAddress()
}
func (c *Cluster) VolumeAdminURL() string {
return "http://" + c.VolumeAdminAddress()
}
func (c *Cluster) VolumePublicURL() string {
return "http://" + c.VolumePublicAddress()
}
func (c *Cluster) BaseDir() string {
return c.baseDir
}
@@ -0,0 +1,293 @@
package framework
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"sync"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
type DualVolumeCluster struct {
testingTB testing.TB
profile matrix.Profile
weedBinary string
baseDir string
configDir string
logsDir string
keepLogs bool
masterPort int
masterGrpcPort int
volumePort0 int
volumeGrpcPort0 int
volumePubPort0 int
volumePort1 int
volumeGrpcPort1 int
volumePubPort1 int
masterCmd *exec.Cmd
volumeCmd0 *exec.Cmd
volumeCmd1 *exec.Cmd
cleanupOnce sync.Once
}
func StartDualVolumeCluster(t testing.TB, profile matrix.Profile) *DualVolumeCluster {
t.Helper()
weedBinary, err := FindOrBuildWeedBinary()
if err != nil {
t.Fatalf("resolve weed binary: %v", err)
}
baseDir, keepLogs, err := newWorkDir()
if err != nil {
t.Fatalf("create temp test directory: %v", err)
}
configDir := filepath.Join(baseDir, "config")
logsDir := filepath.Join(baseDir, "logs")
masterDataDir := filepath.Join(baseDir, "master")
volumeDataDir0 := filepath.Join(baseDir, "volume0")
volumeDataDir1 := filepath.Join(baseDir, "volume1")
for _, dir := range []string{configDir, logsDir, masterDataDir, volumeDataDir0, volumeDataDir1} {
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
t.Fatalf("create %s: %v", dir, mkErr)
}
}
if err = writeSecurityConfig(configDir, profile); err != nil {
t.Fatalf("write security config: %v", err)
}
masterPort, masterGrpcPort, err := allocateMasterPortPair()
if err != nil {
t.Fatalf("allocate master port pair: %v", err)
}
ports, err := allocatePorts(6)
if err != nil {
t.Fatalf("allocate volume ports: %v", err)
}
c := &DualVolumeCluster{
testingTB: t,
profile: profile,
weedBinary: weedBinary,
baseDir: baseDir,
configDir: configDir,
logsDir: logsDir,
keepLogs: keepLogs,
masterPort: masterPort,
masterGrpcPort: masterGrpcPort,
volumePort0: ports[0],
volumeGrpcPort0: ports[1],
volumePubPort0: ports[0],
volumePort1: ports[2],
volumeGrpcPort1: ports[3],
volumePubPort1: ports[2],
}
if profile.SplitPublicPort {
c.volumePubPort0 = ports[4]
c.volumePubPort1 = ports[5]
}
if err = c.startMaster(masterDataDir); err != nil {
c.Stop()
t.Fatalf("start master: %v", err)
}
if err = c.waitForHTTP(c.MasterURL() + "/dir/status"); err != nil {
masterLog := c.tailLog("master.log")
c.Stop()
t.Fatalf("wait for master readiness: %v\nmaster log tail:\n%s", err, masterLog)
}
if err = c.startVolume(0, volumeDataDir0); err != nil {
masterLog := c.tailLog("master.log")
c.Stop()
t.Fatalf("start first volume server: %v\nmaster log tail:\n%s", err, masterLog)
}
if err = c.waitForHTTP(c.VolumeAdminURL(0) + "/status"); err != nil {
volumeLog := c.tailLog("volume0.log")
c.Stop()
t.Fatalf("wait for first volume readiness: %v\nvolume log tail:\n%s", err, volumeLog)
}
if err = c.waitForTCP(c.VolumeGRPCAddress(0)); err != nil {
volumeLog := c.tailLog("volume0.log")
c.Stop()
t.Fatalf("wait for first volume grpc readiness: %v\nvolume log tail:\n%s", err, volumeLog)
}
if err = c.startVolume(1, volumeDataDir1); err != nil {
volumeLog := c.tailLog("volume0.log")
c.Stop()
t.Fatalf("start second volume server: %v\nfirst volume log tail:\n%s", err, volumeLog)
}
if err = c.waitForHTTP(c.VolumeAdminURL(1) + "/status"); err != nil {
volumeLog := c.tailLog("volume1.log")
c.Stop()
t.Fatalf("wait for second volume readiness: %v\nvolume log tail:\n%s", err, volumeLog)
}
if err = c.waitForTCP(c.VolumeGRPCAddress(1)); err != nil {
volumeLog := c.tailLog("volume1.log")
c.Stop()
t.Fatalf("wait for second volume grpc readiness: %v\nvolume log tail:\n%s", err, volumeLog)
}
t.Cleanup(func() {
c.Stop()
})
return c
}
func (c *DualVolumeCluster) Stop() {
if c == nil {
return
}
c.cleanupOnce.Do(func() {
stopProcess(c.volumeCmd1)
stopProcess(c.volumeCmd0)
stopProcess(c.masterCmd)
if !c.keepLogs && !c.testingTB.Failed() {
_ = os.RemoveAll(c.baseDir)
} else if c.baseDir != "" {
c.testingTB.Logf("volume server integration logs kept at %s", c.baseDir)
}
})
}
func (c *DualVolumeCluster) startMaster(dataDir string) error {
logFile, err := os.Create(filepath.Join(c.logsDir, "master.log"))
if err != nil {
return err
}
args := []string{
"-config_dir=" + c.configDir,
"master",
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(c.masterPort),
"-port.grpc=" + strconv.Itoa(c.masterGrpcPort),
"-mdir=" + dataDir,
"-peers=none",
"-volumeSizeLimitMB=" + strconv.Itoa(testVolumeSizeLimitMB),
"-defaultReplication=000",
}
c.masterCmd = exec.Command(c.weedBinary, args...)
c.masterCmd.Dir = c.baseDir
c.masterCmd.Stdout = logFile
c.masterCmd.Stderr = logFile
return c.masterCmd.Start()
}
func (c *DualVolumeCluster) startVolume(index int, dataDir string) error {
logName := fmt.Sprintf("volume%d.log", index)
logFile, err := os.Create(filepath.Join(c.logsDir, logName))
if err != nil {
return err
}
volumePort := c.volumePort0
volumeGrpcPort := c.volumeGrpcPort0
volumePubPort := c.volumePubPort0
if index == 1 {
volumePort = c.volumePort1
volumeGrpcPort = c.volumeGrpcPort1
volumePubPort = c.volumePubPort1
}
args := []string{
"-config_dir=" + c.configDir,
"volume",
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(volumePort),
"-port.grpc=" + strconv.Itoa(volumeGrpcPort),
"-port.public=" + strconv.Itoa(volumePubPort),
"-dir=" + dataDir,
"-max=16",
"-master=127.0.0.1:" + strconv.Itoa(c.masterPort),
"-readMode=" + c.profile.ReadMode,
"-concurrentUploadLimitMB=" + strconv.Itoa(c.profile.ConcurrentUploadLimitMB),
"-concurrentDownloadLimitMB=" + strconv.Itoa(c.profile.ConcurrentDownloadLimitMB),
}
if c.profile.InflightUploadTimeout > 0 {
args = append(args, "-inflightUploadDataTimeout="+c.profile.InflightUploadTimeout.String())
}
if c.profile.InflightDownloadTimeout > 0 {
args = append(args, "-inflightDownloadDataTimeout="+c.profile.InflightDownloadTimeout.String())
}
cmd := exec.Command(c.weedBinary, args...)
cmd.Dir = c.baseDir
cmd.Stdout = logFile
cmd.Stderr = logFile
if err = cmd.Start(); err != nil {
return err
}
if index == 1 {
c.volumeCmd1 = cmd
} else {
c.volumeCmd0 = cmd
}
return nil
}
func (c *DualVolumeCluster) waitForHTTP(url string) error {
return (&Cluster{}).waitForHTTP(url)
}
func (c *DualVolumeCluster) waitForTCP(addr string) error {
return (&Cluster{}).waitForTCP(addr)
}
func (c *DualVolumeCluster) tailLog(logName string) string {
return (&Cluster{logsDir: c.logsDir}).tailLog(logName)
}
func (c *DualVolumeCluster) MasterAddress() string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.masterPort))
}
func (c *DualVolumeCluster) MasterURL() string {
return "http://" + c.MasterAddress()
}
func (c *DualVolumeCluster) VolumeAdminAddress(index int) string {
if index == 1 {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePort1))
}
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePort0))
}
func (c *DualVolumeCluster) VolumePublicAddress(index int) string {
if index == 1 {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePubPort1))
}
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumePubPort0))
}
func (c *DualVolumeCluster) VolumeGRPCAddress(index int) string {
if index == 1 {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumeGrpcPort1))
}
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.volumeGrpcPort0))
}
func (c *DualVolumeCluster) VolumeAdminURL(index int) string {
return "http://" + c.VolumeAdminAddress(index)
}
func (c *DualVolumeCluster) VolumePublicURL(index int) string {
return "http://" + c.VolumePublicAddress(index)
}
@@ -0,0 +1,91 @@
package framework
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
type ClusterWithFiler struct {
*Cluster
filerCmd *exec.Cmd
filerPort int
filerGrpcPort int
}
func StartSingleVolumeClusterWithFiler(t testing.TB, profile matrix.Profile) *ClusterWithFiler {
t.Helper()
baseCluster := StartSingleVolumeCluster(t, profile)
ports, err := allocatePorts(2)
if err != nil {
t.Fatalf("allocate filer ports: %v", err)
}
filerDataDir := filepath.Join(baseCluster.baseDir, "filer")
if mkErr := os.MkdirAll(filerDataDir, 0o755); mkErr != nil {
t.Fatalf("create filer data dir: %v", mkErr)
}
logFile, err := os.Create(filepath.Join(baseCluster.logsDir, "filer.log"))
if err != nil {
t.Fatalf("create filer log file: %v", err)
}
filerPort := ports[0]
filerGrpcPort := ports[1]
args := []string{
"-config_dir=" + baseCluster.configDir,
"filer",
"-master=127.0.0.1:" + strconv.Itoa(baseCluster.masterPort),
"-ip=127.0.0.1",
"-port=" + strconv.Itoa(filerPort),
"-port.grpc=" + strconv.Itoa(filerGrpcPort),
"-defaultStoreDir=" + filerDataDir,
}
filerCmd := exec.Command(baseCluster.weedBinary, args...)
filerCmd.Dir = baseCluster.baseDir
filerCmd.Stdout = logFile
filerCmd.Stderr = logFile
if err = filerCmd.Start(); err != nil {
t.Fatalf("start filer: %v", err)
}
if err = baseCluster.waitForTCP(net.JoinHostPort("127.0.0.1", strconv.Itoa(filerGrpcPort))); err != nil {
filerLogTail := baseCluster.tailLog("filer.log")
stopProcess(filerCmd)
t.Fatalf("wait for filer grpc readiness: %v\nfiler log tail:\n%s", err, filerLogTail)
}
t.Cleanup(func() {
stopProcess(filerCmd)
})
return &ClusterWithFiler{
Cluster: baseCluster,
filerCmd: filerCmd,
filerPort: filerPort,
filerGrpcPort: filerGrpcPort,
}
}
func (c *ClusterWithFiler) FilerAddress() string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.filerPort))
}
func (c *ClusterWithFiler) FilerGRPCAddress() string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(c.filerGrpcPort))
}
func (c *ClusterWithFiler) FilerServerAddress() string {
return fmt.Sprintf("%s.%d", c.FilerAddress(), c.filerGrpcPort)
}
@@ -0,0 +1,8 @@
package framework
// Phase 0 placeholder for future fault injection utilities.
//
// Planned extensions:
// - restart/kill selected processes
// - temporary network isolation hooks
// - master or peer outage helpers for proxy/replication branch coverage
@@ -0,0 +1,28 @@
package framework
import (
"context"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func DialVolumeServer(t testing.TB, address string) (*grpc.ClientConn, volume_server_pb.VolumeServerClient) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conn, err := grpc.DialContext(ctx, address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
if err != nil {
t.Fatalf("dial volume grpc %s: %v", address, err)
}
return conn, volume_server_pb.NewVolumeServerClient(conn)
}
@@ -0,0 +1,34 @@
package framework
import (
"io"
"net/http"
"testing"
"time"
)
func NewHTTPClient() *http.Client {
return &http.Client{Timeout: 10 * time.Second}
}
func DoRequest(t testing.TB, client *http.Client, req *http.Request) *http.Response {
t.Helper()
resp, err := client.Do(req)
if err != nil {
t.Fatalf("http request %s %s: %v", req.Method, req.URL.String(), err)
}
return resp
}
func ReadAllAndClose(t testing.TB, resp *http.Response) []byte {
t.Helper()
if resp == nil {
return nil
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response body: %v", err)
}
return body
}
@@ -0,0 +1,56 @@
package framework
import (
"bytes"
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
)
func AllocateVolume(t testing.TB, client volume_server_pb.VolumeServerClient, volumeID uint32, collection string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := client.AllocateVolume(ctx, &volume_server_pb.AllocateVolumeRequest{
VolumeId: volumeID,
Collection: collection,
Replication: "000",
Version: uint32(needle.GetCurrentVersion()),
})
if err != nil {
t.Fatalf("allocate volume %d: %v", volumeID, err)
}
}
func NewFileID(volumeID uint32, key uint64, cookie uint32) string {
return needle.NewFileId(needle.VolumeId(volumeID), key, cookie).String()
}
func UploadBytes(t testing.TB, client *http.Client, volumeURL, fid string, data []byte) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/%s", volumeURL, fid), bytes.NewReader(data))
if err != nil {
t.Fatalf("build upload request: %v", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(data)))
return DoRequest(t, client, req)
}
func ReadBytes(t testing.TB, client *http.Client, volumeURL, fid string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/%s", volumeURL, fid), nil)
if err != nil {
t.Fatalf("build read request: %v", err)
}
return DoRequest(t, client, req)
}
+445
View File
@@ -0,0 +1,445 @@
package volume_server_grpc_test
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestVolumeNeedleStatusForUploadedFile(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(21)
const needleID = uint64(778899)
const cookie = uint32(0xA1B2C3D4)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, needleID, cookie)
client := framework.NewHTTPClient()
payload := []byte("needle-status-payload")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload status: expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
statusResp, err := grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
VolumeId: volumeID,
NeedleId: needleID,
})
if err != nil {
t.Fatalf("VolumeNeedleStatus failed: %v", err)
}
if statusResp.GetNeedleId() != needleID {
t.Fatalf("needle id mismatch: got %d want %d", statusResp.GetNeedleId(), needleID)
}
if statusResp.GetCookie() != cookie {
t.Fatalf("cookie mismatch: got %d want %d", statusResp.GetCookie(), cookie)
}
if statusResp.GetSize() == 0 {
t.Fatalf("expected non-zero needle size")
}
}
func TestVolumeNeedleStatusViaEcShardsWhenNormalVolumeUnmounted(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(26)
const needleID = uint64(778900)
const cookie = uint32(0xA1B2C3D5)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, needleID, cookie)
payload := []byte("needle-status-ec-path-payload")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload status: expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
})
if err != nil {
t.Fatalf("VolumeEcShardsMount data shards failed: %v", err)
}
_, err = grpcClient.VolumeUnmount(ctx, &volume_server_pb.VolumeUnmountRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("VolumeUnmount failed: %v", err)
}
statusResp, err := grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
VolumeId: volumeID,
NeedleId: needleID,
})
if err != nil {
t.Fatalf("VolumeNeedleStatus via EC shards failed: %v", err)
}
if statusResp.GetNeedleId() != needleID {
t.Fatalf("needle id mismatch: got %d want %d", statusResp.GetNeedleId(), needleID)
}
if statusResp.GetCookie() != cookie {
t.Fatalf("cookie mismatch: got %d want %d", statusResp.GetCookie(), cookie)
}
if statusResp.GetSize() == 0 {
t.Fatalf("expected non-zero needle size from EC-backed needle status")
}
_, err = grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
VolumeId: volumeID,
NeedleId: needleID + 999999,
})
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "not found") {
t.Fatalf("VolumeNeedleStatus via EC shards missing-needle error mismatch: %v", err)
}
}
func TestVolumeNeedleStatusMissingVolumeAndNeedle(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(25)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
VolumeId: 99925,
NeedleId: 1,
})
if err == nil {
t.Fatalf("VolumeNeedleStatus should fail for missing volume")
}
if !strings.Contains(strings.ToLower(err.Error()), "volume not found") {
t.Fatalf("VolumeNeedleStatus missing-volume error mismatch: %v", err)
}
_, err = grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
VolumeId: volumeID,
NeedleId: 123456789,
})
if err == nil {
t.Fatalf("VolumeNeedleStatus should fail for missing needle")
}
if !strings.Contains(strings.ToLower(err.Error()), "not found") {
t.Fatalf("VolumeNeedleStatus missing-needle error mismatch: %v", err)
}
}
func mustNewRequest(t testing.TB, method, url string) *http.Request {
t.Helper()
req, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatalf("create request %s %s: %v", method, url, err)
}
return req
}
func TestVolumeConfigureInvalidReplication(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(22)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := grpcClient.VolumeConfigure(ctx, &volume_server_pb.VolumeConfigureRequest{
VolumeId: volumeID,
Replication: "bad-replication",
})
if err != nil {
t.Fatalf("VolumeConfigure returned grpc error: %v", err)
}
if resp.GetError() == "" {
t.Fatalf("VolumeConfigure expected response error for invalid replication")
}
if !strings.Contains(strings.ToLower(resp.GetError()), "replication") {
t.Fatalf("VolumeConfigure error should mention replication, got: %q", resp.GetError())
}
}
func TestVolumeConfigureSuccessAndMissingRollbackPath(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(24)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
successResp, err := grpcClient.VolumeConfigure(ctx, &volume_server_pb.VolumeConfigureRequest{
VolumeId: volumeID,
Replication: "000",
})
if err != nil {
t.Fatalf("VolumeConfigure success path returned grpc error: %v", err)
}
if successResp.GetError() != "" {
t.Fatalf("VolumeConfigure success path expected empty response error, got: %q", successResp.GetError())
}
statusResp, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeStatus after successful configure failed: %v", err)
}
if statusResp.GetIsReadOnly() {
t.Fatalf("VolumeStatus after configure expected writable volume")
}
missingResp, err := grpcClient.VolumeConfigure(ctx, &volume_server_pb.VolumeConfigureRequest{
VolumeId: 99024,
Replication: "000",
})
if err != nil {
t.Fatalf("VolumeConfigure missing-volume branch should return response error, got grpc error: %v", err)
}
if missingResp.GetError() == "" {
t.Fatalf("VolumeConfigure missing-volume expected non-empty response error")
}
lower := strings.ToLower(missingResp.GetError())
if !strings.Contains(lower, "not found on disk") {
t.Fatalf("VolumeConfigure missing-volume error should mention not found on disk, got: %q", missingResp.GetError())
}
if !strings.Contains(lower, "failed to restore mount") {
t.Fatalf("VolumeConfigure missing-volume error should include remount rollback failure, got: %q", missingResp.GetError())
}
}
func TestPingVolumeTargetAndLeaveAffectsHealthz(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
pingResp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: cluster.VolumeServerType,
Target: clusterHarness.VolumeServerAddress(),
})
if err != nil {
t.Fatalf("Ping target volume server failed: %v", err)
}
if pingResp.GetRemoteTimeNs() == 0 {
t.Fatalf("expected remote timestamp from ping target volume server")
}
if _, err = grpcClient.VolumeServerLeave(ctx, &volume_server_pb.VolumeServerLeaveRequest{}); err != nil {
t.Fatalf("VolumeServerLeave failed: %v", err)
}
client := framework.NewHTTPClient()
healthURL := clusterHarness.VolumeAdminURL() + "/healthz"
deadline := time.Now().Add(5 * time.Second)
for {
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, healthURL))
_ = framework.ReadAllAndClose(t, resp)
if resp.StatusCode == http.StatusServiceUnavailable {
return
}
if time.Now().After(deadline) {
t.Fatalf("expected healthz to return 503 after leave, got %d", resp.StatusCode)
}
time.Sleep(100 * time.Millisecond)
}
}
func TestVolumeServerLeaveIsIdempotent(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := grpcClient.VolumeServerLeave(ctx, &volume_server_pb.VolumeServerLeaveRequest{}); err != nil {
t.Fatalf("first VolumeServerLeave failed: %v", err)
}
if _, err := grpcClient.VolumeServerLeave(ctx, &volume_server_pb.VolumeServerLeaveRequest{}); err != nil {
t.Fatalf("second VolumeServerLeave should be idempotent success, got: %v", err)
}
client := framework.NewHTTPClient()
healthURL := clusterHarness.VolumeAdminURL() + "/healthz"
deadline := time.Now().Add(5 * time.Second)
for {
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, healthURL))
_ = framework.ReadAllAndClose(t, resp)
if resp.StatusCode == http.StatusServiceUnavailable {
return
}
if time.Now().After(deadline) {
t.Fatalf("expected healthz to stay 503 after repeated leave, got %d", resp.StatusCode)
}
time.Sleep(100 * time.Millisecond)
}
}
func TestPingUnknownAndUnreachableTargetPaths(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
unknownResp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: "unknown-type",
Target: "127.0.0.1:12345",
})
if err != nil {
t.Fatalf("Ping unknown target type should not return grpc error, got: %v", err)
}
if unknownResp.GetRemoteTimeNs() != 0 {
t.Fatalf("Ping unknown target type expected remote_time_ns=0, got %d", unknownResp.GetRemoteTimeNs())
}
if unknownResp.GetStopTimeNs() < unknownResp.GetStartTimeNs() {
t.Fatalf("Ping unknown target type expected stop_time_ns >= start_time_ns")
}
_, err = grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: cluster.MasterType,
Target: "127.0.0.1:1",
})
if err == nil {
t.Fatalf("Ping master target should fail when target is unreachable")
}
if !strings.Contains(err.Error(), "ping master") {
t.Fatalf("Ping master unreachable error mismatch: %v", err)
}
_, err = grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: cluster.FilerType,
Target: "127.0.0.1:1",
})
if err == nil {
t.Fatalf("Ping filer target should fail when target is unreachable")
}
if !strings.Contains(err.Error(), "ping filer") {
t.Fatalf("Ping filer unreachable error mismatch: %v", err)
}
}
func TestPingMasterTargetSuccess(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: cluster.MasterType,
Target: clusterHarness.MasterAddress(),
})
if err != nil {
t.Fatalf("Ping master target success path failed: %v", err)
}
if resp.GetRemoteTimeNs() == 0 {
t.Fatalf("Ping master target expected non-zero remote time")
}
if resp.GetStopTimeNs() < resp.GetStartTimeNs() {
t.Fatalf("Ping master target expected stop >= start, got start=%d stop=%d", resp.GetStartTimeNs(), resp.GetStopTimeNs())
}
}
func TestPingFilerTargetSuccess(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeClusterWithFiler(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := grpcClient.Ping(ctx, &volume_server_pb.PingRequest{
TargetType: cluster.FilerType,
Target: clusterHarness.FilerServerAddress(),
})
if err != nil {
t.Fatalf("Ping filer target success path failed: %v", err)
}
if resp.GetRemoteTimeNs() == 0 {
t.Fatalf("Ping filer target expected non-zero remote time")
}
if resp.GetStopTimeNs() < resp.GetStartTimeNs() {
t.Fatalf("Ping filer target expected stop >= start, got start=%d stop=%d", resp.GetStartTimeNs(), resp.GetStopTimeNs())
}
}
@@ -0,0 +1,215 @@
package volume_server_grpc_test
import (
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestVolumeAdminLifecycleRPCs(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
const volumeID = uint32(11)
framework.AllocateVolume(t, client, volumeID, "")
statusResp, err := client.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeStatus failed: %v", err)
}
if statusResp.GetFileCount() != 0 {
t.Fatalf("new volume should be empty, got file_count=%d", statusResp.GetFileCount())
}
if _, err = client.VolumeUnmount(ctx, &volume_server_pb.VolumeUnmountRequest{VolumeId: volumeID}); err != nil {
t.Fatalf("VolumeUnmount failed: %v", err)
}
if _, err = client.VolumeMount(ctx, &volume_server_pb.VolumeMountRequest{VolumeId: volumeID}); err != nil {
t.Fatalf("VolumeMount failed: %v", err)
}
if _, err = client.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{VolumeId: volumeID, OnlyEmpty: true}); err != nil {
t.Fatalf("VolumeDelete failed: %v", err)
}
_, err = client.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err == nil {
t.Fatalf("VolumeStatus should fail after delete")
}
if st, ok := status.FromError(err); !ok || st.Code() == codes.OK {
t.Fatalf("VolumeStatus error should be a non-OK grpc status, got: %v", err)
}
}
func TestVolumeDeleteOnlyEmptyVariants(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(13)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 66001, 0x11223344)
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("volume-delete-only-empty"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{VolumeId: volumeID, OnlyEmpty: true})
if err == nil || !strings.Contains(err.Error(), "volume not empty") {
t.Fatalf("VolumeDelete only_empty=true expected volume-not-empty error, got: %v", err)
}
_, err = grpcClient.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{VolumeId: volumeID, OnlyEmpty: false})
if err != nil {
t.Fatalf("VolumeDelete only_empty=false failed: %v", err)
}
_, err = grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err == nil {
t.Fatalf("VolumeStatus should fail after non-empty delete with only_empty=false")
}
}
func TestMaintenanceModeRejectsAllocateVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stateResp, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = client.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
_, err = client.AllocateVolume(ctx, &volume_server_pb.AllocateVolumeRequest{VolumeId: 12, Replication: "000"})
if err == nil {
t.Fatalf("AllocateVolume should fail when maintenance mode is enabled")
}
if !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("expected maintenance mode error, got: %v", err)
}
}
func TestAllocateDuplicateAndMountUnmountMissingVariants(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
const missingVolumeID = uint32(99331)
const volumeID = uint32(14)
if _, err := client.VolumeUnmount(ctx, &volume_server_pb.VolumeUnmountRequest{VolumeId: missingVolumeID}); err != nil {
t.Fatalf("VolumeUnmount missing volume should be idempotent success, got: %v", err)
}
_, err := client.VolumeMount(ctx, &volume_server_pb.VolumeMountRequest{VolumeId: missingVolumeID})
if err == nil {
t.Fatalf("VolumeMount missing volume should fail")
}
if !strings.Contains(err.Error(), "not found on disk") {
t.Fatalf("VolumeMount missing volume error mismatch: %v", err)
}
framework.AllocateVolume(t, client, volumeID, "")
_, err = client.AllocateVolume(ctx, &volume_server_pb.AllocateVolumeRequest{
VolumeId: volumeID,
Replication: "000",
})
if err == nil {
t.Fatalf("AllocateVolume duplicate should fail")
}
if !strings.Contains(strings.ToLower(err.Error()), "already exists") {
t.Fatalf("AllocateVolume duplicate error mismatch: %v", err)
}
if _, err = client.VolumeUnmount(ctx, &volume_server_pb.VolumeUnmountRequest{VolumeId: volumeID}); err != nil {
t.Fatalf("VolumeUnmount existing volume failed: %v", err)
}
if _, err = client.VolumeUnmount(ctx, &volume_server_pb.VolumeUnmountRequest{VolumeId: volumeID}); err != nil {
t.Fatalf("VolumeUnmount already-unmounted volume should be idempotent success, got: %v", err)
}
if _, err = client.VolumeMount(ctx, &volume_server_pb.VolumeMountRequest{VolumeId: volumeID}); err != nil {
t.Fatalf("VolumeMount remount failed: %v", err)
}
}
func TestMaintenanceModeRejectsVolumeDelete(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(15)
framework.AllocateVolume(t, client, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stateResp, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = client.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
_, err = client.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{VolumeId: volumeID, OnlyEmpty: true})
if err == nil {
t.Fatalf("VolumeDelete should fail when maintenance mode is enabled")
}
if !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("expected maintenance mode error, got: %v", err)
}
}
@@ -0,0 +1,177 @@
package volume_server_grpc_test
import (
"context"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestVolumeMarkReadonlyAndWritableLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(72)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
VolumeId: volumeID,
Persist: false,
})
if err != nil {
t.Fatalf("VolumeMarkReadonly failed: %v", err)
}
readOnlyStatus, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeStatus after readonly failed: %v", err)
}
if !readOnlyStatus.GetIsReadOnly() {
t.Fatalf("VolumeStatus expected readonly=true after VolumeMarkReadonly")
}
_, err = grpcClient.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeMarkWritable failed: %v", err)
}
writableStatus, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeStatus after writable failed: %v", err)
}
if writableStatus.GetIsReadOnly() {
t.Fatalf("VolumeStatus expected readonly=false after VolumeMarkWritable")
}
}
func TestVolumeMarkReadonlyPersistTrue(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(74)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
VolumeId: volumeID,
Persist: true,
})
if err != nil {
t.Fatalf("VolumeMarkReadonly persist=true failed: %v", err)
}
statusResp, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeStatus after persist readonly failed: %v", err)
}
if !statusResp.GetIsReadOnly() {
t.Fatalf("VolumeStatus expected readonly=true after persist readonly")
}
}
func TestVolumeMarkReadonlyWritableErrorPaths(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: 98771, Persist: true})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeMarkReadonly missing-volume error mismatch: %v", err)
}
_, err = grpcClient.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: 98772})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeMarkWritable missing-volume error mismatch: %v", err)
}
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{
Maintenance: true,
Version: stateResp.GetState().GetVersion(),
},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
_, err = grpcClient.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: 1, Persist: true})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeMarkReadonly maintenance error mismatch: %v", err)
}
_, err = grpcClient.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{VolumeId: 1})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeMarkWritable maintenance error mismatch: %v", err)
}
}
func TestDeleteCollectionRemovesVolumeAndIsIdempotent(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(73)
const collection = "it-delete-collection"
framework.AllocateVolume(t, grpcClient, volumeID, collection)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeStatus before DeleteCollection failed: %v", err)
}
_, err = grpcClient.DeleteCollection(ctx, &volume_server_pb.DeleteCollectionRequest{Collection: collection})
if err != nil {
t.Fatalf("DeleteCollection existing collection failed: %v", err)
}
_, err = grpcClient.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{VolumeId: volumeID})
if err == nil {
t.Fatalf("VolumeStatus should fail after collection delete")
}
if !strings.Contains(err.Error(), "not found volume") {
t.Fatalf("VolumeStatus after DeleteCollection error mismatch: %v", err)
}
_, err = grpcClient.DeleteCollection(ctx, &volume_server_pb.DeleteCollectionRequest{Collection: collection})
if err != nil {
t.Fatalf("DeleteCollection idempotent retry failed: %v", err)
}
}
@@ -0,0 +1,264 @@
package volume_server_grpc_test
import (
"bytes"
"context"
"net/http"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestBatchDeleteInvalidFidAndMaintenanceMode(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{FileIds: []string{"bad-fid"}})
if err != nil {
t.Fatalf("BatchDelete invalid fid should return response, got error: %v", err)
}
if len(resp.GetResults()) != 1 {
t.Fatalf("expected one batch delete result, got %d", len(resp.GetResults()))
}
if got := resp.GetResults()[0].GetStatus(); got != 400 {
t.Fatalf("invalid fid expected status 400, got %d", got)
}
stateResp, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = client.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
_, err = client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{FileIds: []string{"1,1234567890ab"}})
if err == nil {
t.Fatalf("BatchDelete should fail when maintenance mode is enabled")
}
if !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("expected maintenance mode error, got: %v", err)
}
}
func TestBatchDeleteCookieMismatchAndSkipCheck(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(31)
const needleID = uint64(900001)
const correctCookie = uint32(0x1122AABB)
const wrongCookie = uint32(0x1122AABC)
framework.AllocateVolume(t, client, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, needleID, correctCookie)
uploadResp := framework.UploadBytes(t, httpClient, cluster.VolumeAdminURL(), fid, []byte("batch-delete-cookie-check"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
wrongCookieFid := framework.NewFileID(volumeID, needleID, wrongCookie)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
mismatchResp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{
FileIds: []string{wrongCookieFid},
SkipCookieCheck: false,
})
if err != nil {
t.Fatalf("BatchDelete with cookie check failed: %v", err)
}
if len(mismatchResp.GetResults()) != 1 {
t.Fatalf("BatchDelete cookie mismatch expected 1 result, got %d", len(mismatchResp.GetResults()))
}
if mismatchResp.GetResults()[0].GetStatus() != http.StatusBadRequest {
t.Fatalf("BatchDelete cookie mismatch expected status 400, got %d", mismatchResp.GetResults()[0].GetStatus())
}
skipCheckResp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{
FileIds: []string{wrongCookieFid},
SkipCookieCheck: true,
})
if err != nil {
t.Fatalf("BatchDelete skip cookie check failed: %v", err)
}
if len(skipCheckResp.GetResults()) != 1 {
t.Fatalf("BatchDelete skip check expected 1 result, got %d", len(skipCheckResp.GetResults()))
}
if skipCheckResp.GetResults()[0].GetStatus() != http.StatusAccepted {
t.Fatalf("BatchDelete skip check expected status 202, got %d", skipCheckResp.GetResults()[0].GetStatus())
}
readAfterDelete := framework.ReadBytes(t, httpClient, cluster.VolumeAdminURL(), fid)
_ = framework.ReadAllAndClose(t, readAfterDelete)
if readAfterDelete.StatusCode != http.StatusNotFound {
t.Fatalf("read after skip-check batch delete expected 404, got %d", readAfterDelete.StatusCode)
}
}
func TestBatchDeleteMixedStatusesAndMismatchStopsProcessing(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(32)
framework.AllocateVolume(t, client, volumeID, "")
const needleA = uint64(910001)
const needleB = uint64(910002)
const needleC = uint64(910003)
const cookieA = uint32(0x11111111)
const cookieB = uint32(0x22222222)
const cookieC = uint32(0x33333333)
httpClient := framework.NewHTTPClient()
fidA := framework.NewFileID(volumeID, needleA, cookieA)
fidB := framework.NewFileID(volumeID, needleB, cookieB)
fidC := framework.NewFileID(volumeID, needleC, cookieC)
for _, tc := range []struct {
fid string
body string
}{
{fid: fidA, body: "batch-delete-mixed-a"},
{fid: fidB, body: "batch-delete-mixed-b"},
{fid: fidC, body: "batch-delete-mixed-c"},
} {
uploadResp := framework.UploadBytes(t, httpClient, cluster.VolumeAdminURL(), tc.fid, []byte(tc.body))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload %s expected 201, got %d", tc.fid, uploadResp.StatusCode)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
missingFid := framework.NewFileID(volumeID, 919999, 0x44444444)
mixedResp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{
FileIds: []string{"bad-fid", fidA, missingFid},
})
if err != nil {
t.Fatalf("BatchDelete mixed status request failed: %v", err)
}
if len(mixedResp.GetResults()) != 3 {
t.Fatalf("BatchDelete mixed status expected 3 results, got %d", len(mixedResp.GetResults()))
}
if mixedResp.GetResults()[0].GetStatus() != http.StatusBadRequest {
t.Fatalf("BatchDelete mixed result[0] expected 400, got %d", mixedResp.GetResults()[0].GetStatus())
}
if mixedResp.GetResults()[1].GetStatus() != http.StatusAccepted {
t.Fatalf("BatchDelete mixed result[1] expected 202, got %d", mixedResp.GetResults()[1].GetStatus())
}
if mixedResp.GetResults()[2].GetStatus() != http.StatusNotFound {
t.Fatalf("BatchDelete mixed result[2] expected 404, got %d", mixedResp.GetResults()[2].GetStatus())
}
readDeletedA := framework.ReadBytes(t, httpClient, cluster.VolumeAdminURL(), fidA)
_ = framework.ReadAllAndClose(t, readDeletedA)
if readDeletedA.StatusCode != http.StatusNotFound {
t.Fatalf("fidA should be deleted after batch delete, got status %d", readDeletedA.StatusCode)
}
wrongCookieB := framework.NewFileID(volumeID, needleB, cookieB+1)
stopResp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{
FileIds: []string{wrongCookieB, fidC},
})
if err != nil {
t.Fatalf("BatchDelete mismatch-stop request failed: %v", err)
}
if len(stopResp.GetResults()) != 1 {
t.Fatalf("BatchDelete mismatch-stop expected 1 result due early break, got %d", len(stopResp.GetResults()))
}
if stopResp.GetResults()[0].GetStatus() != http.StatusBadRequest {
t.Fatalf("BatchDelete mismatch-stop expected 400, got %d", stopResp.GetResults()[0].GetStatus())
}
readB := framework.ReadBytes(t, httpClient, cluster.VolumeAdminURL(), fidB)
_ = framework.ReadAllAndClose(t, readB)
if readB.StatusCode != http.StatusOK {
t.Fatalf("fidB should remain after cookie mismatch path, got %d", readB.StatusCode)
}
readC := framework.ReadBytes(t, httpClient, cluster.VolumeAdminURL(), fidC)
_ = framework.ReadAllAndClose(t, readC)
if readC.StatusCode != http.StatusOK {
t.Fatalf("fidC should remain when batch processing stops on mismatch, got %d", readC.StatusCode)
}
}
func TestBatchDeleteRejectsChunkManifestNeedles(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(33)
framework.AllocateVolume(t, client, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 920001, 0x5555AAAA)
req, err := http.NewRequest(http.MethodPost, cluster.VolumeAdminURL()+"/"+fid+"?cm=true", bytes.NewReader([]byte("manifest-placeholder-payload")))
if err != nil {
t.Fatalf("create chunk manifest upload request: %v", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
uploadResp := framework.DoRequest(t, httpClient, req)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("chunk manifest upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := client.BatchDelete(ctx, &volume_server_pb.BatchDeleteRequest{FileIds: []string{fid}})
if err != nil {
t.Fatalf("BatchDelete chunk manifest should return response, got grpc error: %v", err)
}
if len(resp.GetResults()) != 1 {
t.Fatalf("BatchDelete chunk manifest expected one result, got %d", len(resp.GetResults()))
}
if resp.GetResults()[0].GetStatus() != http.StatusNotAcceptable {
t.Fatalf("BatchDelete chunk manifest expected status 406, got %d", resp.GetResults()[0].GetStatus())
}
if !strings.Contains(resp.GetResults()[0].GetError(), "ChunkManifest") {
t.Fatalf("BatchDelete chunk manifest expected error mentioning ChunkManifest, got %q", resp.GetResults()[0].GetError())
}
readResp := framework.ReadBytes(t, httpClient, cluster.VolumeAdminURL(), fid)
_ = framework.ReadAllAndClose(t, readResp)
if readResp.StatusCode != http.StatusOK {
t.Fatalf("chunk manifest should not be deleted by BatchDelete reject path, got %d", readResp.StatusCode)
}
}
@@ -0,0 +1,431 @@
package volume_server_grpc_test
import (
"context"
"io"
"math"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestVolumeIncrementalCopyDataAndNoDataPaths(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(91)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 770001, 0x1122AABB)
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("incremental-copy-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != 201 {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
dataStream, err := grpcClient.VolumeIncrementalCopy(ctx, &volume_server_pb.VolumeIncrementalCopyRequest{
VolumeId: volumeID,
SinceNs: 0,
})
if err != nil {
t.Fatalf("VolumeIncrementalCopy start failed: %v", err)
}
totalBytes := 0
for {
msg, recvErr := dataStream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
t.Fatalf("VolumeIncrementalCopy recv failed: %v", recvErr)
}
totalBytes += len(msg.GetFileContent())
}
if totalBytes == 0 {
t.Fatalf("VolumeIncrementalCopy expected streamed bytes for since_ns=0")
}
noDataStream, err := grpcClient.VolumeIncrementalCopy(ctx, &volume_server_pb.VolumeIncrementalCopyRequest{
VolumeId: volumeID,
SinceNs: math.MaxUint64,
})
if err != nil {
t.Fatalf("VolumeIncrementalCopy no-data start failed: %v", err)
}
_, err = noDataStream.Recv()
if err != io.EOF {
t.Fatalf("VolumeIncrementalCopy no-data expected EOF, got: %v", err)
}
}
func TestCopyFileIgnoreNotFoundAndStopOffsetZeroPaths(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(92)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
missingNoIgnore, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: volumeID,
Ext: ".definitely-missing",
CompactionRevision: math.MaxUint32,
StopOffset: 1,
IgnoreSourceFileNotFound: false,
})
if err == nil {
_, err = missingNoIgnore.Recv()
}
if err == nil {
t.Fatalf("CopyFile should fail for missing source file when ignore_source_file_not_found=false")
}
missingIgnored, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: volumeID,
Ext: ".definitely-missing",
CompactionRevision: math.MaxUint32,
StopOffset: 1,
IgnoreSourceFileNotFound: true,
})
if err != nil {
t.Fatalf("CopyFile ignore-not-found start failed: %v", err)
}
_, err = missingIgnored.Recv()
if err != io.EOF {
t.Fatalf("CopyFile ignore-not-found expected EOF, got: %v", err)
}
stopZeroStream, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: volumeID,
Ext: ".definitely-missing",
CompactionRevision: math.MaxUint32,
StopOffset: 0,
IgnoreSourceFileNotFound: false,
})
if err != nil {
t.Fatalf("CopyFile stop_offset=0 start failed: %v", err)
}
_, err = stopZeroStream.Recv()
if err != io.EOF {
t.Fatalf("CopyFile stop_offset=0 expected EOF, got: %v", err)
}
}
func TestCopyFileCompactionRevisionMismatch(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(94)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stream, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: volumeID,
Ext: ".idx",
CompactionRevision: 1, // fresh volume starts at revision 0
StopOffset: 1,
})
if err == nil {
_, err = stream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "is compacted") {
t.Fatalf("CopyFile compaction mismatch error mismatch: %v", err)
}
}
func TestReceiveFileProtocolViolationResponses(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
contentFirstStream, err := grpcClient.ReceiveFile(ctx)
if err != nil {
t.Fatalf("ReceiveFile stream create failed: %v", err)
}
if err = contentFirstStream.Send(&volume_server_pb.ReceiveFileRequest{
Data: &volume_server_pb.ReceiveFileRequest_FileContent{
FileContent: []byte("content-before-info"),
},
}); err != nil {
t.Fatalf("ReceiveFile send content-first failed: %v", err)
}
contentFirstResp, err := contentFirstStream.CloseAndRecv()
if err != nil {
t.Fatalf("ReceiveFile content-first close failed: %v", err)
}
if !strings.Contains(contentFirstResp.GetError(), "file info must be sent first") {
t.Fatalf("ReceiveFile content-first response mismatch: %+v", contentFirstResp)
}
unknownTypeStream, err := grpcClient.ReceiveFile(ctx)
if err != nil {
t.Fatalf("ReceiveFile stream create for unknown-type failed: %v", err)
}
if err = unknownTypeStream.Send(&volume_server_pb.ReceiveFileRequest{}); err != nil {
t.Fatalf("ReceiveFile send unknown-type request failed: %v", err)
}
unknownTypeResp, err := unknownTypeStream.CloseAndRecv()
if err != nil {
t.Fatalf("ReceiveFile unknown-type close failed: %v", err)
}
if !strings.Contains(unknownTypeResp.GetError(), "unknown message type") {
t.Fatalf("ReceiveFile unknown-type response mismatch: %+v", unknownTypeResp)
}
}
func TestReceiveFileSuccessForRegularVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(95)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
payloadA := []byte("receive-file-chunk-a:")
payloadB := []byte("receive-file-chunk-b")
expected := append(append([]byte{}, payloadA...), payloadB...)
receiveStream, err := grpcClient.ReceiveFile(ctx)
if err != nil {
t.Fatalf("ReceiveFile stream create failed: %v", err)
}
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
Data: &volume_server_pb.ReceiveFileRequest_Info{
Info: &volume_server_pb.ReceiveFileInfo{
VolumeId: volumeID,
Ext: ".tmprecv",
Collection: "",
IsEcVolume: false,
FileSize: uint64(len(expected)),
},
},
}); err != nil {
t.Fatalf("ReceiveFile send info failed: %v", err)
}
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
Data: &volume_server_pb.ReceiveFileRequest_FileContent{FileContent: payloadA},
}); err != nil {
t.Fatalf("ReceiveFile send payloadA failed: %v", err)
}
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
Data: &volume_server_pb.ReceiveFileRequest_FileContent{FileContent: payloadB},
}); err != nil {
t.Fatalf("ReceiveFile send payloadB failed: %v", err)
}
resp, err := receiveStream.CloseAndRecv()
if err != nil {
t.Fatalf("ReceiveFile close failed: %v", err)
}
if resp.GetError() != "" {
t.Fatalf("ReceiveFile unexpected error response: %+v", resp)
}
if resp.GetBytesWritten() != uint64(len(expected)) {
t.Fatalf("ReceiveFile bytes_written mismatch: got %d want %d", resp.GetBytesWritten(), len(expected))
}
copyStream, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: volumeID,
Ext: ".tmprecv",
CompactionRevision: math.MaxUint32,
StopOffset: uint64(len(expected)),
})
if err != nil {
t.Fatalf("CopyFile for received data start failed: %v", err)
}
var copied []byte
for {
msg, recvErr := copyStream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
t.Fatalf("CopyFile for received data recv failed: %v", recvErr)
}
copied = append(copied, msg.GetFileContent()...)
}
if string(copied) != string(expected) {
t.Fatalf("received file data mismatch: got %q want %q", string(copied), string(expected))
}
}
func TestReceiveFileSuccessForEcVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
const volumeID = uint32(96)
const collection = "ec-receive-success"
const ext = ".ec00"
payloadA := []byte("receive-ec-file-chunk-a:")
payloadB := []byte("receive-ec-file-chunk-b")
expected := append(append([]byte{}, payloadA...), payloadB...)
receiveStream, err := grpcClient.ReceiveFile(ctx)
if err != nil {
t.Fatalf("ReceiveFile stream create failed: %v", err)
}
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
Data: &volume_server_pb.ReceiveFileRequest_Info{
Info: &volume_server_pb.ReceiveFileInfo{
VolumeId: volumeID,
Ext: ext,
Collection: collection,
IsEcVolume: true,
ShardId: 0,
FileSize: uint64(len(expected)),
},
},
}); err != nil {
t.Fatalf("ReceiveFile send EC info failed: %v", err)
}
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
Data: &volume_server_pb.ReceiveFileRequest_FileContent{FileContent: payloadA},
}); err != nil {
t.Fatalf("ReceiveFile send EC payloadA failed: %v", err)
}
if err = receiveStream.Send(&volume_server_pb.ReceiveFileRequest{
Data: &volume_server_pb.ReceiveFileRequest_FileContent{FileContent: payloadB},
}); err != nil {
t.Fatalf("ReceiveFile send EC payloadB failed: %v", err)
}
resp, err := receiveStream.CloseAndRecv()
if err != nil {
t.Fatalf("ReceiveFile EC close failed: %v", err)
}
if resp.GetError() != "" {
t.Fatalf("ReceiveFile EC unexpected error response: %+v", resp)
}
if resp.GetBytesWritten() != uint64(len(expected)) {
t.Fatalf("ReceiveFile EC bytes_written mismatch: got %d want %d", resp.GetBytesWritten(), len(expected))
}
copyStream, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: volumeID,
Collection: collection,
IsEcVolume: true,
Ext: ext,
CompactionRevision: math.MaxUint32,
StopOffset: uint64(len(expected)),
})
if err != nil {
t.Fatalf("CopyFile for received EC data start failed: %v", err)
}
var copied []byte
for {
msg, recvErr := copyStream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
t.Fatalf("CopyFile for received EC data recv failed: %v", recvErr)
}
copied = append(copied, msg.GetFileContent()...)
}
if string(copied) != string(expected) {
t.Fatalf("received EC file data mismatch: got %q want %q", string(copied), string(expected))
}
}
func TestCopyFileEcVolumeIgnoreMissingSourcePaths(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
streamNoIgnore, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: 99601,
Collection: "ec-copy-missing",
IsEcVolume: true,
Ext: ".ec00",
CompactionRevision: math.MaxUint32,
StopOffset: 1,
IgnoreSourceFileNotFound: false,
})
if err == nil {
_, err = streamNoIgnore.Recv()
}
if err == nil || !strings.Contains(err.Error(), "not found ec volume id") {
t.Fatalf("CopyFile EC missing source error mismatch: %v", err)
}
streamIgnore, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: 99602,
Collection: "ec-copy-missing",
IsEcVolume: true,
Ext: ".ec00",
CompactionRevision: math.MaxUint32,
StopOffset: 1,
IgnoreSourceFileNotFound: true,
})
if err != nil {
t.Fatalf("CopyFile EC ignore-missing start failed: %v", err)
}
_, err = streamIgnore.Recv()
if err != io.EOF {
t.Fatalf("CopyFile EC ignore-missing expected EOF, got: %v", err)
}
}
+284
View File
@@ -0,0 +1,284 @@
package volume_server_grpc_test
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestVolumeSyncStatusAndReadVolumeFileStatus(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(41)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
syncResp, err := grpcClient.VolumeSyncStatus(ctx, &volume_server_pb.VolumeSyncStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VolumeSyncStatus failed: %v", err)
}
if syncResp.GetVolumeId() != volumeID {
t.Fatalf("VolumeSyncStatus volume id mismatch: got %d want %d", syncResp.GetVolumeId(), volumeID)
}
statusResp, err := grpcClient.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("ReadVolumeFileStatus failed: %v", err)
}
if statusResp.GetVolumeId() != volumeID {
t.Fatalf("ReadVolumeFileStatus volume id mismatch: got %d want %d", statusResp.GetVolumeId(), volumeID)
}
if statusResp.GetVersion() == 0 {
t.Fatalf("ReadVolumeFileStatus expected non-zero version")
}
}
func TestCopyAndStreamMethodsMissingVolumePaths(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.VolumeSyncStatus(ctx, &volume_server_pb.VolumeSyncStatusRequest{VolumeId: 98761})
if err == nil {
t.Fatalf("VolumeSyncStatus should fail for missing volume")
}
incrementalStream, err := grpcClient.VolumeIncrementalCopy(ctx, &volume_server_pb.VolumeIncrementalCopyRequest{VolumeId: 98762, SinceNs: 0})
if err == nil {
_, err = incrementalStream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "not found volume") {
t.Fatalf("VolumeIncrementalCopy missing-volume error mismatch: %v", err)
}
readAllStream, err := grpcClient.ReadAllNeedles(ctx, &volume_server_pb.ReadAllNeedlesRequest{VolumeIds: []uint32{98763}})
if err == nil {
_, err = readAllStream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "not found volume") {
t.Fatalf("ReadAllNeedles missing-volume error mismatch: %v", err)
}
copyFileStream, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{VolumeId: 98764, Ext: ".dat", StopOffset: 1})
if err == nil {
_, err = copyFileStream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "not found volume") {
t.Fatalf("CopyFile missing-volume error mismatch: %v", err)
}
_, err = grpcClient.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{VolumeId: 98765})
if err == nil || !strings.Contains(err.Error(), "not found volume") {
t.Fatalf("ReadVolumeFileStatus missing-volume error mismatch: %v", err)
}
}
func TestVolumeCopyAndReceiveFileMaintenanceRejection(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
copyStream, err := grpcClient.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{VolumeId: 1, SourceDataNode: "127.0.0.1:1234"})
if err == nil {
_, err = copyStream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeCopy maintenance error mismatch: %v", err)
}
receiveClient, err := grpcClient.ReceiveFile(ctx)
if err != nil {
t.Fatalf("ReceiveFile client creation failed: %v", err)
}
_ = receiveClient.Send(&volume_server_pb.ReceiveFileRequest{
Data: &volume_server_pb.ReceiveFileRequest_Info{
Info: &volume_server_pb.ReceiveFileInfo{VolumeId: 1, Ext: ".dat"},
},
})
_, err = receiveClient.CloseAndRecv()
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("ReceiveFile maintenance error mismatch: %v", err)
}
}
func TestVolumeCopySuccessFromPeerAndMountsDestination(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartDualVolumeCluster(t, matrix.P1())
sourceConn, sourceClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer sourceConn.Close()
destConn, destClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
defer destConn.Close()
const volumeID = uint32(42)
framework.AllocateVolume(t, sourceClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 880001, 0x12345678)
payload := []byte("volume-copy-success-payload")
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload to source expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
copyStream, err := destClient.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{
VolumeId: volumeID,
Collection: "",
SourceDataNode: clusterHarness.VolumeAdminAddress(0) + "." + strings.Split(clusterHarness.VolumeGRPCAddress(0), ":")[1],
})
if err != nil {
t.Fatalf("VolumeCopy start failed: %v", err)
}
sawFinalAppendTimestamp := false
for {
msg, recvErr := copyStream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
t.Fatalf("VolumeCopy recv failed: %v", recvErr)
}
if msg.GetLastAppendAtNs() > 0 {
sawFinalAppendTimestamp = true
}
}
if !sawFinalAppendTimestamp {
t.Fatalf("VolumeCopy expected final response with last_append_at_ns")
}
destReadResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(1), fid)
destReadBody := framework.ReadAllAndClose(t, destReadResp)
if destReadResp.StatusCode != http.StatusOK {
t.Fatalf("read from copied destination expected 200, got %d", destReadResp.StatusCode)
}
if string(destReadBody) != string(payload) {
t.Fatalf("destination copied payload mismatch: got %q want %q", string(destReadBody), string(payload))
}
}
func TestVolumeCopyOverwritesExistingDestinationVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartDualVolumeCluster(t, matrix.P1())
sourceConn, sourceClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer sourceConn.Close()
destConn, destClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
defer destConn.Close()
const volumeID = uint32(43)
framework.AllocateVolume(t, sourceClient, volumeID, "")
framework.AllocateVolume(t, destClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 880002, 0x23456789)
sourcePayload := []byte("volume-copy-overwrite-source")
destPayload := []byte("volume-copy-overwrite-destination-old")
sourceUploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(0), fid, sourcePayload)
_ = framework.ReadAllAndClose(t, sourceUploadResp)
if sourceUploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload to source expected 201, got %d", sourceUploadResp.StatusCode)
}
destUploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(1), fid, destPayload)
_ = framework.ReadAllAndClose(t, destUploadResp)
if destUploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload to destination expected 201, got %d", destUploadResp.StatusCode)
}
destReadBeforeResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(1), fid)
destReadBeforeBody := framework.ReadAllAndClose(t, destReadBeforeResp)
if destReadBeforeResp.StatusCode != http.StatusOK {
t.Fatalf("destination pre-copy read expected 200, got %d", destReadBeforeResp.StatusCode)
}
if string(destReadBeforeBody) != string(destPayload) {
t.Fatalf("destination pre-copy payload mismatch: got %q want %q", string(destReadBeforeBody), string(destPayload))
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
copyStream, err := destClient.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{
VolumeId: volumeID,
Collection: "",
SourceDataNode: clusterHarness.VolumeAdminAddress(0) + "." + strings.Split(clusterHarness.VolumeGRPCAddress(0), ":")[1],
})
if err != nil {
t.Fatalf("VolumeCopy overwrite start failed: %v", err)
}
sawFinalAppendTimestamp := false
for {
msg, recvErr := copyStream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
t.Fatalf("VolumeCopy overwrite recv failed: %v", recvErr)
}
if msg.GetLastAppendAtNs() > 0 {
sawFinalAppendTimestamp = true
}
}
if !sawFinalAppendTimestamp {
t.Fatalf("VolumeCopy overwrite expected final response with last_append_at_ns")
}
destReadAfterResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(1), fid)
destReadAfterBody := framework.ReadAllAndClose(t, destReadAfterResp)
if destReadAfterResp.StatusCode != http.StatusOK {
t.Fatalf("destination post-copy read expected 200, got %d", destReadAfterResp.StatusCode)
}
if string(destReadAfterBody) != string(sourcePayload) {
t.Fatalf("destination post-copy payload mismatch: got %q want %q", string(destReadAfterBody), string(sourcePayload))
}
}
+146
View File
@@ -0,0 +1,146 @@
package volume_server_grpc_test
import (
"context"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestReadNeedleBlobAndMetaMissingVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.ReadNeedleBlob(ctx, &volume_server_pb.ReadNeedleBlobRequest{
VolumeId: 99111,
Offset: 0,
Size: 16,
})
if err == nil {
t.Fatalf("ReadNeedleBlob should fail for missing volume")
}
if !strings.Contains(err.Error(), "not found volume") {
t.Fatalf("ReadNeedleBlob missing volume error mismatch: %v", err)
}
_, err = grpcClient.ReadNeedleMeta(ctx, &volume_server_pb.ReadNeedleMetaRequest{
VolumeId: 99112,
NeedleId: 1,
Offset: 0,
Size: 16,
})
if err == nil {
t.Fatalf("ReadNeedleMeta should fail for missing volume")
}
if !strings.Contains(err.Error(), "not found volume") {
t.Fatalf("ReadNeedleMeta missing volume error mismatch: %v", err)
}
}
func TestWriteNeedleBlobMaintenanceAndMissingVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.WriteNeedleBlob(ctx, &volume_server_pb.WriteNeedleBlobRequest{
VolumeId: 99113,
NeedleId: 1,
NeedleBlob: []byte("abc"),
Size: 3,
})
if err == nil {
t.Fatalf("WriteNeedleBlob should fail for missing volume")
}
if !strings.Contains(err.Error(), "not found volume") {
t.Fatalf("WriteNeedleBlob missing volume error mismatch: %v", err)
}
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
_, err = grpcClient.WriteNeedleBlob(ctx, &volume_server_pb.WriteNeedleBlobRequest{
VolumeId: 1,
NeedleId: 2,
NeedleBlob: []byte("def"),
Size: 3,
})
if err == nil {
t.Fatalf("WriteNeedleBlob should fail in maintenance mode")
}
if !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("WriteNeedleBlob maintenance mode error mismatch: %v", err)
}
}
func TestReadNeedleBlobAndMetaInvalidOffsets(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(92)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 880001, 0xCCDD1122)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("invalid-offset-check"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != 201 {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.ReadNeedleBlob(ctx, &volume_server_pb.ReadNeedleBlobRequest{
VolumeId: volumeID,
Offset: 1 << 40,
Size: 64,
})
if err == nil {
t.Fatalf("ReadNeedleBlob should fail for invalid offset")
}
if !strings.Contains(strings.ToLower(err.Error()), "read needle blob") {
t.Fatalf("ReadNeedleBlob invalid offset error mismatch: %v", err)
}
_, err = grpcClient.ReadNeedleMeta(ctx, &volume_server_pb.ReadNeedleMetaRequest{
VolumeId: volumeID,
NeedleId: 880001,
Offset: 1 << 40,
Size: 64,
})
if err == nil {
t.Fatalf("ReadNeedleMeta should fail for invalid offset")
}
}
@@ -0,0 +1,273 @@
package volume_server_grpc_test
import (
"context"
"io"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/idx"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
)
func TestReadWriteNeedleBlobAndMetaRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(83)
const sourceNeedleID = uint64(333333)
const sourceCookie = uint32(0xABCD0102)
const clonedNeedleID = uint64(333334)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
payload := []byte("blob-roundtrip-content")
fid := framework.NewFileID(volumeID, sourceNeedleID, sourceCookie)
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != 201 {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
fileStatus, err := grpcClient.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("ReadVolumeFileStatus failed: %v", err)
}
if fileStatus.GetIdxFileSize() == 0 {
t.Fatalf("expected non-zero idx file size after upload")
}
idxBytes := copyFileBytes(t, grpcClient, &volume_server_pb.CopyFileRequest{
VolumeId: volumeID,
Ext: ".idx",
CompactionRevision: fileStatus.GetCompactionRevision(),
StopOffset: fileStatus.GetIdxFileSize(),
})
offset, size := findNeedleOffsetAndSize(t, idxBytes, sourceNeedleID)
blobResp, err := grpcClient.ReadNeedleBlob(ctx, &volume_server_pb.ReadNeedleBlobRequest{
VolumeId: volumeID,
Offset: offset,
Size: size,
})
if err != nil {
t.Fatalf("ReadNeedleBlob failed: %v", err)
}
if len(blobResp.GetNeedleBlob()) == 0 {
t.Fatalf("ReadNeedleBlob returned empty blob")
}
metaResp, err := grpcClient.ReadNeedleMeta(ctx, &volume_server_pb.ReadNeedleMetaRequest{
VolumeId: volumeID,
NeedleId: sourceNeedleID,
Offset: offset,
Size: size,
})
if err != nil {
t.Fatalf("ReadNeedleMeta failed: %v", err)
}
if metaResp.GetCookie() != sourceCookie {
t.Fatalf("ReadNeedleMeta cookie mismatch: got %d want %d", metaResp.GetCookie(), sourceCookie)
}
_, err = grpcClient.WriteNeedleBlob(ctx, &volume_server_pb.WriteNeedleBlobRequest{
VolumeId: volumeID,
NeedleId: clonedNeedleID,
Size: size,
NeedleBlob: blobResp.GetNeedleBlob(),
})
if err != nil {
t.Fatalf("WriteNeedleBlob failed: %v", err)
}
clonedStatus, err := grpcClient.VolumeNeedleStatus(ctx, &volume_server_pb.VolumeNeedleStatusRequest{
VolumeId: volumeID,
NeedleId: clonedNeedleID,
})
if err != nil {
t.Fatalf("VolumeNeedleStatus for cloned needle failed: %v", err)
}
if clonedStatus.GetNeedleId() != sourceNeedleID {
t.Fatalf("cloned needle status id mismatch: got %d want %d", clonedStatus.GetNeedleId(), sourceNeedleID)
}
if clonedStatus.GetCookie() != sourceCookie {
t.Fatalf("cloned needle cookie mismatch: got %d want %d", clonedStatus.GetCookie(), sourceCookie)
}
clonedReadResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeID, clonedNeedleID, sourceCookie))
clonedReadBody := framework.ReadAllAndClose(t, clonedReadResp)
if clonedReadResp.StatusCode != 200 {
t.Fatalf("cloned needle GET expected 200, got %d", clonedReadResp.StatusCode)
}
if string(clonedReadBody) != string(payload) {
t.Fatalf("cloned needle body mismatch: got %q want %q", string(clonedReadBody), string(payload))
}
}
func TestReadAllNeedlesStreamsUploadedRecords(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(84)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
expected := map[uint64]string{
444441: "read-all-needle-one",
444442: "read-all-needle-two",
}
for key, body := range expected {
resp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeID, key, 0xA0B0C0D0), []byte(body))
_ = framework.ReadAllAndClose(t, resp)
if resp.StatusCode != 201 {
t.Fatalf("upload for key %d expected 201, got %d", key, resp.StatusCode)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stream, err := grpcClient.ReadAllNeedles(ctx, &volume_server_pb.ReadAllNeedlesRequest{VolumeIds: []uint32{volumeID}})
if err != nil {
t.Fatalf("ReadAllNeedles start failed: %v", err)
}
seen := map[uint64]string{}
for {
msg, recvErr := stream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
t.Fatalf("ReadAllNeedles recv failed: %v", recvErr)
}
if _, wanted := expected[msg.GetNeedleId()]; wanted {
seen[msg.GetNeedleId()] = string(msg.GetNeedleBlob())
}
}
for key, body := range expected {
got, found := seen[key]
if !found {
t.Fatalf("ReadAllNeedles missing key %d in stream", key)
}
if got != body {
t.Fatalf("ReadAllNeedles body mismatch for key %d: got %q want %q", key, got, body)
}
}
}
func TestReadAllNeedlesExistingThenMissingVolumeAbortsStream(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const existingVolumeID = uint32(85)
const missingVolumeID = uint32(98585)
const needleID = uint64(445551)
framework.AllocateVolume(t, grpcClient, existingVolumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(existingVolumeID, needleID, 0xAA11BB22)
payload := "read-all-existing-then-missing"
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte(payload))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != 201 {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stream, err := grpcClient.ReadAllNeedles(ctx, &volume_server_pb.ReadAllNeedlesRequest{
VolumeIds: []uint32{existingVolumeID, missingVolumeID},
})
if err != nil {
t.Fatalf("ReadAllNeedles start failed: %v", err)
}
seenUploadedNeedle := false
for {
msg, recvErr := stream.Recv()
if recvErr == io.EOF {
t.Fatalf("ReadAllNeedles expected stream error for missing volume, got EOF")
}
if recvErr != nil {
if !strings.Contains(recvErr.Error(), "not found volume id") {
t.Fatalf("ReadAllNeedles missing-volume error mismatch: %v", recvErr)
}
break
}
if msg.GetNeedleId() == needleID && string(msg.GetNeedleBlob()) == payload {
seenUploadedNeedle = true
}
}
if !seenUploadedNeedle {
t.Fatalf("ReadAllNeedles should stream entries from existing volume before missing-volume abort")
}
}
func copyFileBytes(t testing.TB, grpcClient volume_server_pb.VolumeServerClient, req *volume_server_pb.CopyFileRequest) []byte {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stream, err := grpcClient.CopyFile(ctx, req)
if err != nil {
t.Fatalf("CopyFile start failed: %v", err)
}
var out []byte
for {
msg, recvErr := stream.Recv()
if recvErr == io.EOF {
return out
}
if recvErr != nil {
t.Fatalf("CopyFile recv failed: %v", recvErr)
}
out = append(out, msg.GetFileContent()...)
}
}
func findNeedleOffsetAndSize(t testing.TB, idxBytes []byte, needleID uint64) (offset int64, size int32) {
t.Helper()
for i := 0; i+types.NeedleMapEntrySize <= len(idxBytes); i += types.NeedleMapEntrySize {
key, entryOffset, entrySize := idx.IdxFileEntry(idxBytes[i : i+types.NeedleMapEntrySize])
if uint64(key) != needleID {
continue
}
if entryOffset.IsZero() || entrySize <= 0 {
continue
}
return entryOffset.ToActualOffset(), int32(entrySize)
}
t.Fatalf("needle id %d not found in idx entries", needleID)
return 0, 0
}
@@ -0,0 +1,777 @@
package volume_server_grpc_test
import (
"context"
"io"
"math"
"net/http"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestEcMaintenanceModeRejections(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{
Maintenance: true,
Version: stateResp.GetState().GetVersion(),
},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{VolumeId: 1, Collection: ""})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeEcShardsGenerate maintenance error mismatch: %v", err)
}
_, err = grpcClient.VolumeEcShardsCopy(ctx, &volume_server_pb.VolumeEcShardsCopyRequest{
VolumeId: 1,
Collection: "",
SourceDataNode: "127.0.0.1:1",
ShardIds: []uint32{0},
})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeEcShardsCopy maintenance error mismatch: %v", err)
}
_, err = grpcClient.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
VolumeId: 1,
Collection: "",
ShardIds: []uint32{0},
})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeEcShardsDelete maintenance error mismatch: %v", err)
}
_, err = grpcClient.VolumeEcBlobDelete(ctx, &volume_server_pb.VolumeEcBlobDeleteRequest{
VolumeId: 1,
Collection: "",
FileKey: 1,
Version: 3,
})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeEcBlobDelete maintenance error mismatch: %v", err)
}
_, err = grpcClient.VolumeEcShardsToVolume(ctx, &volume_server_pb.VolumeEcShardsToVolumeRequest{
VolumeId: 1,
Collection: "",
})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeEcShardsToVolume maintenance error mismatch: %v", err)
}
}
func TestEcMissingInvalidAndNoopPaths(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: 98791,
Collection: "",
})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeEcShardsGenerate missing-volume error mismatch: %v", err)
}
rebuildResp, err := grpcClient.VolumeEcShardsRebuild(ctx, &volume_server_pb.VolumeEcShardsRebuildRequest{
VolumeId: 98792,
Collection: "ec-rebuild",
})
if err != nil {
t.Fatalf("VolumeEcShardsRebuild missing-volume should return empty success, got: %v", err)
}
if len(rebuildResp.GetRebuiltShardIds()) != 0 {
t.Fatalf("VolumeEcShardsRebuild expected no rebuilt shards for missing volume, got %v", rebuildResp.GetRebuiltShardIds())
}
_, err = grpcClient.VolumeEcShardsCopy(ctx, &volume_server_pb.VolumeEcShardsCopyRequest{
VolumeId: 98793,
Collection: "ec-copy",
SourceDataNode: "127.0.0.1:1",
ShardIds: []uint32{0},
DiskId: 99,
})
if err == nil || !strings.Contains(err.Error(), "invalid disk_id") {
t.Fatalf("VolumeEcShardsCopy invalid-disk error mismatch: %v", err)
}
_, err = grpcClient.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
VolumeId: 98794,
Collection: "ec-delete",
ShardIds: []uint32{0, 1},
})
if err != nil {
t.Fatalf("VolumeEcShardsDelete missing-volume should be no-op success, got: %v", err)
}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: 98795,
Collection: "ec-mount",
ShardIds: []uint32{0},
})
if err == nil {
t.Fatalf("VolumeEcShardsMount should fail for missing EC shards")
}
_, err = grpcClient.VolumeEcShardsUnmount(ctx, &volume_server_pb.VolumeEcShardsUnmountRequest{
VolumeId: 98796,
ShardIds: []uint32{0},
})
if err != nil {
t.Fatalf("VolumeEcShardsUnmount missing shards should be no-op success, got: %v", err)
}
readStream, err := grpcClient.VolumeEcShardRead(ctx, &volume_server_pb.VolumeEcShardReadRequest{
VolumeId: 98797,
ShardId: 0,
Offset: 0,
Size: 1,
})
if err == nil {
_, err = readStream.Recv()
}
if err == nil || err == io.EOF {
t.Fatalf("VolumeEcShardRead should fail for missing EC volume")
}
_, err = grpcClient.VolumeEcBlobDelete(ctx, &volume_server_pb.VolumeEcBlobDeleteRequest{
VolumeId: 98798,
Collection: "ec-blob",
FileKey: 1,
Version: 3,
})
if err != nil {
t.Fatalf("VolumeEcBlobDelete missing local EC volume should be no-op success, got: %v", err)
}
_, err = grpcClient.VolumeEcShardsToVolume(ctx, &volume_server_pb.VolumeEcShardsToVolumeRequest{
VolumeId: 98799,
Collection: "ec-to-volume",
})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeEcShardsToVolume missing-volume error mismatch: %v", err)
}
_, err = grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
VolumeId: 98800,
})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeEcShardsInfo missing-volume error mismatch: %v", err)
}
}
func TestEcGenerateMountInfoUnmountLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(115)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 990001, 0x1234ABCD)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-generate-lifecycle-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("VolumeEcShardsGenerate success path failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{0},
})
if err != nil {
t.Fatalf("VolumeEcShardsMount success path failed: %v", err)
}
infoResp, err := grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
VolumeId: volumeID,
})
if err != nil {
t.Fatalf("VolumeEcShardsInfo after mount failed: %v", err)
}
if len(infoResp.GetEcShardInfos()) == 0 {
t.Fatalf("VolumeEcShardsInfo expected non-empty shard infos after mount")
}
if infoResp.GetVolumeSize() == 0 {
t.Fatalf("VolumeEcShardsInfo expected non-zero volume size after mount")
}
_, err = grpcClient.VolumeEcShardsUnmount(ctx, &volume_server_pb.VolumeEcShardsUnmountRequest{
VolumeId: volumeID,
ShardIds: []uint32{0},
})
if err != nil {
t.Fatalf("VolumeEcShardsUnmount success path failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsInfo(ctx, &volume_server_pb.VolumeEcShardsInfoRequest{
VolumeId: volumeID,
})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeEcShardsInfo after unmount expected not-found error, got: %v", err)
}
}
func TestEcShardReadAndBlobDeleteLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(116)
const fileKey = uint64(990002)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, fileKey, 0x2233CCDD)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-shard-read-delete-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{0},
})
if err != nil {
t.Fatalf("VolumeEcShardsMount failed: %v", err)
}
readStream, err := grpcClient.VolumeEcShardRead(ctx, &volume_server_pb.VolumeEcShardReadRequest{
VolumeId: volumeID,
ShardId: 0,
Offset: 0,
Size: 1,
})
if err != nil {
t.Fatalf("VolumeEcShardRead start failed: %v", err)
}
firstChunk, err := readStream.Recv()
if err != nil {
t.Fatalf("VolumeEcShardRead recv failed: %v", err)
}
if len(firstChunk.GetData()) == 0 {
t.Fatalf("VolumeEcShardRead expected non-empty data chunk before deletion")
}
_, err = grpcClient.VolumeEcBlobDelete(ctx, &volume_server_pb.VolumeEcBlobDeleteRequest{
VolumeId: volumeID,
Collection: "",
FileKey: fileKey,
Version: uint32(needle.GetCurrentVersion()),
})
if err != nil {
t.Fatalf("VolumeEcBlobDelete first delete failed: %v", err)
}
_, err = grpcClient.VolumeEcBlobDelete(ctx, &volume_server_pb.VolumeEcBlobDeleteRequest{
VolumeId: volumeID,
Collection: "",
FileKey: fileKey,
Version: uint32(needle.GetCurrentVersion()),
})
if err != nil {
t.Fatalf("VolumeEcBlobDelete second delete should be idempotent success, got: %v", err)
}
deletedStream, err := grpcClient.VolumeEcShardRead(ctx, &volume_server_pb.VolumeEcShardReadRequest{
VolumeId: volumeID,
ShardId: 0,
FileKey: fileKey,
Offset: 0,
Size: 1,
})
if err != nil {
t.Fatalf("VolumeEcShardRead deleted-check start failed: %v", err)
}
deletedMsg, err := deletedStream.Recv()
if err != nil {
t.Fatalf("VolumeEcShardRead deleted-check recv failed: %v", err)
}
if !deletedMsg.GetIsDeleted() {
t.Fatalf("VolumeEcShardRead expected IsDeleted=true after blob delete")
}
_, err = deletedStream.Recv()
if err != io.EOF {
t.Fatalf("VolumeEcShardRead deleted-check expected EOF after deleted marker, got: %v", err)
}
}
func TestEcRebuildMissingShardLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(117)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 990003, 0x3344DDEE)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-rebuild-shard-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{0},
})
if err != nil {
t.Fatalf("VolumeEcShardsDelete shard 0 failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{0},
})
if err == nil {
t.Fatalf("VolumeEcShardsMount should fail when shard 0 has been deleted")
}
rebuildResp, err := grpcClient.VolumeEcShardsRebuild(ctx, &volume_server_pb.VolumeEcShardsRebuildRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("VolumeEcShardsRebuild failed: %v", err)
}
if len(rebuildResp.GetRebuiltShardIds()) == 0 {
t.Fatalf("VolumeEcShardsRebuild expected rebuilt shard ids")
}
foundShard0 := false
for _, shardID := range rebuildResp.GetRebuiltShardIds() {
if shardID == 0 {
foundShard0 = true
break
}
}
if !foundShard0 {
t.Fatalf("VolumeEcShardsRebuild expected shard 0 to be rebuilt, got %v", rebuildResp.GetRebuiltShardIds())
}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{0},
})
if err != nil {
t.Fatalf("VolumeEcShardsMount shard 0 after rebuild failed: %v", err)
}
}
func TestEcShardsToVolumeMissingShardAndNoLiveEntries(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
httpClient := framework.NewHTTPClient()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
t.Run("missing shard returns error", func(t *testing.T) {
const volumeID = uint32(118)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, 990004, 0x4455EEFF)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-to-volume-missing-shard-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{0},
})
if err != nil {
t.Fatalf("VolumeEcShardsDelete shard 0 failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{1},
})
if err != nil {
t.Fatalf("VolumeEcShardsMount shard 1 failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsToVolume(ctx, &volume_server_pb.VolumeEcShardsToVolumeRequest{
VolumeId: volumeID,
Collection: "",
})
if err == nil || !strings.Contains(err.Error(), "missing shard 0") {
t.Fatalf("VolumeEcShardsToVolume missing-shard error mismatch: %v", err)
}
})
t.Run("no live entries returns failed precondition", func(t *testing.T) {
const volumeID = uint32(119)
const needleID = uint64(990005)
const cookie = uint32(0x5566FF11)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, needleID, cookie)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-no-live-entries-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
deleteResp := framework.DoRequest(t, httpClient, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+fid))
_ = framework.ReadAllAndClose(t, deleteResp)
if deleteResp.StatusCode != http.StatusAccepted {
t.Fatalf("delete expected 202, got %d", deleteResp.StatusCode)
}
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
})
if err != nil {
t.Fatalf("VolumeEcShardsMount data shards failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsToVolume(ctx, &volume_server_pb.VolumeEcShardsToVolumeRequest{
VolumeId: volumeID,
Collection: "",
})
if err == nil {
t.Fatalf("VolumeEcShardsToVolume expected failed-precondition error when no live entries")
}
if status.Code(err) != codes.FailedPrecondition {
t.Fatalf("VolumeEcShardsToVolume no-live-entries expected FailedPrecondition, got %v (%v)", status.Code(err), err)
}
if !strings.Contains(err.Error(), erasure_coding.EcNoLiveEntriesSubstring) {
t.Fatalf("VolumeEcShardsToVolume no-live-entries error should mention %q, got %v", erasure_coding.EcNoLiveEntriesSubstring, err)
}
})
}
func TestEcShardsToVolumeSuccessRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(120)
const needleID = uint64(990006)
const cookie = uint32(0x66771122)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, needleID, cookie)
payload := []byte("ec-shards-to-volume-success-roundtrip-content")
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsMount(ctx, &volume_server_pb.VolumeEcShardsMountRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
})
if err != nil {
t.Fatalf("VolumeEcShardsMount data shards failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsToVolume(ctx, &volume_server_pb.VolumeEcShardsToVolumeRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("VolumeEcShardsToVolume success path failed: %v", err)
}
readResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid)
readBody := framework.ReadAllAndClose(t, readResp)
if readResp.StatusCode != http.StatusOK {
t.Fatalf("post-conversion read expected 200, got %d", readResp.StatusCode)
}
if string(readBody) != string(payload) {
t.Fatalf("post-conversion payload mismatch: got %q want %q", string(readBody), string(payload))
}
}
func TestEcShardsDeleteLastShardRemovesEcx(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(121)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 990007, 0x77882233)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, []byte("ec-delete-all-shards-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("VolumeEcShardsGenerate failed: %v", err)
}
// Verify .ecx is present before deleting all shards.
ecxBeforeDelete, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: volumeID,
Collection: "",
IsEcVolume: true,
Ext: ".ecx",
CompactionRevision: math.MaxUint32,
StopOffset: 1,
})
if err != nil {
t.Fatalf("CopyFile .ecx before shard deletion start failed: %v", err)
}
if _, err = ecxBeforeDelete.Recv(); err != nil {
t.Fatalf("CopyFile .ecx before shard deletion recv failed: %v", err)
}
_, err = grpcClient.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
VolumeId: volumeID,
Collection: "",
ShardIds: []uint32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13},
})
if err != nil {
t.Fatalf("VolumeEcShardsDelete all shards failed: %v", err)
}
ecxAfterDelete, err := grpcClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: volumeID,
Collection: "",
IsEcVolume: true,
Ext: ".ecx",
CompactionRevision: math.MaxUint32,
StopOffset: 1,
})
if err == nil {
_, err = ecxAfterDelete.Recv()
}
if err == nil || !strings.Contains(err.Error(), "not found ec volume id") {
t.Fatalf("CopyFile .ecx after deleting all shards should fail not-found, got: %v", err)
}
}
func TestEcShardsCopyFromPeerSuccess(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartDualVolumeCluster(t, matrix.P1())
sourceConn, sourceClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer sourceConn.Close()
destConn, destClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
defer destConn.Close()
const volumeID = uint32(122)
framework.AllocateVolume(t, sourceClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 990008, 0x88993344)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(0), fid, []byte("ec-copy-from-peer-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("source upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := sourceClient.VolumeEcShardsGenerate(ctx, &volume_server_pb.VolumeEcShardsGenerateRequest{
VolumeId: volumeID,
Collection: "",
})
if err != nil {
t.Fatalf("source VolumeEcShardsGenerate failed: %v", err)
}
sourceDataNode := clusterHarness.VolumeAdminAddress(0) + "." + strings.Split(clusterHarness.VolumeGRPCAddress(0), ":")[1]
_, err = destClient.VolumeEcShardsCopy(ctx, &volume_server_pb.VolumeEcShardsCopyRequest{
VolumeId: volumeID,
Collection: "",
SourceDataNode: sourceDataNode,
ShardIds: []uint32{0},
CopyEcxFile: true,
CopyVifFile: true,
})
if err != nil {
t.Fatalf("destination VolumeEcShardsCopy success path failed: %v", err)
}
for _, ext := range []string{".ec00", ".ecx", ".vif"} {
copyStream, copyErr := destClient.CopyFile(ctx, &volume_server_pb.CopyFileRequest{
VolumeId: volumeID,
Collection: "",
IsEcVolume: true,
Ext: ext,
CompactionRevision: math.MaxUint32,
StopOffset: 1,
})
if copyErr != nil {
t.Fatalf("destination CopyFile %s start failed: %v", ext, copyErr)
}
if _, copyErr = copyStream.Recv(); copyErr != nil {
t.Fatalf("destination CopyFile %s recv failed: %v", ext, copyErr)
}
}
}
func TestEcShardsCopyFailsWhenSourceUnavailable(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.VolumeEcShardsCopy(ctx, &volume_server_pb.VolumeEcShardsCopyRequest{
VolumeId: 12345,
Collection: "",
SourceDataNode: "127.0.0.1:1.1",
ShardIds: []uint32{0},
CopyEcxFile: true,
})
if err == nil || !strings.Contains(err.Error(), "VolumeEcShardsCopy volume") {
t.Fatalf("VolumeEcShardsCopy source-unavailable error mismatch: %v", err)
}
}
@@ -0,0 +1,139 @@
package volume_server_grpc_test
import (
"context"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestStateAndStatusRPCs(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
initialState, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
if initialState.GetState() == nil {
t.Fatalf("GetState returned nil state")
}
setResp, err := client.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{
Maintenance: true,
Version: initialState.GetState().GetVersion(),
},
})
if err != nil {
t.Fatalf("SetState(maintenance=true) failed: %v", err)
}
if !setResp.GetState().GetMaintenance() {
t.Fatalf("expected maintenance=true after SetState")
}
setResp, err = client.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{
Maintenance: false,
Version: setResp.GetState().GetVersion(),
},
})
if err != nil {
t.Fatalf("SetState(maintenance=false) failed: %v", err)
}
if setResp.GetState().GetMaintenance() {
t.Fatalf("expected maintenance=false after SetState")
}
statusResp, err := client.VolumeServerStatus(ctx, &volume_server_pb.VolumeServerStatusRequest{})
if err != nil {
t.Fatalf("VolumeServerStatus failed: %v", err)
}
if statusResp.GetVersion() == "" {
t.Fatalf("VolumeServerStatus returned empty version")
}
if len(statusResp.GetDiskStatuses()) == 0 {
t.Fatalf("VolumeServerStatus returned no disk statuses")
}
if statusResp.GetState() == nil {
t.Fatalf("VolumeServerStatus returned nil state")
}
if statusResp.GetMemoryStatus() == nil {
t.Fatalf("VolumeServerStatus returned nil memory status")
}
if statusResp.GetMemoryStatus().GetGoroutines() <= 0 {
t.Fatalf("VolumeServerStatus memory status should report goroutines, got %d", statusResp.GetMemoryStatus().GetGoroutines())
}
pingResp, err := client.Ping(ctx, &volume_server_pb.PingRequest{})
if err != nil {
t.Fatalf("Ping failed: %v", err)
}
if pingResp.GetStartTimeNs() == 0 || pingResp.GetStopTimeNs() == 0 {
t.Fatalf("Ping timestamps should be non-zero: %+v", pingResp)
}
if pingResp.GetStopTimeNs() < pingResp.GetStartTimeNs() {
t.Fatalf("Ping stop time should be >= start time: %+v", pingResp)
}
}
func TestSetStateVersionMismatchAndNilStateNoop(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, client := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
initialState, err := client.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
initialVersion := initialState.GetState().GetVersion()
staleResp, err := client.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{
Maintenance: true,
Version: initialVersion + 1,
},
})
if err == nil {
t.Fatalf("SetState with stale version should fail")
}
if !strings.Contains(err.Error(), "version mismatch") {
t.Fatalf("SetState stale version error mismatch: %v", err)
}
if staleResp.GetState().GetVersion() != initialVersion {
t.Fatalf("SetState stale version should not mutate server version: got %d want %d", staleResp.GetState().GetVersion(), initialVersion)
}
if staleResp.GetState().GetMaintenance() != initialState.GetState().GetMaintenance() {
t.Fatalf("SetState stale version should not mutate maintenance flag")
}
nilResp, err := client.SetState(ctx, &volume_server_pb.SetStateRequest{})
if err != nil {
t.Fatalf("SetState nil-state request should be no-op success: %v", err)
}
if nilResp.GetState().GetVersion() != initialVersion {
t.Fatalf("SetState nil-state should keep version unchanged: got %d want %d", nilResp.GetState().GetVersion(), initialVersion)
}
if nilResp.GetState().GetMaintenance() != initialState.GetState().GetMaintenance() {
t.Fatalf("SetState nil-state should keep maintenance unchanged")
}
}
+387
View File
@@ -0,0 +1,387 @@
package volume_server_grpc_test
import (
"context"
"io"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestScrubVolumeIndexAndUnsupportedMode(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(61)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
indexResp, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
VolumeIds: []uint32{volumeID},
Mode: volume_server_pb.VolumeScrubMode_INDEX,
})
if err != nil {
t.Fatalf("ScrubVolume index mode failed: %v", err)
}
if indexResp.GetTotalVolumes() != 1 {
t.Fatalf("ScrubVolume expected total_volumes=1, got %d", indexResp.GetTotalVolumes())
}
_, err = grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
VolumeIds: []uint32{volumeID},
Mode: volume_server_pb.VolumeScrubMode(99),
})
if err == nil {
t.Fatalf("ScrubVolume should fail for unsupported mode")
}
if !strings.Contains(err.Error(), "unsupported volume scrub mode") {
t.Fatalf("ScrubVolume unsupported mode error mismatch: %v", err)
}
}
func TestScrubEcVolumeMissingVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.ScrubEcVolume(ctx, &volume_server_pb.ScrubEcVolumeRequest{
VolumeIds: []uint32{98765},
Mode: volume_server_pb.VolumeScrubMode_INDEX,
})
if err == nil {
t.Fatalf("ScrubEcVolume should fail for missing EC volume")
}
if !strings.Contains(err.Error(), "EC volume id") {
t.Fatalf("ScrubEcVolume missing-volume error mismatch: %v", err)
}
}
func TestScrubEcVolumeAutoSelectNoEcVolumes(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := grpcClient.ScrubEcVolume(ctx, &volume_server_pb.ScrubEcVolumeRequest{
Mode: volume_server_pb.VolumeScrubMode_INDEX,
})
if err != nil {
t.Fatalf("ScrubEcVolume auto-select failed: %v", err)
}
if resp.GetTotalVolumes() != 0 {
t.Fatalf("ScrubEcVolume auto-select expected total_volumes=0 without EC data, got %d", resp.GetTotalVolumes())
}
if len(resp.GetBrokenVolumeIds()) != 0 {
t.Fatalf("ScrubEcVolume auto-select expected no broken volumes, got %v", resp.GetBrokenVolumeIds())
}
}
func TestQueryInvalidAndMissingFileIDPaths(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
invalidStream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
FromFileIds: []string{"bad-fid"},
Selections: []string{"name"},
Filter: &volume_server_pb.QueryRequest_Filter{},
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
JsonInput: &volume_server_pb.QueryRequest_InputSerialization_JSONInput{},
},
})
if err == nil {
_, err = invalidStream.Recv()
}
if err == nil {
t.Fatalf("Query should fail for invalid file id")
}
missingFid := framework.NewFileID(98766, 1, 1)
missingStream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
FromFileIds: []string{missingFid},
Selections: []string{"name"},
Filter: &volume_server_pb.QueryRequest_Filter{},
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
JsonInput: &volume_server_pb.QueryRequest_InputSerialization_JSONInput{},
},
})
if err == nil {
_, err = missingStream.Recv()
}
if err == nil {
t.Fatalf("Query should fail for missing file id volume")
}
}
func TestScrubVolumeAutoSelectAndAllModes(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeIDA = uint32(62)
const volumeIDB = uint32(63)
framework.AllocateVolume(t, grpcClient, volumeIDA, "")
framework.AllocateVolume(t, grpcClient, volumeIDB, "")
// upload some data so index files are not zero-sized
httpClient := framework.NewHTTPClient()
framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeIDA, 1, 1), []byte("test data A"))
framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), framework.NewFileID(volumeIDB, 2, 2), []byte("test data B"))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
autoResp, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
Mode: volume_server_pb.VolumeScrubMode_INDEX,
})
if err != nil {
t.Fatalf("ScrubVolume auto-select failed: %v", err)
}
if autoResp.GetTotalVolumes() < 2 {
t.Fatalf("ScrubVolume auto-select expected at least 2 volumes, got %d", autoResp.GetTotalVolumes())
}
localResp, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
VolumeIds: []uint32{volumeIDA},
Mode: volume_server_pb.VolumeScrubMode_LOCAL,
})
if err != nil {
t.Fatalf("ScrubVolume local mode failed: %v", err)
}
if localResp.GetTotalVolumes() != 1 {
t.Fatalf("ScrubVolume local mode expected total_volumes=1, got %d", localResp.GetTotalVolumes())
}
if len(localResp.GetBrokenVolumeIds()) != 0 {
t.Fatalf("ScrubVolume local mode expected no broken volumes, got %v: %v", localResp.GetBrokenVolumeIds(), localResp.GetDetails())
}
fullResp, err := grpcClient.ScrubVolume(ctx, &volume_server_pb.ScrubVolumeRequest{
VolumeIds: []uint32{volumeIDA},
Mode: volume_server_pb.VolumeScrubMode_FULL,
})
if err != nil {
t.Fatalf("ScrubVolume full mode failed: %v", err)
}
if fullResp.GetTotalVolumes() != 1 {
t.Fatalf("ScrubVolume full mode expected total_volumes=1, got %d", fullResp.GetTotalVolumes())
}
if len(fullResp.GetBrokenVolumeIds()) != 0 {
t.Fatalf("ScrubVolume full mode expected no broken volumes, got %v: %v", fullResp.GetBrokenVolumeIds(), fullResp.GetDetails())
}
}
func TestQueryJsonSuccessAndCsvNoOutput(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(64)
const needleID = uint64(777001)
const cookie = uint32(0xAABBCCDD)
framework.AllocateVolume(t, grpcClient, volumeID, "")
jsonLines := []byte("{\"score\":3}\n{\"score\":12}\n{\"score\":18}\n")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, needleID, cookie)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, jsonLines)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != 201 {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
queryStream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
FromFileIds: []string{fid},
Selections: []string{"score"},
Filter: &volume_server_pb.QueryRequest_Filter{
Field: "score",
Operand: ">",
Value: "10",
},
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
JsonInput: &volume_server_pb.QueryRequest_InputSerialization_JSONInput{Type: "LINES"},
},
})
if err != nil {
t.Fatalf("Query json start failed: %v", err)
}
firstStripe, err := queryStream.Recv()
if err != nil {
t.Fatalf("Query json recv failed: %v", err)
}
records := string(firstStripe.GetRecords())
if !strings.Contains(records, "score:12") || !strings.Contains(records, "score:18") {
t.Fatalf("Query json records missing expected filtered scores: %q", records)
}
if strings.Contains(records, "score:3") {
t.Fatalf("Query json records should not include filtered-out score: %q", records)
}
_, err = queryStream.Recv()
if err != io.EOF {
t.Fatalf("Query json expected EOF after first stripe, got: %v", err)
}
csvStream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
FromFileIds: []string{fid},
Selections: []string{"score"},
Filter: &volume_server_pb.QueryRequest_Filter{},
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
CsvInput: &volume_server_pb.QueryRequest_InputSerialization_CSVInput{},
},
})
if err != nil {
t.Fatalf("Query csv start failed: %v", err)
}
_, err = csvStream.Recv()
if err != io.EOF {
t.Fatalf("Query csv expected EOF with no rows, got: %v", err)
}
}
func TestQueryJsonNoMatchReturnsEmptyStripe(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(65)
const needleID = uint64(777002)
const cookie = uint32(0xABABCDCD)
framework.AllocateVolume(t, grpcClient, volumeID, "")
jsonLines := []byte("{\"score\":1}\n{\"score\":2}\n")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, needleID, cookie)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, jsonLines)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != 201 {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
queryStream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
FromFileIds: []string{fid},
Selections: []string{"score"},
Filter: &volume_server_pb.QueryRequest_Filter{
Field: "score",
Operand: ">",
Value: "100",
},
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
JsonInput: &volume_server_pb.QueryRequest_InputSerialization_JSONInput{Type: "LINES"},
},
})
if err != nil {
t.Fatalf("Query json no-match start failed: %v", err)
}
firstStripe, err := queryStream.Recv()
if err != nil {
t.Fatalf("Query json no-match recv failed: %v", err)
}
if len(firstStripe.GetRecords()) != 0 {
t.Fatalf("Query json no-match expected empty records stripe, got: %q", string(firstStripe.GetRecords()))
}
_, err = queryStream.Recv()
if err != io.EOF {
t.Fatalf("Query json no-match expected EOF after first empty stripe, got: %v", err)
}
}
func TestQueryCookieMismatchReturnsEOFNoResults(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(66)
const needleID = uint64(777003)
const cookie = uint32(0xCDCDABAB)
framework.AllocateVolume(t, grpcClient, volumeID, "")
jsonLines := []byte("{\"score\":7}\n{\"score\":8}\n")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, needleID, cookie)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, jsonLines)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != 201 {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
wrongCookieFid := framework.NewFileID(volumeID, needleID, cookie+1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stream, err := grpcClient.Query(ctx, &volume_server_pb.QueryRequest{
FromFileIds: []string{wrongCookieFid},
Selections: []string{"score"},
Filter: &volume_server_pb.QueryRequest_Filter{
Field: "score",
Operand: ">",
Value: "0",
},
InputSerialization: &volume_server_pb.QueryRequest_InputSerialization{
JsonInput: &volume_server_pb.QueryRequest_InputSerialization_JSONInput{Type: "LINES"},
},
})
if err != nil {
t.Fatalf("Query start for cookie mismatch should not fail immediately, got: %v", err)
}
_, err = stream.Recv()
if err != io.EOF {
t.Fatalf("Query cookie mismatch expected EOF with no streamed records, got: %v", err)
}
}
+206
View File
@@ -0,0 +1,206 @@
package volume_server_grpc_test
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestVolumeTailSenderMissingVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stream, err := grpcClient.VolumeTailSender(ctx, &volume_server_pb.VolumeTailSenderRequest{VolumeId: 77777, SinceNs: 0, IdleTimeoutSeconds: 1})
if err == nil {
_, err = stream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "not found volume") {
t.Fatalf("VolumeTailSender missing-volume error mismatch: %v", err)
}
}
func TestVolumeTailSenderHeartbeatThenEOF(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(71)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stream, err := grpcClient.VolumeTailSender(ctx, &volume_server_pb.VolumeTailSenderRequest{
VolumeId: volumeID,
SinceNs: 0,
IdleTimeoutSeconds: 1,
})
if err != nil {
t.Fatalf("VolumeTailSender start failed: %v", err)
}
msg, err := stream.Recv()
if err != nil {
t.Fatalf("VolumeTailSender first recv failed: %v", err)
}
if !msg.GetIsLastChunk() {
t.Fatalf("expected first tail message to be heartbeat IsLastChunk=true")
}
_, err = stream.Recv()
if err != io.EOF {
t.Fatalf("expected EOF after idle timeout drain, got: %v", err)
}
}
func TestVolumeTailReceiverMissingVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.VolumeTailReceiver(ctx, &volume_server_pb.VolumeTailReceiverRequest{VolumeId: 88888, SourceVolumeServer: clusterHarness.VolumeServerAddress(), SinceNs: 0, IdleTimeoutSeconds: 1})
if err == nil || !strings.Contains(err.Error(), "receiver not found volume") {
t.Fatalf("VolumeTailReceiver missing-volume error mismatch: %v", err)
}
}
func TestVolumeTailReceiverReplicatesSourceUpdates(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartDualVolumeCluster(t, matrix.P1())
sourceConn, sourceClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer sourceConn.Close()
destConn, destClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
defer destConn.Close()
const volumeID = uint32(72)
framework.AllocateVolume(t, sourceClient, volumeID, "")
framework.AllocateVolume(t, destClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 880003, 0x3456789A)
payload := []byte("tail-receiver-replicates-source-updates")
sourceUploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, sourceUploadResp)
if sourceUploadResp.StatusCode != http.StatusCreated {
t.Fatalf("source upload expected 201, got %d", sourceUploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := destClient.VolumeTailReceiver(ctx, &volume_server_pb.VolumeTailReceiverRequest{
VolumeId: volumeID,
SourceVolumeServer: clusterHarness.VolumeAdminAddress(0) + "." + strings.Split(clusterHarness.VolumeGRPCAddress(0), ":")[1],
SinceNs: 0,
IdleTimeoutSeconds: 1,
})
if err != nil {
t.Fatalf("VolumeTailReceiver success path failed: %v", err)
}
destReadResp := framework.ReadBytes(t, httpClient, clusterHarness.VolumeAdminURL(1), fid)
destReadBody := framework.ReadAllAndClose(t, destReadResp)
if destReadResp.StatusCode != http.StatusOK {
t.Fatalf("destination read after tail receive expected 200, got %d", destReadResp.StatusCode)
}
if string(destReadBody) != string(payload) {
t.Fatalf("destination tail-received payload mismatch: got %q want %q", string(destReadBody), string(payload))
}
}
func TestVolumeTailSenderLargeNeedleChunking(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(73)
framework.AllocateVolume(t, grpcClient, volumeID, "")
httpClient := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 880004, 0x456789AB)
largePayload := bytes.Repeat([]byte("L"), 2*1024*1024+128*1024)
uploadResp := framework.UploadBytes(t, httpClient, clusterHarness.VolumeAdminURL(), fid, largePayload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, err := grpcClient.VolumeTailSender(ctx, &volume_server_pb.VolumeTailSenderRequest{
VolumeId: volumeID,
SinceNs: 0,
IdleTimeoutSeconds: 1,
})
if err != nil {
t.Fatalf("VolumeTailSender start failed: %v", err)
}
dataChunkCount := 0
sawNonLastDataChunk := false
sawLastDataChunk := false
for {
msg, recvErr := stream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
t.Fatalf("VolumeTailSender recv failed: %v", recvErr)
}
if len(msg.GetNeedleBody()) == 0 {
continue
}
dataChunkCount++
if msg.GetIsLastChunk() {
sawLastDataChunk = true
} else {
sawNonLastDataChunk = true
}
}
if dataChunkCount < 2 {
t.Fatalf("VolumeTailSender expected multiple chunks for large needle, got %d", dataChunkCount)
}
if !sawNonLastDataChunk {
t.Fatalf("VolumeTailSender expected at least one non-last data chunk")
}
if !sawLastDataChunk {
t.Fatalf("VolumeTailSender expected a final data chunk marked IsLastChunk=true")
}
}
@@ -0,0 +1,236 @@
package volume_server_grpc_test
import (
"context"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/remote_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestFetchAndWriteNeedleMaintenanceAndMissingVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.FetchAndWriteNeedle(ctx, &volume_server_pb.FetchAndWriteNeedleRequest{
VolumeId: 98781,
NeedleId: 1,
})
if err == nil || !strings.Contains(err.Error(), "not found volume id") {
t.Fatalf("FetchAndWriteNeedle missing-volume error mismatch: %v", err)
}
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{
Maintenance: true,
Version: stateResp.GetState().GetVersion(),
},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
_, err = grpcClient.FetchAndWriteNeedle(ctx, &volume_server_pb.FetchAndWriteNeedleRequest{
VolumeId: 1,
NeedleId: 1,
})
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("FetchAndWriteNeedle maintenance error mismatch: %v", err)
}
}
func TestFetchAndWriteNeedleInvalidRemoteConfig(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(88)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := grpcClient.FetchAndWriteNeedle(ctx, &volume_server_pb.FetchAndWriteNeedleRequest{
VolumeId: volumeID,
NeedleId: 1,
Cookie: 1,
Size: 1,
RemoteConf: &remote_pb.RemoteConf{
Name: "it-invalid-remote",
Type: "does-not-exist",
},
RemoteLocation: &remote_pb.RemoteStorageLocation{
Name: "it-invalid-remote",
Path: "/test",
},
})
if err == nil || !strings.Contains(err.Error(), "get remote client") {
t.Fatalf("FetchAndWriteNeedle invalid-remote error mismatch: %v", err)
}
}
func TestVolumeTierMoveDatToRemoteErrorPaths(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(85)
const collection = "tier-collection"
framework.AllocateVolume(t, grpcClient, volumeID, collection)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
missingStream, err := grpcClient.VolumeTierMoveDatToRemote(ctx, &volume_server_pb.VolumeTierMoveDatToRemoteRequest{
VolumeId: 98782,
Collection: collection,
DestinationBackendName: "dummy",
})
if err == nil {
_, err = missingStream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeTierMoveDatToRemote missing-volume error mismatch: %v", err)
}
mismatchStream, err := grpcClient.VolumeTierMoveDatToRemote(ctx, &volume_server_pb.VolumeTierMoveDatToRemoteRequest{
VolumeId: volumeID,
Collection: "wrong-collection",
DestinationBackendName: "dummy",
})
if err == nil {
_, err = mismatchStream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "unexpected input") {
t.Fatalf("VolumeTierMoveDatToRemote collection mismatch error mismatch: %v", err)
}
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{
Maintenance: true,
Version: stateResp.GetState().GetVersion(),
},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
maintenanceStream, err := grpcClient.VolumeTierMoveDatToRemote(ctx, &volume_server_pb.VolumeTierMoveDatToRemoteRequest{
VolumeId: volumeID,
Collection: collection,
DestinationBackendName: "dummy",
})
if err == nil {
_, err = maintenanceStream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("VolumeTierMoveDatToRemote maintenance error mismatch: %v", err)
}
}
func TestVolumeTierMoveDatToRemoteMissingBackend(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(89)
const collection = "tier-missing-backend"
framework.AllocateVolume(t, grpcClient, volumeID, collection)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stream, err := grpcClient.VolumeTierMoveDatToRemote(ctx, &volume_server_pb.VolumeTierMoveDatToRemoteRequest{
VolumeId: volumeID,
Collection: collection,
DestinationBackendName: "definitely-missing-backend",
})
if err == nil {
_, err = stream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "destination definitely-missing-backend not found") {
t.Fatalf("VolumeTierMoveDatToRemote missing-backend error mismatch: %v", err)
}
}
func TestVolumeTierMoveDatFromRemoteErrorPaths(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(86)
const collection = "tier-download-collection"
framework.AllocateVolume(t, grpcClient, volumeID, collection)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
missingStream, err := grpcClient.VolumeTierMoveDatFromRemote(ctx, &volume_server_pb.VolumeTierMoveDatFromRemoteRequest{
VolumeId: 98783,
Collection: collection,
})
if err == nil {
_, err = missingStream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("VolumeTierMoveDatFromRemote missing-volume error mismatch: %v", err)
}
mismatchStream, err := grpcClient.VolumeTierMoveDatFromRemote(ctx, &volume_server_pb.VolumeTierMoveDatFromRemoteRequest{
VolumeId: volumeID,
Collection: "wrong-collection",
})
if err == nil {
_, err = mismatchStream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "unexpected input") {
t.Fatalf("VolumeTierMoveDatFromRemote collection mismatch error mismatch: %v", err)
}
localDiskStream, err := grpcClient.VolumeTierMoveDatFromRemote(ctx, &volume_server_pb.VolumeTierMoveDatFromRemoteRequest{
VolumeId: volumeID,
Collection: collection,
})
if err == nil {
_, err = localDiskStream.Recv()
}
if err == nil || !strings.Contains(err.Error(), "already on local disk") {
t.Fatalf("VolumeTierMoveDatFromRemote local-disk error mismatch: %v", err)
}
}
+87
View File
@@ -0,0 +1,87 @@
package volume_server_grpc_test
import (
"context"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
func TestVacuumVolumeCheckSuccessAndMissingVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(31)
framework.AllocateVolume(t, grpcClient, volumeID, "")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := grpcClient.VacuumVolumeCheck(ctx, &volume_server_pb.VacuumVolumeCheckRequest{VolumeId: volumeID})
if err != nil {
t.Fatalf("VacuumVolumeCheck existing volume failed: %v", err)
}
if resp.GetGarbageRatio() < 0 || resp.GetGarbageRatio() > 1 {
t.Fatalf("unexpected garbage ratio: %f", resp.GetGarbageRatio())
}
_, err = grpcClient.VacuumVolumeCheck(ctx, &volume_server_pb.VacuumVolumeCheckRequest{VolumeId: 99999})
if err == nil {
t.Fatalf("VacuumVolumeCheck should fail for missing volume")
}
}
func TestVacuumMaintenanceModeRejections(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
stateResp, err := grpcClient.GetState(ctx, &volume_server_pb.GetStateRequest{})
if err != nil {
t.Fatalf("GetState failed: %v", err)
}
_, err = grpcClient.SetState(ctx, &volume_server_pb.SetStateRequest{
State: &volume_server_pb.VolumeServerState{Maintenance: true, Version: stateResp.GetState().GetVersion()},
})
if err != nil {
t.Fatalf("SetState maintenance=true failed: %v", err)
}
assertMaintenanceErr := func(name string, err error) {
t.Helper()
if err == nil {
t.Fatalf("%s should fail in maintenance mode", name)
}
if !strings.Contains(err.Error(), "maintenance mode") {
t.Fatalf("%s expected maintenance mode error, got: %v", name, err)
}
}
compactStream, err := grpcClient.VacuumVolumeCompact(ctx, &volume_server_pb.VacuumVolumeCompactRequest{VolumeId: 31})
if err == nil {
_, err = compactStream.Recv()
}
assertMaintenanceErr("VacuumVolumeCompact", err)
_, err = grpcClient.VacuumVolumeCommit(ctx, &volume_server_pb.VacuumVolumeCommitRequest{VolumeId: 31})
assertMaintenanceErr("VacuumVolumeCommit", err)
_, err = grpcClient.VacuumVolumeCleanup(ctx, &volume_server_pb.VacuumVolumeCleanupRequest{VolumeId: 31})
assertMaintenanceErr("VacuumVolumeCleanup", err)
}
+174
View File
@@ -0,0 +1,174 @@
package volume_server_http_test
import (
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/util/request_id"
)
func TestAdminStatusAndHealthz(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
client := framework.NewHTTPClient()
statusReq, err := http.NewRequest(http.MethodGet, cluster.VolumeAdminURL()+"/status", nil)
if err != nil {
t.Fatalf("create status request: %v", err)
}
statusReq.Header.Set(request_id.AmzRequestIDHeader, "test-request-id-1")
statusResp := framework.DoRequest(t, client, statusReq)
statusBody := framework.ReadAllAndClose(t, statusResp)
if statusResp.StatusCode != http.StatusOK {
t.Fatalf("expected /status code 200, got %d, body: %s", statusResp.StatusCode, string(statusBody))
}
if got := statusResp.Header.Get("Server"); !strings.Contains(got, "SeaweedFS Volume") {
t.Fatalf("expected /status Server header to contain SeaweedFS Volume, got %q", got)
}
if got := statusResp.Header.Get(request_id.AmzRequestIDHeader); got != "test-request-id-1" {
t.Fatalf("expected echoed request id, got %q", got)
}
var payload map[string]interface{}
if err := json.Unmarshal(statusBody, &payload); err != nil {
t.Fatalf("decode status response: %v", err)
}
for _, field := range []string{"Version", "DiskStatuses", "Volumes"} {
if _, found := payload[field]; !found {
t.Fatalf("status payload missing field %q", field)
}
}
healthReq := mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/healthz")
healthReq.Header.Set(request_id.AmzRequestIDHeader, "test-request-id-2")
healthResp := framework.DoRequest(t, client, healthReq)
_ = framework.ReadAllAndClose(t, healthResp)
if healthResp.StatusCode != http.StatusOK {
t.Fatalf("expected /healthz code 200, got %d", healthResp.StatusCode)
}
if got := healthResp.Header.Get("Server"); !strings.Contains(got, "SeaweedFS Volume") {
t.Fatalf("expected /healthz Server header to contain SeaweedFS Volume, got %q", got)
}
if got := healthResp.Header.Get(request_id.AmzRequestIDHeader); got != "test-request-id-2" {
t.Fatalf("expected /healthz echoed request id, got %q", got)
}
uiResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/ui/index.html"))
uiBody := framework.ReadAllAndClose(t, uiResp)
if uiResp.StatusCode != http.StatusOK {
t.Fatalf("expected /ui/index.html code 200, got %d, body: %s", uiResp.StatusCode, string(uiBody))
}
if !strings.Contains(strings.ToLower(string(uiBody)), "volume") {
t.Fatalf("ui page does not look like volume status page")
}
}
func TestOptionsMethodsByPort(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P2())
client := framework.NewHTTPClient()
adminResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodOptions, cluster.VolumeAdminURL()+"/"))
_ = framework.ReadAllAndClose(t, adminResp)
if adminResp.StatusCode != http.StatusOK {
t.Fatalf("admin OPTIONS expected 200, got %d", adminResp.StatusCode)
}
adminAllowed := adminResp.Header.Get("Access-Control-Allow-Methods")
for _, expected := range []string{"PUT", "POST", "GET", "DELETE", "OPTIONS"} {
if !strings.Contains(adminAllowed, expected) {
t.Fatalf("admin allow methods missing %q, got %q", expected, adminAllowed)
}
}
if adminResp.Header.Get("Access-Control-Allow-Headers") != "*" {
t.Fatalf("admin allow headers expected '*', got %q", adminResp.Header.Get("Access-Control-Allow-Headers"))
}
publicResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodOptions, cluster.VolumePublicURL()+"/"))
_ = framework.ReadAllAndClose(t, publicResp)
if publicResp.StatusCode != http.StatusOK {
t.Fatalf("public OPTIONS expected 200, got %d", publicResp.StatusCode)
}
publicAllowed := publicResp.Header.Get("Access-Control-Allow-Methods")
if !strings.Contains(publicAllowed, "GET") || !strings.Contains(publicAllowed, "OPTIONS") {
t.Fatalf("public allow methods expected GET and OPTIONS, got %q", publicAllowed)
}
if strings.Contains(publicAllowed, "POST") {
t.Fatalf("public allow methods should not include POST, got %q", publicAllowed)
}
if publicResp.Header.Get("Access-Control-Allow-Headers") != "*" {
t.Fatalf("public allow headers expected '*', got %q", publicResp.Header.Get("Access-Control-Allow-Headers"))
}
}
func TestOptionsWithOriginIncludesCorsHeaders(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P2())
client := framework.NewHTTPClient()
adminReq := mustNewRequest(t, http.MethodOptions, cluster.VolumeAdminURL()+"/")
adminReq.Header.Set("Origin", "https://example.com")
adminResp := framework.DoRequest(t, client, adminReq)
_ = framework.ReadAllAndClose(t, adminResp)
if adminResp.StatusCode != http.StatusOK {
t.Fatalf("admin OPTIONS expected 200, got %d", adminResp.StatusCode)
}
if adminResp.Header.Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("admin OPTIONS expected Access-Control-Allow-Origin=*, got %q", adminResp.Header.Get("Access-Control-Allow-Origin"))
}
if adminResp.Header.Get("Access-Control-Allow-Credentials") != "true" {
t.Fatalf("admin OPTIONS expected Access-Control-Allow-Credentials=true, got %q", adminResp.Header.Get("Access-Control-Allow-Credentials"))
}
publicReq := mustNewRequest(t, http.MethodOptions, cluster.VolumePublicURL()+"/")
publicReq.Header.Set("Origin", "https://example.com")
publicResp := framework.DoRequest(t, client, publicReq)
_ = framework.ReadAllAndClose(t, publicResp)
if publicResp.StatusCode != http.StatusOK {
t.Fatalf("public OPTIONS expected 200, got %d", publicResp.StatusCode)
}
if publicResp.Header.Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("public OPTIONS expected Access-Control-Allow-Origin=*, got %q", publicResp.Header.Get("Access-Control-Allow-Origin"))
}
if publicResp.Header.Get("Access-Control-Allow-Credentials") != "true" {
t.Fatalf("public OPTIONS expected Access-Control-Allow-Credentials=true, got %q", publicResp.Header.Get("Access-Control-Allow-Credentials"))
}
}
func TestUiIndexNotExposedWhenJwtSigningEnabled(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P3())
client := framework.NewHTTPClient()
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/ui/index.html"))
body := framework.ReadAllAndClose(t, resp)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected /ui/index.html to be gated by auth under JWT profile (401), got %d body=%s", resp.StatusCode, string(body))
}
}
func mustNewRequest(t testing.TB, method, url string) *http.Request {
t.Helper()
req, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatalf("create request %s %s: %v", method, url, err)
}
return req
}
+419
View File
@@ -0,0 +1,419 @@
package volume_server_http_test
import (
"bytes"
"net/http"
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/security"
)
func TestJWTAuthForWriteAndRead(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P3()
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(51)
const needleID = uint64(123456)
const cookie = uint32(0xABCDEF12)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, needleID, cookie)
payload := []byte("jwt-protected-content")
client := framework.NewHTTPClient()
unauthWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
unauthWriteResp := framework.DoRequest(t, client, unauthWrite)
_ = framework.ReadAllAndClose(t, unauthWriteResp)
if unauthWriteResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unauthorized write expected 401, got %d", unauthWriteResp.StatusCode)
}
invalidWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
invalidWrite.Header.Set("Authorization", "Bearer invalid")
invalidWriteResp := framework.DoRequest(t, client, invalidWrite)
_ = framework.ReadAllAndClose(t, invalidWriteResp)
if invalidWriteResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("invalid write token expected 401, got %d", invalidWriteResp.StatusCode)
}
writeToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
authWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
authWrite.Header.Set("Authorization", "Bearer "+string(writeToken))
authWriteResp := framework.DoRequest(t, client, authWrite)
_ = framework.ReadAllAndClose(t, authWriteResp)
if authWriteResp.StatusCode != http.StatusCreated {
t.Fatalf("authorized write expected 201, got %d", authWriteResp.StatusCode)
}
unauthReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
unauthReadResp := framework.DoRequest(t, client, unauthReadReq)
_ = framework.ReadAllAndClose(t, unauthReadResp)
if unauthReadResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unauthorized read expected 401, got %d", unauthReadResp.StatusCode)
}
readToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, fid)
authReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
authReadReq.Header.Set("Authorization", "Bearer "+string(readToken))
authReadResp := framework.DoRequest(t, client, authReadReq)
authReadBody := framework.ReadAllAndClose(t, authReadResp)
if authReadResp.StatusCode != http.StatusOK {
t.Fatalf("authorized read expected 200, got %d", authReadResp.StatusCode)
}
if string(authReadBody) != string(payload) {
t.Fatalf("authorized read content mismatch: got %q want %q", string(authReadBody), string(payload))
}
}
func TestJWTAuthRejectsFidMismatch(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P3()
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(52)
const needleID = uint64(223344)
const cookie = uint32(0x10203040)
const otherNeedleID = uint64(223345)
const otherCookie = uint32(0x50607080)
const wrongCookie = uint32(0x10203041)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, needleID, cookie)
otherFid := framework.NewFileID(volumeID, otherNeedleID, otherCookie)
payload := []byte("jwt-fid-mismatch-content")
client := framework.NewHTTPClient()
writeTokenForOtherFid := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, otherFid)
mismatchedWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
mismatchedWrite.Header.Set("Authorization", "Bearer "+string(writeTokenForOtherFid))
mismatchedWriteResp := framework.DoRequest(t, client, mismatchedWrite)
_ = framework.ReadAllAndClose(t, mismatchedWriteResp)
if mismatchedWriteResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("write with mismatched fid token expected 401, got %d", mismatchedWriteResp.StatusCode)
}
wrongCookieFid := framework.NewFileID(volumeID, needleID, wrongCookie)
writeTokenWrongCookie := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, wrongCookieFid)
wrongCookieWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
wrongCookieWrite.Header.Set("Authorization", "Bearer "+string(writeTokenWrongCookie))
wrongCookieWriteResp := framework.DoRequest(t, client, wrongCookieWrite)
_ = framework.ReadAllAndClose(t, wrongCookieWriteResp)
if wrongCookieWriteResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("write with wrong-cookie fid token expected 401, got %d", wrongCookieWriteResp.StatusCode)
}
writeTokenForFid := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
validWrite := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
validWrite.Header.Set("Authorization", "Bearer "+string(writeTokenForFid))
validWriteResp := framework.DoRequest(t, client, validWrite)
_ = framework.ReadAllAndClose(t, validWriteResp)
if validWriteResp.StatusCode != http.StatusCreated {
t.Fatalf("authorized write expected 201, got %d", validWriteResp.StatusCode)
}
readTokenForOtherFid := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, otherFid)
mismatchedReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
mismatchedReadReq.Header.Set("Authorization", "Bearer "+string(readTokenForOtherFid))
mismatchedReadResp := framework.DoRequest(t, client, mismatchedReadReq)
_ = framework.ReadAllAndClose(t, mismatchedReadResp)
if mismatchedReadResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("read with mismatched fid token expected 401, got %d", mismatchedReadResp.StatusCode)
}
readTokenWrongCookie := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, wrongCookieFid)
wrongCookieReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
wrongCookieReadReq.Header.Set("Authorization", "Bearer "+string(readTokenWrongCookie))
wrongCookieReadResp := framework.DoRequest(t, client, wrongCookieReadReq)
_ = framework.ReadAllAndClose(t, wrongCookieReadResp)
if wrongCookieReadResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("read with wrong-cookie fid token expected 401, got %d", wrongCookieReadResp.StatusCode)
}
}
func newUploadRequest(t testing.TB, url string, payload []byte) *http.Request {
t.Helper()
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
t.Fatalf("create upload request %s: %v", url, err)
}
req.Header.Set("Content-Type", "application/octet-stream")
return req
}
func TestJWTAuthRejectsExpiredTokens(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P3()
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(53)
const needleID = uint64(334455)
const cookie = uint32(0x22334455)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, needleID, cookie)
payload := []byte("expired-token-content")
client := framework.NewHTTPClient()
expiredWriteToken := mustGenExpiredToken(t, []byte(profile.JWTSigningKey), fid)
writeReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
writeReq.Header.Set("Authorization", "Bearer "+expiredWriteToken)
writeResp := framework.DoRequest(t, client, writeReq)
_ = framework.ReadAllAndClose(t, writeResp)
if writeResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expired write token expected 401, got %d", writeResp.StatusCode)
}
// Seed data with a valid token so read auth path can be exercised against existing content.
validWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
validWriteReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
validWriteReq.Header.Set("Authorization", "Bearer "+string(validWriteToken))
validWriteResp := framework.DoRequest(t, client, validWriteReq)
_ = framework.ReadAllAndClose(t, validWriteResp)
if validWriteResp.StatusCode != http.StatusCreated {
t.Fatalf("valid write expected 201, got %d", validWriteResp.StatusCode)
}
expiredReadToken := mustGenExpiredToken(t, []byte(profile.JWTReadKey), fid)
readReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
readReq.Header.Set("Authorization", "Bearer "+expiredReadToken)
readResp := framework.DoRequest(t, client, readReq)
_ = framework.ReadAllAndClose(t, readResp)
if readResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expired read token expected 401, got %d", readResp.StatusCode)
}
}
func TestJWTAuthViaQueryParamAndCookie(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P3()
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(54)
const needleID = uint64(445566)
const cookie = uint32(0x31415926)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, needleID, cookie)
payload := []byte("jwt-query-cookie-content")
client := framework.NewHTTPClient()
writeToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
writeReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(writeToken), payload)
writeResp := framework.DoRequest(t, client, writeReq)
_ = framework.ReadAllAndClose(t, writeResp)
if writeResp.StatusCode != http.StatusCreated {
t.Fatalf("query-jwt write expected 201, got %d", writeResp.StatusCode)
}
readToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, fid)
readReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
readReq.AddCookie(&http.Cookie{Name: "AT", Value: string(readToken)})
readResp := framework.DoRequest(t, client, readReq)
readBody := framework.ReadAllAndClose(t, readResp)
if readResp.StatusCode != http.StatusOK {
t.Fatalf("cookie-jwt read expected 200, got %d", readResp.StatusCode)
}
if string(readBody) != string(payload) {
t.Fatalf("cookie-jwt read body mismatch: got %q want %q", string(readBody), string(payload))
}
}
func TestJWTTokenSourcePrecedenceQueryOverHeader(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P3()
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(55)
const needleID = uint64(556677)
const cookie = uint32(0x99887766)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, needleID, cookie)
otherFID := framework.NewFileID(volumeID, needleID+1, cookie+1)
payload := []byte("jwt-precedence-content")
client := framework.NewHTTPClient()
validWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
invalidWriteQueryToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, otherFID)
writeReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(invalidWriteQueryToken), payload)
writeReq.Header.Set("Authorization", "Bearer "+string(validWriteToken))
writeResp := framework.DoRequest(t, client, writeReq)
_ = framework.ReadAllAndClose(t, writeResp)
if writeResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("query token should take precedence over header token for write, expected 401 got %d", writeResp.StatusCode)
}
// Seed data with valid write token, then exercise read precedence.
seedWriteReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
seedWriteReq.Header.Set("Authorization", "Bearer "+string(validWriteToken))
seedWriteResp := framework.DoRequest(t, client, seedWriteReq)
_ = framework.ReadAllAndClose(t, seedWriteResp)
if seedWriteResp.StatusCode != http.StatusCreated {
t.Fatalf("seed write expected 201, got %d", seedWriteResp.StatusCode)
}
validReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, fid)
invalidReadQueryToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, otherFID)
readReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(invalidReadQueryToken))
readReq.Header.Set("Authorization", "Bearer "+string(validReadToken))
readResp := framework.DoRequest(t, client, readReq)
_ = framework.ReadAllAndClose(t, readResp)
if readResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("query token should take precedence over header token for read, expected 401 got %d", readResp.StatusCode)
}
}
func TestJWTTokenSourcePrecedenceHeaderOverCookie(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P3()
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(56)
const needleID = uint64(667788)
const cookie = uint32(0x11229988)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, needleID, cookie)
otherFID := framework.NewFileID(volumeID, needleID+1, cookie+1)
payload := []byte("jwt-precedence-header-cookie")
client := framework.NewHTTPClient()
validWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
invalidCookieWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, otherFID)
writeReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
writeReq.Header.Set("Authorization", "Bearer "+string(validWriteToken))
writeReq.AddCookie(&http.Cookie{Name: "AT", Value: string(invalidCookieWriteToken)})
writeResp := framework.DoRequest(t, client, writeReq)
_ = framework.ReadAllAndClose(t, writeResp)
if writeResp.StatusCode != http.StatusCreated {
t.Fatalf("header token should take precedence over cookie token for write, expected 201 got %d", writeResp.StatusCode)
}
validReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, fid)
invalidCookieReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, otherFID)
readReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
readReq.Header.Set("Authorization", "Bearer "+string(validReadToken))
readReq.AddCookie(&http.Cookie{Name: "AT", Value: string(invalidCookieReadToken)})
readResp := framework.DoRequest(t, client, readReq)
readBody := framework.ReadAllAndClose(t, readResp)
if readResp.StatusCode != http.StatusOK {
t.Fatalf("header token should take precedence over cookie token for read, expected 200 got %d", readResp.StatusCode)
}
if string(readBody) != string(payload) {
t.Fatalf("header-over-cookie read body mismatch: got %q want %q", string(readBody), string(payload))
}
}
func TestJWTTokenSourcePrecedenceQueryOverCookie(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P3()
clusterHarness := framework.StartSingleVolumeCluster(t, profile)
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(57)
const needleID = uint64(778899)
const cookie = uint32(0x88776655)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, needleID, cookie)
otherFID := framework.NewFileID(volumeID, needleID+1, cookie+1)
payload := []byte("jwt-precedence-query-cookie")
client := framework.NewHTTPClient()
validWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, fid)
invalidQueryWriteToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTSigningKey)), 60, otherFID)
writeReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(invalidQueryWriteToken), payload)
writeReq.AddCookie(&http.Cookie{Name: "AT", Value: string(validWriteToken)})
writeResp := framework.DoRequest(t, client, writeReq)
_ = framework.ReadAllAndClose(t, writeResp)
if writeResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("query token should take precedence over cookie token for write, expected 401 got %d", writeResp.StatusCode)
}
// Seed data with valid write token so read precedence can be exercised.
seedWriteReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
seedWriteReq.Header.Set("Authorization", "Bearer "+string(validWriteToken))
seedWriteResp := framework.DoRequest(t, client, seedWriteReq)
_ = framework.ReadAllAndClose(t, seedWriteResp)
if seedWriteResp.StatusCode != http.StatusCreated {
t.Fatalf("seed write expected 201, got %d", seedWriteResp.StatusCode)
}
validReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, fid)
invalidQueryReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, otherFID)
readReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(invalidQueryReadToken))
readReq.AddCookie(&http.Cookie{Name: "AT", Value: string(validReadToken)})
readResp := framework.DoRequest(t, client, readReq)
_ = framework.ReadAllAndClose(t, readResp)
if readResp.StatusCode != http.StatusUnauthorized {
t.Fatalf("query token should take precedence over cookie token for read, expected 401 got %d", readResp.StatusCode)
}
// Validate positive path: valid query token should succeed even if cookie token is invalid.
validQueryReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid+"?jwt="+string(validReadToken))
invalidCookieReadToken := security.GenJwtForVolumeServer(security.SigningKey([]byte(profile.JWTReadKey)), 60, otherFID)
validQueryReadReq.AddCookie(&http.Cookie{Name: "AT", Value: string(invalidCookieReadToken)})
validQueryReadResp := framework.DoRequest(t, client, validQueryReadReq)
validQueryReadBody := framework.ReadAllAndClose(t, validQueryReadResp)
if validQueryReadResp.StatusCode != http.StatusOK {
t.Fatalf("valid query token should succeed over invalid cookie token, expected 200 got %d", validQueryReadResp.StatusCode)
}
if string(validQueryReadBody) != string(payload) {
t.Fatalf("query-over-cookie read body mismatch: got %q want %q", string(validQueryReadBody), string(payload))
}
}
func mustGenExpiredToken(t testing.TB, key []byte, fid string) string {
t.Helper()
claims := security.SeaweedFileIdClaims{
Fid: fid,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(key)
if err != nil {
t.Fatalf("sign expired token: %v", err)
}
return signed
}
@@ -0,0 +1,232 @@
package volume_server_http_test
import (
"bytes"
"encoding/json"
"net/http"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/operation"
)
func TestChunkManifestExpansionAndBypass(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(102)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
chunkFID := framework.NewFileID(volumeID, 772005, 0x5E6F7081)
chunkPayload := []byte("chunk-manifest-expanded-content")
chunkUploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), chunkFID, chunkPayload)
_ = framework.ReadAllAndClose(t, chunkUploadResp)
if chunkUploadResp.StatusCode != http.StatusCreated {
t.Fatalf("chunk upload expected 201, got %d", chunkUploadResp.StatusCode)
}
manifest := &operation.ChunkManifest{
Name: "manifest.bin",
Mime: "application/octet-stream",
Size: int64(len(chunkPayload)),
Chunks: []*operation.ChunkInfo{
{
Fid: chunkFID,
Offset: 0,
Size: int64(len(chunkPayload)),
},
},
}
manifestBytes, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("marshal chunk manifest: %v", err)
}
manifestFID := framework.NewFileID(volumeID, 772006, 0x6F708192)
manifestUploadReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+manifestFID+"?cm=true", bytes.NewReader(manifestBytes))
if err != nil {
t.Fatalf("create manifest upload request: %v", err)
}
manifestUploadReq.Header.Set("Content-Type", "application/json")
manifestUploadResp := framework.DoRequest(t, client, manifestUploadReq)
_ = framework.ReadAllAndClose(t, manifestUploadResp)
if manifestUploadResp.StatusCode != http.StatusCreated {
t.Fatalf("manifest upload expected 201, got %d", manifestUploadResp.StatusCode)
}
expandedReadResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), manifestFID)
expandedReadBody := framework.ReadAllAndClose(t, expandedReadResp)
if expandedReadResp.StatusCode != http.StatusOK {
t.Fatalf("manifest expanded read expected 200, got %d", expandedReadResp.StatusCode)
}
if string(expandedReadBody) != string(chunkPayload) {
t.Fatalf("manifest expanded read mismatch: got %q want %q", string(expandedReadBody), string(chunkPayload))
}
if expandedReadResp.Header.Get("X-File-Store") != "chunked" {
t.Fatalf("manifest expanded read expected X-File-Store=chunked, got %q", expandedReadResp.Header.Get("X-File-Store"))
}
bypassReadResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+manifestFID+"?cm=false"))
bypassReadBody := framework.ReadAllAndClose(t, bypassReadResp)
if bypassReadResp.StatusCode != http.StatusOK {
t.Fatalf("manifest bypass read expected 200, got %d", bypassReadResp.StatusCode)
}
if bypassReadResp.Header.Get("X-File-Store") != "" {
t.Fatalf("manifest bypass read expected empty X-File-Store header, got %q", bypassReadResp.Header.Get("X-File-Store"))
}
var gotManifest operation.ChunkManifest
if err = json.Unmarshal(bypassReadBody, &gotManifest); err != nil {
t.Fatalf("manifest bypass read expected JSON payload, got decode error: %v body=%q", err, string(bypassReadBody))
}
if len(gotManifest.Chunks) != 1 || gotManifest.Chunks[0].Fid != chunkFID {
t.Fatalf("manifest bypass read payload mismatch: %+v", gotManifest)
}
}
func TestChunkManifestDeleteRemovesChildChunks(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(104)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
chunkFID := framework.NewFileID(volumeID, 772008, 0x8192A3B4)
chunkPayload := []byte("chunk-manifest-delete-content")
chunkUploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), chunkFID, chunkPayload)
_ = framework.ReadAllAndClose(t, chunkUploadResp)
if chunkUploadResp.StatusCode != http.StatusCreated {
t.Fatalf("chunk upload expected 201, got %d", chunkUploadResp.StatusCode)
}
manifest := &operation.ChunkManifest{
Name: "manifest-delete.bin",
Mime: "application/octet-stream",
Size: int64(len(chunkPayload)),
Chunks: []*operation.ChunkInfo{
{
Fid: chunkFID,
Offset: 0,
Size: int64(len(chunkPayload)),
},
},
}
manifestBytes, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("marshal chunk manifest: %v", err)
}
manifestFID := framework.NewFileID(volumeID, 772009, 0x92A3B4C5)
manifestUploadReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+manifestFID+"?cm=true", bytes.NewReader(manifestBytes))
if err != nil {
t.Fatalf("create manifest upload request: %v", err)
}
manifestUploadReq.Header.Set("Content-Type", "application/json")
manifestUploadResp := framework.DoRequest(t, client, manifestUploadReq)
_ = framework.ReadAllAndClose(t, manifestUploadResp)
if manifestUploadResp.StatusCode != http.StatusCreated {
t.Fatalf("manifest upload expected 201, got %d", manifestUploadResp.StatusCode)
}
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+manifestFID))
deleteBody := framework.ReadAllAndClose(t, deleteResp)
if deleteResp.StatusCode != http.StatusAccepted {
t.Fatalf("manifest delete expected 202, got %d", deleteResp.StatusCode)
}
var deleteResult map[string]int64
if err = json.Unmarshal(deleteBody, &deleteResult); err != nil {
t.Fatalf("decode manifest delete response: %v body=%q", err, string(deleteBody))
}
if deleteResult["size"] != int64(len(chunkPayload)) {
t.Fatalf("manifest delete expected size=%d, got %d", len(chunkPayload), deleteResult["size"])
}
manifestReadAfterDelete := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), manifestFID)
_ = framework.ReadAllAndClose(t, manifestReadAfterDelete)
if manifestReadAfterDelete.StatusCode != http.StatusNotFound {
t.Fatalf("manifest read after delete expected 404, got %d", manifestReadAfterDelete.StatusCode)
}
chunkReadAfterDelete := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), chunkFID)
_ = framework.ReadAllAndClose(t, chunkReadAfterDelete)
if chunkReadAfterDelete.StatusCode != http.StatusNotFound {
t.Fatalf("chunk read after manifest delete expected 404, got %d", chunkReadAfterDelete.StatusCode)
}
}
func TestChunkManifestDeleteFailsWhenChildDeletionFails(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(105)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
manifest := &operation.ChunkManifest{
Name: "manifest-delete-failure.bin",
Mime: "application/octet-stream",
Size: 1,
Chunks: []*operation.ChunkInfo{
{
Fid: "not-a-valid-fid",
Offset: 0,
Size: 1,
},
},
}
manifestBytes, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("marshal chunk manifest: %v", err)
}
manifestFID := framework.NewFileID(volumeID, 772010, 0xA3B4C5D6)
manifestUploadReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+manifestFID+"?cm=true", bytes.NewReader(manifestBytes))
if err != nil {
t.Fatalf("create manifest upload request: %v", err)
}
manifestUploadReq.Header.Set("Content-Type", "application/json")
manifestUploadResp := framework.DoRequest(t, client, manifestUploadReq)
_ = framework.ReadAllAndClose(t, manifestUploadResp)
if manifestUploadResp.StatusCode != http.StatusCreated {
t.Fatalf("manifest upload expected 201, got %d", manifestUploadResp.StatusCode)
}
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+manifestFID))
deleteBody := framework.ReadAllAndClose(t, deleteResp)
if deleteResp.StatusCode != http.StatusInternalServerError {
t.Fatalf("manifest delete with invalid child fid expected 500, got %d body=%q", deleteResp.StatusCode, string(deleteBody))
}
manifestBypassRead := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+manifestFID+"?cm=false"))
manifestBypassBody := framework.ReadAllAndClose(t, manifestBypassRead)
if manifestBypassRead.StatusCode != http.StatusOK {
t.Fatalf("manifest bypass read after failed delete expected 200, got %d", manifestBypassRead.StatusCode)
}
var gotManifest operation.ChunkManifest
if err = json.Unmarshal(manifestBypassBody, &gotManifest); err != nil {
t.Fatalf("manifest bypass read expected JSON payload, got decode error: %v body=%q", err, string(manifestBypassBody))
}
if len(gotManifest.Chunks) != 1 || gotManifest.Chunks[0].Fid != "not-a-valid-fid" {
t.Fatalf("manifest payload mismatch after failed delete: %+v", gotManifest)
}
}
@@ -0,0 +1,97 @@
package volume_server_http_test
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func gzipData(t testing.TB, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
if _, err := zw.Write(data); err != nil {
t.Fatalf("gzip write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("gzip close: %v", err)
}
return buf.Bytes()
}
func gunzipData(t testing.TB, data []byte) []byte {
t.Helper()
zr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
t.Fatalf("gunzip new reader: %v", err)
}
defer zr.Close()
out, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip read: %v", err)
}
return out
}
func TestCompressedReadAcceptEncodingMatrix(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(103)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 772007, 0x708192A3)
plainPayload := []byte("compressed-read-accept-encoding-matrix-content-compressed-read-accept-encoding-matrix-content")
compressedPayload := gzipData(t, plainPayload)
uploadReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+fid, bytes.NewReader(compressedPayload))
if err != nil {
t.Fatalf("create compressed upload request: %v", err)
}
uploadReq.Header.Set("Content-Type", "text/plain")
uploadReq.Header.Set("Content-Encoding", "gzip")
uploadResp := framework.DoRequest(t, client, uploadReq)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("compressed upload expected 201, got %d", uploadResp.StatusCode)
}
gzipReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
gzipReadReq.Header.Set("Accept-Encoding", "gzip")
gzipReadResp := framework.DoRequest(t, client, gzipReadReq)
gzipReadBody := framework.ReadAllAndClose(t, gzipReadResp)
if gzipReadResp.StatusCode != http.StatusOK {
t.Fatalf("gzip-accepted read expected 200, got %d", gzipReadResp.StatusCode)
}
if gzipReadResp.Header.Get("Content-Encoding") != "gzip" {
t.Fatalf("gzip-accepted read expected Content-Encoding=gzip, got %q", gzipReadResp.Header.Get("Content-Encoding"))
}
if string(gunzipData(t, gzipReadBody)) != string(plainPayload) {
t.Fatalf("gzip-accepted read body mismatch after gunzip")
}
identityReadReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
identityReadReq.Header.Set("Accept-Encoding", "identity")
identityReadResp := framework.DoRequest(t, client, identityReadReq)
identityReadBody := framework.ReadAllAndClose(t, identityReadResp)
if identityReadResp.StatusCode != http.StatusOK {
t.Fatalf("identity read expected 200, got %d", identityReadResp.StatusCode)
}
if identityReadResp.Header.Get("Content-Encoding") != "" {
t.Fatalf("identity read expected no Content-Encoding header, got %q", identityReadResp.Header.Get("Content-Encoding"))
}
if string(identityReadBody) != string(plainPayload) {
t.Fatalf("identity read body mismatch: got %q want %q", string(identityReadBody), string(plainPayload))
}
}
@@ -0,0 +1,102 @@
package volume_server_http_test
import (
"fmt"
"net/http"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func TestReadPassthroughHeadersAndDownloadDisposition(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(96)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fullFileID := framework.NewFileID(volumeID, 661122, 0x55667788)
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fullFileID, []byte("passthrough-header-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
parts := strings.SplitN(fullFileID, ",", 2)
if len(parts) != 2 {
t.Fatalf("unexpected file id format: %q", fullFileID)
}
fidOnly := parts[1]
url := fmt.Sprintf("%s/%d/%s/%s?response-content-type=text/plain&response-cache-control=no-store&dl=true",
clusterHarness.VolumeAdminURL(),
volumeID,
fidOnly,
"report.txt",
)
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, url))
_ = framework.ReadAllAndClose(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("passthrough read expected 200, got %d", resp.StatusCode)
}
if resp.Header.Get("Content-Type") != "text/plain" {
t.Fatalf("response-content-type override mismatch: %q", resp.Header.Get("Content-Type"))
}
if resp.Header.Get("Cache-Control") != "no-store" {
t.Fatalf("response-cache-control override mismatch: %q", resp.Header.Get("Cache-Control"))
}
contentDisposition := resp.Header.Get("Content-Disposition")
if !strings.Contains(contentDisposition, "attachment") || !strings.Contains(contentDisposition, "report.txt") {
t.Fatalf("download disposition header mismatch: %q", contentDisposition)
}
}
func TestStaticAssetEndpoints(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
client := framework.NewHTTPClient()
faviconResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/favicon.ico"))
_ = framework.ReadAllAndClose(t, faviconResp)
if faviconResp.StatusCode != http.StatusOK {
t.Fatalf("/favicon.ico expected 200, got %d", faviconResp.StatusCode)
}
staticResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/seaweedfsstatic/seaweed50x50.png"))
_ = framework.ReadAllAndClose(t, staticResp)
if staticResp.StatusCode != http.StatusOK {
t.Fatalf("/seaweedfsstatic/seaweed50x50.png expected 200, got %d", staticResp.StatusCode)
}
}
func TestStaticAssetEndpointsOnPublicPort(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
client := framework.NewHTTPClient()
faviconResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumePublicURL()+"/favicon.ico"))
_ = framework.ReadAllAndClose(t, faviconResp)
if faviconResp.StatusCode != http.StatusOK {
t.Fatalf("public /favicon.ico expected 200, got %d", faviconResp.StatusCode)
}
staticResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumePublicURL()+"/seaweedfsstatic/seaweed50x50.png"))
_ = framework.ReadAllAndClose(t, staticResp)
if staticResp.StatusCode != http.StatusOK {
t.Fatalf("public /seaweedfsstatic/seaweed50x50.png expected 200, got %d", staticResp.StatusCode)
}
}
@@ -0,0 +1,92 @@
package volume_server_http_test
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"net/http"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func makePNGFixture(t testing.TB, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.RGBA{R: uint8(x * 20), G: uint8(y * 20), B: 200, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png fixture: %v", err)
}
return buf.Bytes()
}
func decodeImageConfig(t testing.TB, data []byte) image.Config {
t.Helper()
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode image config: %v", err)
}
return cfg
}
func TestImageResizeAndCropReadVariants(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(101)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fullFileID := framework.NewFileID(volumeID, 772004, 0x4D5E6F70)
uploadReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fullFileID, makePNGFixture(t, 6, 4))
uploadReq.Header.Set("Content-Type", "image/png")
uploadResp := framework.DoRequest(t, client, uploadReq)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("image upload expected 201, got %d", uploadResp.StatusCode)
}
parts := strings.SplitN(fullFileID, ",", 2)
if len(parts) != 2 {
t.Fatalf("unexpected file id format: %q", fullFileID)
}
fidOnly := parts[1]
resizeURL := fmt.Sprintf("%s/%d/%s/%s?width=2&height=1", clusterHarness.VolumeAdminURL(), volumeID, fidOnly, "fixture.png")
resizeResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, resizeURL))
resizeBody := framework.ReadAllAndClose(t, resizeResp)
if resizeResp.StatusCode != http.StatusOK {
t.Fatalf("image resize read expected 200, got %d", resizeResp.StatusCode)
}
resizeCfg := decodeImageConfig(t, resizeBody)
if resizeCfg.Width > 2 || resizeCfg.Height > 1 {
t.Fatalf("image resize expected dimensions <= 2x1, got %dx%d", resizeCfg.Width, resizeCfg.Height)
}
cropURL := fmt.Sprintf("%s/%d/%s/%s?crop_x1=1&crop_y1=1&crop_x2=4&crop_y2=3", clusterHarness.VolumeAdminURL(), volumeID, fidOnly, "fixture.png")
cropResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, cropURL))
cropBody := framework.ReadAllAndClose(t, cropResp)
if cropResp.StatusCode != http.StatusOK {
t.Fatalf("image crop read expected 200, got %d", cropResp.StatusCode)
}
cropCfg := decodeImageConfig(t, cropBody)
if cropCfg.Width != 3 || cropCfg.Height != 2 {
t.Fatalf("image crop expected 3x2, got %dx%d", cropCfg.Width, cropCfg.Height)
}
}
@@ -0,0 +1,287 @@
package volume_server_http_test
import (
"bytes"
"net/http"
"strconv"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func TestPublicPortReadOnlyMethodBehavior(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(81)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, 123321, 0x01020304)
originalData := []byte("public-port-original")
replacementData := []byte("public-port-replacement")
client := framework.NewHTTPClient()
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, originalData)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("admin upload expected 201, got %d", uploadResp.StatusCode)
}
publicReadResp := framework.ReadBytes(t, client, clusterHarness.VolumePublicURL(), fid)
publicReadBody := framework.ReadAllAndClose(t, publicReadResp)
if publicReadResp.StatusCode != http.StatusOK {
t.Fatalf("public GET expected 200, got %d", publicReadResp.StatusCode)
}
if string(publicReadBody) != string(originalData) {
t.Fatalf("public GET body mismatch: got %q want %q", string(publicReadBody), string(originalData))
}
publicPostReq := newUploadRequest(t, clusterHarness.VolumePublicURL()+"/"+fid, replacementData)
publicPostResp := framework.DoRequest(t, client, publicPostReq)
_ = framework.ReadAllAndClose(t, publicPostResp)
if publicPostResp.StatusCode != http.StatusOK {
t.Fatalf("public POST expected passthrough 200, got %d", publicPostResp.StatusCode)
}
publicDeleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumePublicURL()+"/"+fid))
_ = framework.ReadAllAndClose(t, publicDeleteResp)
if publicDeleteResp.StatusCode != http.StatusOK {
t.Fatalf("public DELETE expected passthrough 200, got %d", publicDeleteResp.StatusCode)
}
adminReadResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
adminReadBody := framework.ReadAllAndClose(t, adminReadResp)
if adminReadResp.StatusCode != http.StatusOK {
t.Fatalf("admin GET after public POST/DELETE expected 200, got %d", adminReadResp.StatusCode)
}
if string(adminReadBody) != string(originalData) {
t.Fatalf("public port should not mutate data: got %q want %q", string(adminReadBody), string(originalData))
}
}
func TestCorsAndUnsupportedMethodBehavior(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(82)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, 789789, 0x0A0B0C0D)
client := framework.NewHTTPClient()
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("cors-check"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("admin upload expected 201, got %d", uploadResp.StatusCode)
}
adminOriginReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
adminOriginReq.Header.Set("Origin", "https://example.com")
adminOriginResp := framework.DoRequest(t, client, adminOriginReq)
_ = framework.ReadAllAndClose(t, adminOriginResp)
if adminOriginResp.Header.Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("admin GET origin header mismatch: %q", adminOriginResp.Header.Get("Access-Control-Allow-Origin"))
}
if adminOriginResp.Header.Get("Access-Control-Allow-Credentials") != "true" {
t.Fatalf("admin GET credentials header mismatch: %q", adminOriginResp.Header.Get("Access-Control-Allow-Credentials"))
}
publicOriginReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumePublicURL()+"/"+fid)
publicOriginReq.Header.Set("Origin", "https://example.com")
publicOriginResp := framework.DoRequest(t, client, publicOriginReq)
_ = framework.ReadAllAndClose(t, publicOriginResp)
if publicOriginResp.Header.Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("public GET origin header mismatch: %q", publicOriginResp.Header.Get("Access-Control-Allow-Origin"))
}
if publicOriginResp.Header.Get("Access-Control-Allow-Credentials") != "true" {
t.Fatalf("public GET credentials header mismatch: %q", publicOriginResp.Header.Get("Access-Control-Allow-Credentials"))
}
adminPatchReq, err := http.NewRequest(http.MethodPatch, clusterHarness.VolumeAdminURL()+"/"+fid, bytes.NewReader([]byte("patch")))
if err != nil {
t.Fatalf("create admin PATCH request: %v", err)
}
adminPatchResp := framework.DoRequest(t, client, adminPatchReq)
_ = framework.ReadAllAndClose(t, adminPatchResp)
if adminPatchResp.StatusCode != http.StatusBadRequest {
t.Fatalf("admin PATCH expected 400, got %d", adminPatchResp.StatusCode)
}
publicPatchReq, err := http.NewRequest(http.MethodPatch, clusterHarness.VolumePublicURL()+"/"+fid, bytes.NewReader([]byte("patch")))
if err != nil {
t.Fatalf("create public PATCH request: %v", err)
}
publicPatchResp := framework.DoRequest(t, client, publicPatchReq)
_ = framework.ReadAllAndClose(t, publicPatchResp)
if publicPatchResp.StatusCode != http.StatusOK {
t.Fatalf("public PATCH expected passthrough 200, got %d", publicPatchResp.StatusCode)
}
}
func TestUnsupportedMethodTraceParity(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(83)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, 123999, 0x01010101)
client := framework.NewHTTPClient()
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("trace-method-check"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
adminTraceReq := mustNewRequest(t, http.MethodTrace, clusterHarness.VolumeAdminURL()+"/"+fid)
adminTraceResp := framework.DoRequest(t, client, adminTraceReq)
_ = framework.ReadAllAndClose(t, adminTraceResp)
if adminTraceResp.StatusCode != http.StatusBadRequest {
t.Fatalf("admin TRACE expected 400, got %d", adminTraceResp.StatusCode)
}
publicTraceReq := mustNewRequest(t, http.MethodTrace, clusterHarness.VolumePublicURL()+"/"+fid)
publicTraceResp := framework.DoRequest(t, client, publicTraceReq)
_ = framework.ReadAllAndClose(t, publicTraceResp)
if publicTraceResp.StatusCode != http.StatusOK {
t.Fatalf("public TRACE expected passthrough 200, got %d", publicTraceResp.StatusCode)
}
}
func TestUnsupportedMethodPropfindParity(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(84)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, 124000, 0x02020202)
client := framework.NewHTTPClient()
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("propfind-method-check"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
adminReq := mustNewRequest(t, "PROPFIND", clusterHarness.VolumeAdminURL()+"/"+fid)
adminResp := framework.DoRequest(t, client, adminReq)
_ = framework.ReadAllAndClose(t, adminResp)
if adminResp.StatusCode != http.StatusBadRequest {
t.Fatalf("admin PROPFIND expected 400, got %d", adminResp.StatusCode)
}
publicReq := mustNewRequest(t, "PROPFIND", clusterHarness.VolumePublicURL()+"/"+fid)
publicResp := framework.DoRequest(t, client, publicReq)
_ = framework.ReadAllAndClose(t, publicResp)
if publicResp.StatusCode != http.StatusOK {
t.Fatalf("public PROPFIND expected passthrough 200, got %d", publicResp.StatusCode)
}
verifyResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
verifyBody := framework.ReadAllAndClose(t, verifyResp)
if verifyResp.StatusCode != http.StatusOK {
t.Fatalf("verify GET expected 200, got %d", verifyResp.StatusCode)
}
if string(verifyBody) != "propfind-method-check" {
t.Fatalf("PROPFIND should not mutate data, got %q", string(verifyBody))
}
}
func TestUnsupportedMethodConnectParity(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(85)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, 124001, 0x03030303)
client := framework.NewHTTPClient()
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("connect-method-check"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
adminReq := mustNewRequest(t, "CONNECT", clusterHarness.VolumeAdminURL()+"/"+fid)
adminResp := framework.DoRequest(t, client, adminReq)
_ = framework.ReadAllAndClose(t, adminResp)
if adminResp.StatusCode != http.StatusBadRequest {
t.Fatalf("admin CONNECT expected 400, got %d", adminResp.StatusCode)
}
publicReq := mustNewRequest(t, "CONNECT", clusterHarness.VolumePublicURL()+"/"+fid)
publicResp := framework.DoRequest(t, client, publicReq)
_ = framework.ReadAllAndClose(t, publicResp)
if publicResp.StatusCode != http.StatusOK {
t.Fatalf("public CONNECT expected passthrough 200, got %d", publicResp.StatusCode)
}
verifyResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
verifyBody := framework.ReadAllAndClose(t, verifyResp)
if verifyResp.StatusCode != http.StatusOK {
t.Fatalf("verify GET expected 200, got %d", verifyResp.StatusCode)
}
if string(verifyBody) != "connect-method-check" {
t.Fatalf("CONNECT should not mutate data, got %q", string(verifyBody))
}
}
func TestPublicPortHeadReadParity(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P2())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(86)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, 124002, 0x04040404)
payload := []byte("public-head-parity-content")
client := framework.NewHTTPClient()
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
headResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodHead, clusterHarness.VolumePublicURL()+"/"+fid))
headBody := framework.ReadAllAndClose(t, headResp)
if headResp.StatusCode != http.StatusOK {
t.Fatalf("public HEAD expected 200, got %d", headResp.StatusCode)
}
if got := headResp.Header.Get("Content-Length"); got != strconv.Itoa(len(payload)) {
t.Fatalf("public HEAD content-length mismatch: got %q want %d", got, len(payload))
}
if len(headBody) != 0 {
t.Fatalf("public HEAD body should be empty, got %d bytes", len(headBody))
}
}
@@ -0,0 +1,82 @@
package volume_server_http_test
import (
"net/http"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func TestMultiRangeReadReturnsMultipartPayload(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(97)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 771999, 0x0A1B2C3D)
payload := []byte("0123456789abcdef")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
multiRangeReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
multiRangeReq.Header.Set("Range", "bytes=0-1,4-5")
multiRangeResp := framework.DoRequest(t, client, multiRangeReq)
multiRangeBody := framework.ReadAllAndClose(t, multiRangeResp)
if multiRangeResp.StatusCode != http.StatusPartialContent {
t.Fatalf("multi-range expected 206, got %d", multiRangeResp.StatusCode)
}
if !strings.Contains(multiRangeResp.Header.Get("Content-Type"), "multipart/byteranges") {
t.Fatalf("multi-range content-type mismatch: %q", multiRangeResp.Header.Get("Content-Type"))
}
bodyText := string(multiRangeBody)
if !strings.Contains(bodyText, "01") || !strings.Contains(bodyText, "45") {
t.Fatalf("multi-range body missing expected segments: %q", bodyText)
}
}
func TestOversizedCombinedRangesAreIgnored(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(100)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 772003, 0x3C4D5E6F)
payload := []byte("0123456789abcdef")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
// Range bytes sum is 22 (> payload size 16), which exercises the oversized-range guard path.
oversizedRangeReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
oversizedRangeReq.Header.Set("Range", "bytes=0-10,5-15")
oversizedRangeResp := framework.DoRequest(t, client, oversizedRangeReq)
oversizedRangeBody := framework.ReadAllAndClose(t, oversizedRangeResp)
if oversizedRangeResp.StatusCode != http.StatusOK {
t.Fatalf("oversized combined range expected 200, got %d", oversizedRangeResp.StatusCode)
}
if len(oversizedRangeBody) != 0 {
t.Fatalf("oversized combined range expected empty body, got %d bytes", len(oversizedRangeBody))
}
}
@@ -0,0 +1,54 @@
package volume_server_http_test
import (
"net/http"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func TestReadDeletedQueryReturnsDeletedNeedleData(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(94)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 551234, 0xCAFE1234)
payload := []byte("read-deleted-needle-payload")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+fid))
_ = framework.ReadAllAndClose(t, deleteResp)
if deleteResp.StatusCode != http.StatusAccepted {
t.Fatalf("delete expected 202, got %d", deleteResp.StatusCode)
}
normalRead := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
_ = framework.ReadAllAndClose(t, normalRead)
if normalRead.StatusCode != http.StatusNotFound {
t.Fatalf("normal read after delete expected 404, got %d", normalRead.StatusCode)
}
readDeletedReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid+"?readDeleted=true")
readDeletedResp := framework.DoRequest(t, client, readDeletedReq)
readDeletedBody := framework.ReadAllAndClose(t, readDeletedResp)
if readDeletedResp.StatusCode != http.StatusOK {
t.Fatalf("read with readDeleted=true expected 200, got %d", readDeletedResp.StatusCode)
}
if string(readDeletedBody) != string(payload) {
t.Fatalf("readDeleted body mismatch: got %q want %q", string(readDeletedBody), string(payload))
}
}
@@ -0,0 +1,319 @@
package volume_server_http_test
import (
"net/http"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func TestReadModeProxyMissingLocalVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P1()
profile.ReadMode = "proxy"
clusterHarness := framework.StartDualVolumeCluster(t, profile)
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer conn0.Close()
const volumeID = uint32(101)
framework.AllocateVolume(t, grpc0, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 120001, 0x0102ABCD)
payload := []byte("proxy-read-mode-forwarded-content")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
readURL := clusterHarness.VolumeAdminURL(1) + "/" + fid
var finalBody []byte
if !waitForHTTPStatus(t, client, readURL, http.StatusOK, 10*time.Second, func(resp *http.Response) {
finalBody = framework.ReadAllAndClose(t, resp)
}) {
t.Fatalf("proxy read mode did not return 200 from non-owning volume server within deadline")
}
if string(finalBody) != string(payload) {
t.Fatalf("proxy read mode body mismatch: got %q want %q", string(finalBody), string(payload))
}
}
func TestReadModeRedirectMissingLocalVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P1()
profile.ReadMode = "redirect"
clusterHarness := framework.StartDualVolumeCluster(t, profile)
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer conn0.Close()
const volumeID = uint32(102)
framework.AllocateVolume(t, grpc0, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 120002, 0x0102DCBA)
payload := []byte("redirect-read-mode-content")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
noRedirectClient := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
readURL := clusterHarness.VolumeAdminURL(1) + "/" + fid
var redirectLocation string
if !waitForHTTPStatus(t, noRedirectClient, readURL, http.StatusMovedPermanently, 10*time.Second, func(resp *http.Response) {
redirectLocation = resp.Header.Get("Location")
_ = framework.ReadAllAndClose(t, resp)
}) {
t.Fatalf("redirect read mode did not return 301 from non-owning volume server within deadline")
}
if redirectLocation == "" {
t.Fatalf("redirect response missing Location header")
}
if !strings.Contains(redirectLocation, "proxied=true") {
t.Fatalf("redirect Location should include proxied=true, got %q", redirectLocation)
}
followResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, redirectLocation))
followBody := framework.ReadAllAndClose(t, followResp)
if followResp.StatusCode != http.StatusOK {
t.Fatalf("following redirect expected 200, got %d", followResp.StatusCode)
}
if string(followBody) != string(payload) {
t.Fatalf("redirect-follow body mismatch: got %q want %q", string(followBody), string(payload))
}
}
func TestReadModeLocalMissingLocalVolumeReturnsNotFound(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P1()
profile.ReadMode = "local"
clusterHarness := framework.StartDualVolumeCluster(t, profile)
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer conn0.Close()
const volumeID = uint32(103)
framework.AllocateVolume(t, grpc0, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 120003, 0x0102BEEF)
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, []byte("local-read-mode-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
readResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(1), fid)
_ = framework.ReadAllAndClose(t, readResp)
if readResp.StatusCode != http.StatusNotFound {
t.Fatalf("local read mode expected 404 on non-owning server, got %d", readResp.StatusCode)
}
}
func TestReadDeletedProxyModeOnMissingLocalVolume(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P1()
profile.ReadMode = "proxy"
clusterHarness := framework.StartDualVolumeCluster(t, profile)
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer conn0.Close()
const volumeID = uint32(104)
framework.AllocateVolume(t, grpc0, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 120004, 0x0102CAFE)
payload := []byte("proxy-readDeleted-missing-local-content")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL(0)+"/"+fid))
_ = framework.ReadAllAndClose(t, deleteResp)
if deleteResp.StatusCode != http.StatusAccepted {
t.Fatalf("delete expected 202, got %d", deleteResp.StatusCode)
}
readURL := clusterHarness.VolumeAdminURL(1) + "/" + fid + "?readDeleted=true"
var proxiedBody []byte
if !waitForHTTPStatus(t, client, readURL, http.StatusOK, 10*time.Second, func(resp *http.Response) {
proxiedBody = framework.ReadAllAndClose(t, resp)
}) {
t.Fatalf("proxy readDeleted path did not return 200 from non-owning volume server within deadline")
}
if string(proxiedBody) != string(payload) {
t.Fatalf("proxy readDeleted body mismatch: got %q want %q", string(proxiedBody), string(payload))
}
}
func TestReadDeletedRedirectModeDropsQueryParameterParity(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P1()
profile.ReadMode = "redirect"
clusterHarness := framework.StartDualVolumeCluster(t, profile)
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer conn0.Close()
const volumeID = uint32(105)
framework.AllocateVolume(t, grpc0, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 120005, 0x0102FACE)
payload := []byte("redirect-readDeleted-query-drop-parity")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL(0)+"/"+fid))
_ = framework.ReadAllAndClose(t, deleteResp)
if deleteResp.StatusCode != http.StatusAccepted {
t.Fatalf("delete expected 202, got %d", deleteResp.StatusCode)
}
noRedirectClient := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
redirectURL := clusterHarness.VolumeAdminURL(1) + "/" + fid + "?readDeleted=true"
var location string
if !waitForHTTPStatus(t, noRedirectClient, redirectURL, http.StatusMovedPermanently, 10*time.Second, func(resp *http.Response) {
location = resp.Header.Get("Location")
_ = framework.ReadAllAndClose(t, resp)
}) {
t.Fatalf("redirect readDeleted path did not return 301 from non-owning volume server within deadline")
}
if location == "" {
t.Fatalf("redirect readDeleted response missing Location header")
}
if !strings.Contains(location, "proxied=true") {
t.Fatalf("redirect readDeleted Location should include proxied=true, got %q", location)
}
if strings.Contains(location, "readDeleted=true") {
t.Fatalf("redirect readDeleted Location should reflect current query-drop behavior, got %q", location)
}
followResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, location))
_ = framework.ReadAllAndClose(t, followResp)
if followResp.StatusCode != http.StatusNotFound {
t.Fatalf("redirect-follow without readDeleted query expected 404 for deleted needle, got %d", followResp.StatusCode)
}
}
func TestReadModeRedirectPreservesCollectionQuery(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P1()
profile.ReadMode = "redirect"
clusterHarness := framework.StartDualVolumeCluster(t, profile)
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer conn0.Close()
const volumeID = uint32(109)
const collection = "redirect-collection"
framework.AllocateVolume(t, grpc0, volumeID, collection)
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 120006, 0x0102F00D)
payload := []byte("redirect-collection-preserve-content")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(0), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
noRedirectClient := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
redirectURL := clusterHarness.VolumeAdminURL(1) + "/" + fid + "?collection=" + collection
var location string
if !waitForHTTPStatus(t, noRedirectClient, redirectURL, http.StatusMovedPermanently, 10*time.Second, func(resp *http.Response) {
location = resp.Header.Get("Location")
_ = framework.ReadAllAndClose(t, resp)
}) {
t.Fatalf("redirect collection path did not return 301 from non-owning volume server within deadline")
}
if location == "" {
t.Fatalf("redirect collection response missing Location header")
}
if !strings.Contains(location, "proxied=true") {
t.Fatalf("redirect collection Location should include proxied=true, got %q", location)
}
if !strings.Contains(location, "collection="+collection) {
t.Fatalf("redirect collection Location should preserve collection query, got %q", location)
}
followResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, location))
followBody := framework.ReadAllAndClose(t, followResp)
if followResp.StatusCode != http.StatusOK {
t.Fatalf("redirect-follow expected 200, got %d", followResp.StatusCode)
}
if string(followBody) != string(payload) {
t.Fatalf("redirect-follow body mismatch: got %q want %q", string(followBody), string(payload))
}
}
func waitForHTTPStatus(t testing.TB, client *http.Client, url string, expectedStatus int, timeout time.Duration, onMatch func(resp *http.Response)) bool {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, url))
if resp.StatusCode == expectedStatus {
onMatch(resp)
return true
}
_ = framework.ReadAllAndClose(t, resp)
time.Sleep(200 * time.Millisecond)
}
return false
}
@@ -0,0 +1,191 @@
package volume_server_http_test
import (
"fmt"
"net/http"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func TestReadPathShapesAndIfModifiedSince(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(93)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fullFileID := framework.NewFileID(volumeID, 771234, 0xBEEFCACE)
uploadPayload := []byte("read-path-shape-content")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fullFileID, uploadPayload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
parts := strings.SplitN(fullFileID, ",", 2)
if len(parts) != 2 {
t.Fatalf("unexpected file id format: %q", fullFileID)
}
fidOnly := parts[1]
readByVidFid := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, fmt.Sprintf("%s/%d/%s", clusterHarness.VolumeAdminURL(), volumeID, fidOnly)))
readByVidFidBody := framework.ReadAllAndClose(t, readByVidFid)
if readByVidFid.StatusCode != http.StatusOK {
t.Fatalf("GET /{vid}/{fid} expected 200, got %d", readByVidFid.StatusCode)
}
if string(readByVidFidBody) != string(uploadPayload) {
t.Fatalf("GET /{vid}/{fid} body mismatch: got %q want %q", string(readByVidFidBody), string(uploadPayload))
}
readWithFilename := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, fmt.Sprintf("%s/%d/%s/%s", clusterHarness.VolumeAdminURL(), volumeID, fidOnly, "named.bin")))
readWithFilenameBody := framework.ReadAllAndClose(t, readWithFilename)
if readWithFilename.StatusCode != http.StatusOK {
t.Fatalf("GET /{vid}/{fid}/{filename} expected 200, got %d", readWithFilename.StatusCode)
}
if string(readWithFilenameBody) != string(uploadPayload) {
t.Fatalf("GET /{vid}/{fid}/{filename} body mismatch: got %q want %q", string(readWithFilenameBody), string(uploadPayload))
}
lastModified := readWithFilename.Header.Get("Last-Modified")
if lastModified == "" {
t.Fatalf("expected Last-Modified header on read response")
}
ifModifiedSinceReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fullFileID)
ifModifiedSinceReq.Header.Set("If-Modified-Since", lastModified)
ifModifiedSinceResp := framework.DoRequest(t, client, ifModifiedSinceReq)
_ = framework.ReadAllAndClose(t, ifModifiedSinceResp)
if ifModifiedSinceResp.StatusCode != http.StatusNotModified {
t.Fatalf("If-Modified-Since expected 304, got %d", ifModifiedSinceResp.StatusCode)
}
headIfModifiedSinceReq := mustNewRequest(t, http.MethodHead, clusterHarness.VolumeAdminURL()+"/"+fullFileID)
headIfModifiedSinceReq.Header.Set("If-Modified-Since", lastModified)
headIfModifiedSinceResp := framework.DoRequest(t, client, headIfModifiedSinceReq)
headIfModifiedSinceBody := framework.ReadAllAndClose(t, headIfModifiedSinceResp)
if headIfModifiedSinceResp.StatusCode != http.StatusNotModified {
t.Fatalf("HEAD If-Modified-Since expected 304, got %d", headIfModifiedSinceResp.StatusCode)
}
if len(headIfModifiedSinceBody) != 0 {
t.Fatalf("HEAD If-Modified-Since expected empty body, got %d bytes", len(headIfModifiedSinceBody))
}
}
func TestMalformedVidFidPathReturnsBadRequest(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
client := framework.NewHTTPClient()
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/not-a-vid/not-a-fid"))
_ = framework.ReadAllAndClose(t, resp)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("malformed /{vid}/{fid} expected 400, got %d", resp.StatusCode)
}
}
func TestReadWrongCookieReturnsNotFound(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(95)
const needleID = uint64(771235)
const cookie = uint32(0xBEEFCACF)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, needleID, cookie)
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("read-cookie-mismatch-content"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
wrongCookieFid := framework.NewFileID(volumeID, needleID, cookie+1)
getResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), wrongCookieFid)
_ = framework.ReadAllAndClose(t, getResp)
if getResp.StatusCode != http.StatusNotFound {
t.Fatalf("GET with wrong cookie expected 404, got %d", getResp.StatusCode)
}
headResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodHead, clusterHarness.VolumeAdminURL()+"/"+wrongCookieFid))
headBody := framework.ReadAllAndClose(t, headResp)
if headResp.StatusCode != http.StatusNotFound {
t.Fatalf("HEAD with wrong cookie expected 404, got %d", headResp.StatusCode)
}
if len(headBody) != 0 {
t.Fatalf("HEAD wrong-cookie response body should be empty, got %d bytes", len(headBody))
}
}
func TestConditionalHeaderPrecedenceAndInvalidIfModifiedSince(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(99)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 772002, 0x2B3C4D5E)
payload := []byte("conditional-precedence-content")
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, payload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
baselineResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
_ = framework.ReadAllAndClose(t, baselineResp)
if baselineResp.StatusCode != http.StatusOK {
t.Fatalf("baseline read expected 200, got %d", baselineResp.StatusCode)
}
lastModified := baselineResp.Header.Get("Last-Modified")
if lastModified == "" {
t.Fatalf("baseline read expected Last-Modified header")
}
precedenceReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
precedenceReq.Header.Set("If-Modified-Since", lastModified)
precedenceReq.Header.Set("If-None-Match", "\"definitely-different-etag\"")
precedenceResp := framework.DoRequest(t, client, precedenceReq)
precedenceBody := framework.ReadAllAndClose(t, precedenceResp)
if precedenceResp.StatusCode != http.StatusNotModified {
t.Fatalf("conditional precedence expected 304, got %d", precedenceResp.StatusCode)
}
if len(precedenceBody) != 0 {
t.Fatalf("conditional precedence expected empty body, got %d bytes", len(precedenceBody))
}
invalidIMSReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid)
invalidIMSReq.Header.Set("If-Modified-Since", "not-a-valid-http-date")
invalidIMSReq.Header.Set("If-None-Match", "\"definitely-different-etag\"")
invalidIMSResp := framework.DoRequest(t, client, invalidIMSReq)
invalidIMSBody := framework.ReadAllAndClose(t, invalidIMSResp)
if invalidIMSResp.StatusCode != http.StatusOK {
t.Fatalf("invalid If-Modified-Since with mismatched etag expected 200, got %d", invalidIMSResp.StatusCode)
}
if string(invalidIMSBody) != string(payload) {
t.Fatalf("invalid If-Modified-Since fallback body mismatch: got %q want %q", string(invalidIMSBody), string(payload))
}
}
@@ -0,0 +1,123 @@
package volume_server_http_test
import (
"net/http"
"strconv"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func TestUploadReadRangeHeadDeleteRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, cluster.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(7)
framework.AllocateVolume(t, grpcClient, volumeID, "")
fid := framework.NewFileID(volumeID, 123456, 0xA1B2C3D4)
data := []byte("hello-volume-server-integration")
client := framework.NewHTTPClient()
uploadResp := framework.UploadBytes(t, client, cluster.VolumeAdminURL(), fid, data)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload status: expected 201, got %d", uploadResp.StatusCode)
}
getResp := framework.ReadBytes(t, client, cluster.VolumeAdminURL(), fid)
getBody := framework.ReadAllAndClose(t, getResp)
if getResp.StatusCode != http.StatusOK {
t.Fatalf("get status: expected 200, got %d", getResp.StatusCode)
}
if string(getBody) != string(data) {
t.Fatalf("get body mismatch: got %q want %q", string(getBody), string(data))
}
etag := getResp.Header.Get("ETag")
if etag == "" {
t.Fatalf("expected ETag header from GET response")
}
notModifiedReq := mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/"+fid)
notModifiedReq.Header.Set("If-None-Match", etag)
notModifiedResp := framework.DoRequest(t, client, notModifiedReq)
_ = framework.ReadAllAndClose(t, notModifiedResp)
if notModifiedResp.StatusCode != http.StatusNotModified {
t.Fatalf("if-none-match expected 304, got %d", notModifiedResp.StatusCode)
}
rangeReq := mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/"+fid)
rangeReq.Header.Set("Range", "bytes=0-4")
rangeResp := framework.DoRequest(t, client, rangeReq)
rangeBody := framework.ReadAllAndClose(t, rangeResp)
if rangeResp.StatusCode != http.StatusPartialContent {
t.Fatalf("range status: expected 206, got %d", rangeResp.StatusCode)
}
if got, want := string(rangeBody), "hello"; got != want {
t.Fatalf("range body mismatch: got %q want %q", got, want)
}
invalidRangeReq := mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/"+fid)
invalidRangeReq.Header.Set("Range", "bytes=9999-10000")
invalidRangeResp := framework.DoRequest(t, client, invalidRangeReq)
_ = framework.ReadAllAndClose(t, invalidRangeResp)
if invalidRangeResp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
t.Fatalf("invalid range expected 416, got %d", invalidRangeResp.StatusCode)
}
headResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodHead, cluster.VolumeAdminURL()+"/"+fid))
headBody := framework.ReadAllAndClose(t, headResp)
if headResp.StatusCode != http.StatusOK {
t.Fatalf("head status: expected 200, got %d", headResp.StatusCode)
}
if got := headResp.Header.Get("Content-Length"); got != strconv.Itoa(len(data)) {
t.Fatalf("head content-length mismatch: got %q want %d", got, len(data))
}
if len(headBody) != 0 {
t.Fatalf("head body should be empty, got %d bytes", len(headBody))
}
headNotModifiedReq := mustNewRequest(t, http.MethodHead, cluster.VolumeAdminURL()+"/"+fid)
headNotModifiedReq.Header.Set("If-None-Match", etag)
headNotModifiedResp := framework.DoRequest(t, client, headNotModifiedReq)
headNotModifiedBody := framework.ReadAllAndClose(t, headNotModifiedResp)
if headNotModifiedResp.StatusCode != http.StatusNotModified {
t.Fatalf("head if-none-match expected 304, got %d", headNotModifiedResp.StatusCode)
}
if len(headNotModifiedBody) != 0 {
t.Fatalf("head if-none-match body should be empty, got %d bytes", len(headNotModifiedBody))
}
deleteResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, cluster.VolumeAdminURL()+"/"+fid))
_ = framework.ReadAllAndClose(t, deleteResp)
if deleteResp.StatusCode != http.StatusAccepted {
t.Fatalf("delete status: expected 202, got %d", deleteResp.StatusCode)
}
notFoundResp := framework.ReadBytes(t, client, cluster.VolumeAdminURL(), fid)
_ = framework.ReadAllAndClose(t, notFoundResp)
if notFoundResp.StatusCode != http.StatusNotFound {
t.Fatalf("read after delete: expected 404, got %d", notFoundResp.StatusCode)
}
}
func TestInvalidReadPathReturnsBadRequest(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
cluster := framework.StartSingleVolumeCluster(t, matrix.P1())
client := framework.NewHTTPClient()
resp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, cluster.VolumeAdminURL()+"/invalid,needle"))
_ = framework.ReadAllAndClose(t, resp)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid read expected 400, got %d", resp.StatusCode)
}
}
+730
View File
@@ -0,0 +1,730 @@
package volume_server_http_test
import (
"bytes"
"context"
"io"
"net/http"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
)
type pausableReader struct {
remaining int64
pauseAfter int64
paused bool
unblock <-chan struct{}
}
func (r *pausableReader) Read(p []byte) (int, error) {
if r.remaining <= 0 {
return 0, io.EOF
}
if !r.paused && r.pauseAfter > 0 {
n := int64(len(p))
if n > r.pauseAfter {
n = r.pauseAfter
}
for i := int64(0); i < n; i++ {
p[i] = 'a'
}
r.remaining -= n
r.pauseAfter -= n
if r.pauseAfter == 0 {
r.paused = true
}
return int(n), nil
}
if r.paused {
<-r.unblock
r.paused = false
}
n := int64(len(p))
if n > r.remaining {
n = r.remaining
}
for i := int64(0); i < n; i++ {
p[i] = 'b'
}
r.remaining -= n
return int(n), nil
}
func TestUploadLimitTimeoutAndReplicateBypass(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(98)
framework.AllocateVolume(t, grpcClient, volumeID, "")
const blockedUploadSize = 2 * 1024 * 1024 // over 1MB P8 upload limit
unblockFirstUpload := make(chan struct{})
firstUploadDone := make(chan error, 1)
firstFID := framework.NewFileID(volumeID, 880001, 0x1A2B3C4D)
go func() {
req, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+firstFID, &pausableReader{
remaining: blockedUploadSize,
pauseAfter: 1,
unblock: unblockFirstUpload,
})
if err != nil {
firstUploadDone <- err
return
}
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = blockedUploadSize
resp, err := (&http.Client{}).Do(req)
if resp != nil {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
firstUploadDone <- err
}()
// Give the first upload time to pass limit checks and block in body processing.
time.Sleep(300 * time.Millisecond)
replicateFID := framework.NewFileID(volumeID, 880002, 0x5E6F7A8B)
replicateReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+replicateFID+"?type=replicate", bytes.NewReader([]byte("replicate")))
if err != nil {
t.Fatalf("create replicate request: %v", err)
}
replicateReq.Header.Set("Content-Type", "application/octet-stream")
replicateReq.ContentLength = int64(len("replicate"))
replicateResp, err := framework.NewHTTPClient().Do(replicateReq)
if err != nil {
t.Fatalf("replicate request failed: %v", err)
}
_ = framework.ReadAllAndClose(t, replicateResp)
if replicateResp.StatusCode != http.StatusCreated {
t.Fatalf("replicate request expected 201 bypassing limit, got %d", replicateResp.StatusCode)
}
normalFID := framework.NewFileID(volumeID, 880003, 0x9C0D1E2F)
normalReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+normalFID, bytes.NewReader([]byte("normal")))
if err != nil {
t.Fatalf("create normal request: %v", err)
}
normalReq.Header.Set("Content-Type", "application/octet-stream")
normalReq.ContentLength = int64(len("normal"))
timeoutClient := &http.Client{Timeout: 10 * time.Second}
normalResp, err := timeoutClient.Do(normalReq)
if err != nil {
t.Fatalf("normal upload request failed: %v", err)
}
_ = framework.ReadAllAndClose(t, normalResp)
if normalResp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("normal upload expected 429 while limit blocked, got %d", normalResp.StatusCode)
}
close(unblockFirstUpload)
select {
case <-firstUploadDone:
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for blocked upload to finish")
}
}
func TestUploadLimitWaitThenProceed(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(111)
framework.AllocateVolume(t, grpcClient, volumeID, "")
const blockedUploadSize = 2 * 1024 * 1024
unblockFirstUpload := make(chan struct{})
firstUploadDone := make(chan error, 1)
firstFID := framework.NewFileID(volumeID, 880601, 0x6A2B3C4D)
go func() {
req, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+firstFID, &pausableReader{
remaining: blockedUploadSize,
pauseAfter: 1,
unblock: unblockFirstUpload,
})
if err != nil {
firstUploadDone <- err
return
}
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = blockedUploadSize
resp, err := (&http.Client{}).Do(req)
if resp != nil {
_ = framework.ReadAllAndClose(t, resp)
}
firstUploadDone <- err
}()
time.Sleep(300 * time.Millisecond)
type uploadResult struct {
resp *http.Response
err error
}
secondUploadDone := make(chan uploadResult, 1)
secondFID := framework.NewFileID(volumeID, 880602, 0x6A2B3C4E)
go func() {
req, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+secondFID, bytes.NewReader([]byte("wait-then-proceed")))
if err != nil {
secondUploadDone <- uploadResult{err: err}
return
}
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = int64(len("wait-then-proceed"))
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
secondUploadDone <- uploadResult{resp: resp, err: err}
}()
time.Sleep(500 * time.Millisecond)
close(unblockFirstUpload)
select {
case firstErr := <-firstUploadDone:
if firstErr != nil {
t.Fatalf("first blocked upload failed: %v", firstErr)
}
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for first upload completion")
}
select {
case result := <-secondUploadDone:
if result.err != nil {
t.Fatalf("second upload failed: %v", result.err)
}
_ = framework.ReadAllAndClose(t, result.resp)
if result.resp.StatusCode != http.StatusCreated {
t.Fatalf("second upload expected 201 after waiting for slot, got %d", result.resp.StatusCode)
}
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for second upload completion")
}
}
func TestUploadLimitTimeoutThenRecovery(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(113)
framework.AllocateVolume(t, grpcClient, volumeID, "")
const blockedUploadSize = 2 * 1024 * 1024
unblockFirstUpload := make(chan struct{})
firstUploadDone := make(chan error, 1)
firstFID := framework.NewFileID(volumeID, 880801, 0x7A2B3C4D)
go func() {
req, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+firstFID, &pausableReader{
remaining: blockedUploadSize,
pauseAfter: 1,
unblock: unblockFirstUpload,
})
if err != nil {
firstUploadDone <- err
return
}
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = blockedUploadSize
resp, err := (&http.Client{}).Do(req)
if resp != nil {
_ = framework.ReadAllAndClose(t, resp)
}
firstUploadDone <- err
}()
time.Sleep(300 * time.Millisecond)
timeoutFID := framework.NewFileID(volumeID, 880802, 0x7A2B3C4E)
timeoutResp := framework.UploadBytes(t, &http.Client{Timeout: 10 * time.Second}, clusterHarness.VolumeAdminURL(), timeoutFID, []byte("should-timeout"))
_ = framework.ReadAllAndClose(t, timeoutResp)
if timeoutResp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("second upload under blocked pressure expected 429, got %d", timeoutResp.StatusCode)
}
close(unblockFirstUpload)
select {
case firstErr := <-firstUploadDone:
if firstErr != nil {
t.Fatalf("first blocked upload failed: %v", firstErr)
}
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for first upload completion")
}
recoveryFID := framework.NewFileID(volumeID, 880803, 0x7A2B3C4F)
recoveryResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), recoveryFID, []byte("recovered-upload"))
_ = framework.ReadAllAndClose(t, recoveryResp)
if recoveryResp.StatusCode != http.StatusCreated {
t.Fatalf("recovery upload expected 201, got %d", recoveryResp.StatusCode)
}
}
func TestDownloadLimitTimeoutReturnsTooManyRequests(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(99)
framework.AllocateVolume(t, grpcClient, volumeID, "")
largePayload := make([]byte, 12*1024*1024) // over 1MB P8 download limit
for i := range largePayload {
largePayload[i] = byte(i % 251)
}
downloadFID := framework.NewFileID(volumeID, 880101, 0x10203040)
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), downloadFID, largePayload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
}
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+downloadFID))
if err != nil {
t.Fatalf("first GET failed: %v", err)
}
if firstResp.StatusCode != http.StatusOK {
_ = framework.ReadAllAndClose(t, firstResp)
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
}
defer firstResp.Body.Close()
// Keep first response body unread so server write path stays in-flight.
time.Sleep(300 * time.Millisecond)
secondClient := &http.Client{Timeout: 10 * time.Second}
secondResp, err := secondClient.Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+downloadFID))
if err != nil {
t.Fatalf("second GET failed: %v", err)
}
_ = framework.ReadAllAndClose(t, secondResp)
if secondResp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("second GET expected 429 while first download holds limit, got %d", secondResp.StatusCode)
}
}
func TestDownloadLimitWaitThenProceedWithoutReplica(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(112)
framework.AllocateVolume(t, grpcClient, volumeID, "")
largePayload := make([]byte, 12*1024*1024)
for i := range largePayload {
largePayload[i] = byte(i % 251)
}
fid := framework.NewFileID(volumeID, 880701, 0x60708090)
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid, largePayload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
}
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid))
if err != nil {
t.Fatalf("first GET failed: %v", err)
}
if firstResp.StatusCode != http.StatusOK {
_ = framework.ReadAllAndClose(t, firstResp)
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
}
type readResult struct {
resp *http.Response
err error
}
secondReadDone := make(chan readResult, 1)
go func() {
resp, readErr := (&http.Client{Timeout: 10 * time.Second}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid))
secondReadDone <- readResult{resp: resp, err: readErr}
}()
time.Sleep(500 * time.Millisecond)
_ = firstResp.Body.Close()
select {
case result := <-secondReadDone:
if result.err != nil {
t.Fatalf("second GET failed: %v", result.err)
}
secondBody := framework.ReadAllAndClose(t, result.resp)
if result.resp.StatusCode != http.StatusOK {
t.Fatalf("second GET expected 200 after waiting for slot, got %d", result.resp.StatusCode)
}
if len(secondBody) != len(largePayload) {
t.Fatalf("second GET body size mismatch: got %d want %d", len(secondBody), len(largePayload))
}
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for second GET completion")
}
}
func TestDownloadLimitTimeoutThenRecovery(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(114)
framework.AllocateVolume(t, grpcClient, volumeID, "")
largePayload := make([]byte, 12*1024*1024)
for i := range largePayload {
largePayload[i] = byte(i % 251)
}
fid := framework.NewFileID(volumeID, 880901, 0x708090A0)
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid, largePayload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
}
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid))
if err != nil {
t.Fatalf("first GET failed: %v", err)
}
if firstResp.StatusCode != http.StatusOK {
_ = framework.ReadAllAndClose(t, firstResp)
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
}
time.Sleep(300 * time.Millisecond)
timeoutResp := framework.ReadBytes(t, &http.Client{Timeout: 10 * time.Second}, clusterHarness.VolumeAdminURL(), fid)
_ = framework.ReadAllAndClose(t, timeoutResp)
if timeoutResp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("second GET under blocked pressure expected 429, got %d", timeoutResp.StatusCode)
}
_ = firstResp.Body.Close()
recoveryResp := framework.ReadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid)
recoveryBody := framework.ReadAllAndClose(t, recoveryResp)
if recoveryResp.StatusCode != http.StatusOK {
t.Fatalf("recovery GET expected 200, got %d", recoveryResp.StatusCode)
}
if len(recoveryBody) != len(largePayload) {
t.Fatalf("recovery GET body size mismatch: got %d want %d", len(recoveryBody), len(largePayload))
}
}
func TestDownloadLimitOverageProxiesToReplica(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P8()
profile.ReadMode = "proxy"
clusterHarness := framework.StartDualVolumeCluster(t, profile)
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer conn0.Close()
conn1, grpc1 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
defer conn1.Close()
const volumeID = uint32(100)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req := &volume_server_pb.AllocateVolumeRequest{
VolumeId: volumeID,
Replication: "001",
Version: uint32(needle.GetCurrentVersion()),
}
if _, err := grpc0.AllocateVolume(ctx, req); err != nil {
t.Fatalf("allocate replicated volume on node0: %v", err)
}
if _, err := grpc1.AllocateVolume(ctx, req); err != nil {
t.Fatalf("allocate replicated volume on node1: %v", err)
}
largePayload := make([]byte, 12*1024*1024)
for i := range largePayload {
largePayload[i] = byte(i % 251)
}
fid := framework.NewFileID(volumeID, 880201, 0x0A0B0C0D)
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(0), fid, largePayload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("replicated large upload expected 201, got %d", uploadResp.StatusCode)
}
replicaReadURL := clusterHarness.VolumeAdminURL(1) + "/" + fid
if !waitForHTTPStatus(t, framework.NewHTTPClient(), replicaReadURL, http.StatusOK, 10*time.Second, func(resp *http.Response) {
_ = framework.ReadAllAndClose(t, resp)
}) {
t.Fatalf("replica did not become readable within deadline")
}
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL(0)+"/"+fid))
if err != nil {
t.Fatalf("first GET failed: %v", err)
}
if firstResp.StatusCode != http.StatusOK {
_ = framework.ReadAllAndClose(t, firstResp)
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
}
defer firstResp.Body.Close()
time.Sleep(300 * time.Millisecond)
secondResp, err := framework.NewHTTPClient().Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL(0)+"/"+fid))
if err != nil {
t.Fatalf("second GET failed: %v", err)
}
secondBody := framework.ReadAllAndClose(t, secondResp)
if secondResp.StatusCode != http.StatusOK {
t.Fatalf("second GET expected 200 via replica proxy fallback, got %d", secondResp.StatusCode)
}
if len(secondBody) != len(largePayload) {
t.Fatalf("second GET proxied body size mismatch: got %d want %d", len(secondBody), len(largePayload))
}
}
func TestDownloadLimitProxiedRequestSkipsReplicaFallbackAndTimesOut(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
profile := matrix.P8()
profile.ReadMode = "proxy"
clusterHarness := framework.StartDualVolumeCluster(t, profile)
conn0, grpc0 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(0))
defer conn0.Close()
conn1, grpc1 := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress(1))
defer conn1.Close()
const volumeID = uint32(106)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req := &volume_server_pb.AllocateVolumeRequest{
VolumeId: volumeID,
Replication: "001",
Version: uint32(needle.GetCurrentVersion()),
}
if _, err := grpc0.AllocateVolume(ctx, req); err != nil {
t.Fatalf("allocate replicated volume on node0: %v", err)
}
if _, err := grpc1.AllocateVolume(ctx, req); err != nil {
t.Fatalf("allocate replicated volume on node1: %v", err)
}
largePayload := make([]byte, 12*1024*1024)
for i := range largePayload {
largePayload[i] = byte(i % 251)
}
fid := framework.NewFileID(volumeID, 880202, 0x0A0B0D0E)
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(0), fid, largePayload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("replicated large upload expected 201, got %d", uploadResp.StatusCode)
}
// Ensure replica path is actually available, so a non-proxied request would proxy.
replicaReadURL := clusterHarness.VolumeAdminURL(1) + "/" + fid
if !waitForHTTPStatus(t, framework.NewHTTPClient(), replicaReadURL, http.StatusOK, 10*time.Second, func(resp *http.Response) {
_ = framework.ReadAllAndClose(t, resp)
}) {
t.Fatalf("replica did not become readable within deadline")
}
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL(0)+"/"+fid))
if err != nil {
t.Fatalf("first GET failed: %v", err)
}
if firstResp.StatusCode != http.StatusOK {
_ = framework.ReadAllAndClose(t, firstResp)
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
}
defer firstResp.Body.Close()
time.Sleep(300 * time.Millisecond)
// proxied=true should bypass replica fallback and hit wait/timeout branch.
secondResp, err := framework.NewHTTPClient().Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL(0)+"/"+fid+"?proxied=true"))
if err != nil {
t.Fatalf("second GET failed: %v", err)
}
_ = framework.ReadAllAndClose(t, secondResp)
if secondResp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("second GET with proxied=true expected 429 timeout path, got %d", secondResp.StatusCode)
}
}
func TestUploadLimitDisabledAllowsConcurrentUploads(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(107)
framework.AllocateVolume(t, grpcClient, volumeID, "")
const blockedUploadSize = 2 * 1024 * 1024
unblockFirstUpload := make(chan struct{})
firstUploadDone := make(chan error, 1)
firstFID := framework.NewFileID(volumeID, 880301, 0x1A2B3C5D)
go func() {
req, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+firstFID, &pausableReader{
remaining: blockedUploadSize,
pauseAfter: 1,
unblock: unblockFirstUpload,
})
if err != nil {
firstUploadDone <- err
return
}
req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = blockedUploadSize
resp, err := (&http.Client{}).Do(req)
if resp != nil {
_ = framework.ReadAllAndClose(t, resp)
}
firstUploadDone <- err
}()
time.Sleep(300 * time.Millisecond)
secondFID := framework.NewFileID(volumeID, 880302, 0x1A2B3C5E)
secondResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), secondFID, []byte("no-limit-second-upload"))
_ = framework.ReadAllAndClose(t, secondResp)
if secondResp.StatusCode != http.StatusCreated {
t.Fatalf("second upload with disabled limit expected 201, got %d", secondResp.StatusCode)
}
close(unblockFirstUpload)
select {
case <-firstUploadDone:
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for first upload completion")
}
}
func TestDownloadLimitDisabledAllowsConcurrentDownloads(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(108)
framework.AllocateVolume(t, grpcClient, volumeID, "")
largePayload := make([]byte, 12*1024*1024)
for i := range largePayload {
largePayload[i] = byte(i % 251)
}
fid := framework.NewFileID(volumeID, 880401, 0x20304050)
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid, largePayload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
}
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid))
if err != nil {
t.Fatalf("first GET failed: %v", err)
}
if firstResp.StatusCode != http.StatusOK {
_ = framework.ReadAllAndClose(t, firstResp)
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
}
defer firstResp.Body.Close()
time.Sleep(300 * time.Millisecond)
secondResp := framework.ReadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid)
secondBody := framework.ReadAllAndClose(t, secondResp)
if secondResp.StatusCode != http.StatusOK {
t.Fatalf("second GET with disabled limit expected 200, got %d", secondResp.StatusCode)
}
if len(secondBody) != len(largePayload) {
t.Fatalf("second GET body size mismatch: got %d want %d", len(secondBody), len(largePayload))
}
}
func TestDownloadLimitInvalidVidWhileOverLimitReturnsBadRequest(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P8())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(110)
framework.AllocateVolume(t, grpcClient, volumeID, "")
largePayload := make([]byte, 12*1024*1024)
for i := range largePayload {
largePayload[i] = byte(i % 251)
}
fid := framework.NewFileID(volumeID, 880501, 0x50607080)
uploadResp := framework.UploadBytes(t, framework.NewHTTPClient(), clusterHarness.VolumeAdminURL(), fid, largePayload)
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("large upload expected 201, got %d", uploadResp.StatusCode)
}
firstResp, err := (&http.Client{}).Do(mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid))
if err != nil {
t.Fatalf("first GET failed: %v", err)
}
if firstResp.StatusCode != http.StatusOK {
_ = framework.ReadAllAndClose(t, firstResp)
t.Fatalf("first GET expected 200, got %d", firstResp.StatusCode)
}
defer firstResp.Body.Close()
time.Sleep(300 * time.Millisecond)
invalidReq := mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/not-a-vid,1234567890ab")
invalidResp := framework.DoRequest(t, framework.NewHTTPClient(), invalidReq)
_ = framework.ReadAllAndClose(t, invalidResp)
if invalidResp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid vid while over limit expected 400, got %d", invalidResp.StatusCode)
}
}
@@ -0,0 +1,118 @@
package volume_server_http_test
import (
"encoding/json"
"net/http"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func TestWriteUnchangedAndDeleteEdgeVariants(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(87)
framework.AllocateVolume(t, grpcClient, volumeID, "")
const key = uint64(999001)
const cookie = uint32(0xDEADBEEF)
fid := framework.NewFileID(volumeID, key, cookie)
client := framework.NewHTTPClient()
payload := []byte("unchanged-write-content")
firstUpload := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
firstUploadResp := framework.DoRequest(t, client, firstUpload)
_ = framework.ReadAllAndClose(t, firstUploadResp)
if firstUploadResp.StatusCode != http.StatusCreated {
t.Fatalf("first upload expected 201, got %d", firstUploadResp.StatusCode)
}
secondUpload := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, payload)
secondUploadResp := framework.DoRequest(t, client, secondUpload)
_ = framework.ReadAllAndClose(t, secondUploadResp)
if secondUploadResp.StatusCode != http.StatusNoContent {
t.Fatalf("second unchanged upload expected 204, got %d", secondUploadResp.StatusCode)
}
if secondUploadResp.Header.Get("ETag") == "" {
t.Fatalf("second unchanged upload expected ETag header")
}
wrongCookieFid := framework.NewFileID(volumeID, key, cookie+1)
wrongCookieDelete := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+wrongCookieFid))
_ = framework.ReadAllAndClose(t, wrongCookieDelete)
if wrongCookieDelete.StatusCode != http.StatusBadRequest {
t.Fatalf("delete with mismatched cookie expected 400, got %d", wrongCookieDelete.StatusCode)
}
missingDelete := framework.DoRequest(t, client, mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+framework.NewFileID(volumeID, key+1, cookie)))
missingDeleteBody := framework.ReadAllAndClose(t, missingDelete)
if missingDelete.StatusCode != http.StatusNotFound {
t.Fatalf("delete missing needle expected 404, got %d", missingDelete.StatusCode)
}
var payloadMap map[string]int64
if err := json.Unmarshal(missingDeleteBody, &payloadMap); err != nil {
t.Fatalf("decode delete missing response: %v", err)
}
if payloadMap["size"] != 0 {
t.Fatalf("delete missing needle expected size=0, got %d", payloadMap["size"])
}
}
func TestDeleteTimestampOverrideKeepsReadDeletedLastModifiedParity(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(88)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 999002, 0xABCD1234)
uploadResp := framework.UploadBytes(t, client, clusterHarness.VolumeAdminURL(), fid, []byte("delete-ts-override"))
_ = framework.ReadAllAndClose(t, uploadResp)
if uploadResp.StatusCode != http.StatusCreated {
t.Fatalf("upload expected 201, got %d", uploadResp.StatusCode)
}
beforeDeleteResp := framework.ReadBytes(t, client, clusterHarness.VolumeAdminURL(), fid)
_ = framework.ReadAllAndClose(t, beforeDeleteResp)
if beforeDeleteResp.StatusCode != http.StatusOK {
t.Fatalf("pre-delete read expected 200, got %d", beforeDeleteResp.StatusCode)
}
lastModifiedBeforeDelete := beforeDeleteResp.Header.Get("Last-Modified")
if lastModifiedBeforeDelete == "" {
t.Fatalf("expected Last-Modified before delete")
}
deleteReq := mustNewRequest(t, http.MethodDelete, clusterHarness.VolumeAdminURL()+"/"+fid+"?ts=1700000000")
deleteResp := framework.DoRequest(t, client, deleteReq)
_ = framework.ReadAllAndClose(t, deleteResp)
if deleteResp.StatusCode != http.StatusAccepted {
t.Fatalf("delete with ts override expected 202, got %d", deleteResp.StatusCode)
}
readDeletedResp := framework.DoRequest(t, client, mustNewRequest(t, http.MethodGet, clusterHarness.VolumeAdminURL()+"/"+fid+"?readDeleted=true"))
_ = framework.ReadAllAndClose(t, readDeletedResp)
if readDeletedResp.StatusCode != http.StatusOK {
t.Fatalf("readDeleted after ts override expected 200, got %d", readDeletedResp.StatusCode)
}
lastModified := readDeletedResp.Header.Get("Last-Modified")
if lastModified == "" {
t.Fatalf("expected Last-Modified header on readDeleted response")
}
if lastModified != lastModifiedBeforeDelete {
t.Fatalf("expected readDeleted Last-Modified parity with pre-delete header, got %q want %q", lastModified, lastModifiedBeforeDelete)
}
}
@@ -0,0 +1,74 @@
package volume_server_http_test
import (
"net/http"
"strings"
"testing"
"github.com/seaweedfs/seaweedfs/test/volume_server/framework"
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
)
func TestWriteInvalidVidAndFidReturnBadRequest(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
client := framework.NewHTTPClient()
invalidVidReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/invalid,12345678", []byte("x"))
invalidVidResp := framework.DoRequest(t, client, invalidVidReq)
_ = framework.ReadAllAndClose(t, invalidVidResp)
if invalidVidResp.StatusCode != http.StatusBadRequest {
t.Fatalf("write with invalid vid expected 400, got %d", invalidVidResp.StatusCode)
}
invalidFidReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/1,bad", []byte("x"))
invalidFidResp := framework.DoRequest(t, client, invalidFidReq)
_ = framework.ReadAllAndClose(t, invalidFidResp)
if invalidFidResp.StatusCode != http.StatusBadRequest {
t.Fatalf("write with invalid fid expected 400, got %d", invalidFidResp.StatusCode)
}
}
func TestWriteMalformedMultipartAndMD5Mismatch(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
clusterHarness := framework.StartSingleVolumeCluster(t, matrix.P1())
conn, grpcClient := framework.DialVolumeServer(t, clusterHarness.VolumeGRPCAddress())
defer conn.Close()
const volumeID = uint32(98)
framework.AllocateVolume(t, grpcClient, volumeID, "")
client := framework.NewHTTPClient()
fid := framework.NewFileID(volumeID, 772001, 0x1A2B3C4D)
malformedMultipartReq, err := http.NewRequest(http.MethodPost, clusterHarness.VolumeAdminURL()+"/"+fid, strings.NewReader("not-a-valid-multipart-body"))
if err != nil {
t.Fatalf("create malformed multipart request: %v", err)
}
malformedMultipartReq.Header.Set("Content-Type", "multipart/form-data")
malformedMultipartResp := framework.DoRequest(t, client, malformedMultipartReq)
malformedMultipartBody := framework.ReadAllAndClose(t, malformedMultipartResp)
if malformedMultipartResp.StatusCode != http.StatusBadRequest {
t.Fatalf("malformed multipart write expected 400, got %d", malformedMultipartResp.StatusCode)
}
if !strings.Contains(strings.ToLower(string(malformedMultipartBody)), "boundary") {
t.Fatalf("malformed multipart response should mention boundary parse failure, got %q", string(malformedMultipartBody))
}
md5MismatchReq := newUploadRequest(t, clusterHarness.VolumeAdminURL()+"/"+fid, []byte("content-md5-mismatch-body"))
md5MismatchReq.Header.Set("Content-MD5", "AAAAAAAAAAAAAAAAAAAAAA==")
md5MismatchResp := framework.DoRequest(t, client, md5MismatchReq)
md5MismatchBody := framework.ReadAllAndClose(t, md5MismatchResp)
if md5MismatchResp.StatusCode != http.StatusBadRequest {
t.Fatalf("content-md5 mismatch write expected 400, got %d", md5MismatchResp.StatusCode)
}
if !strings.Contains(string(md5MismatchBody), "Content-MD5") {
t.Fatalf("content-md5 mismatch response should mention Content-MD5, got %q", string(md5MismatchBody))
}
}
@@ -0,0 +1,63 @@
package matrix
import "time"
// Profile describes one runtime test matrix configuration.
type Profile struct {
Name string
ReadMode string
SplitPublicPort bool
EnableJWT bool
JWTSigningKey string
JWTReadKey string
EnableMaintain bool
ConcurrentUploadLimitMB int
ConcurrentDownloadLimitMB int
InflightUploadTimeout time.Duration
InflightDownloadTimeout time.Duration
ReplicatedLayout bool
HasErasureCoding bool
HasRemoteTier bool
}
// P1 is the baseline profile: one volume server, no JWT, proxy read mode.
func P1() Profile {
return Profile{
Name: "P1",
ReadMode: "proxy",
SplitPublicPort: false,
}
}
// P2 uses split public/admin ports to verify public read-only behavior.
func P2() Profile {
p := P1()
p.Name = "P2"
p.SplitPublicPort = true
return p
}
// P3 enables JWT verification for read/write flows.
func P3() Profile {
p := P1()
p.Name = "P3"
p.EnableJWT = true
p.JWTSigningKey = "volume-server-write-key"
p.JWTReadKey = "volume-server-read-key"
return p
}
// P8 enables upload/download throttling branches.
func P8() Profile {
p := P1()
p.Name = "P8"
p.ConcurrentUploadLimitMB = 1
p.ConcurrentDownloadLimitMB = 1
p.InflightUploadTimeout = 2 * time.Second
p.InflightDownloadTimeout = 2 * time.Second
return p
}
+3 -2
View File
@@ -9,13 +9,14 @@ import (
"github.com/gin-gonic/gin"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
// Access key status constants
const (
AccessKeyStatusActive = "Active"
AccessKeyStatusInactive = "Inactive"
AccessKeyStatusActive = iam.AccessKeyStatusActive
AccessKeyStatusInactive = iam.AccessKeyStatusInactive
)
type AdminData struct {
+58 -9
View File
@@ -63,6 +63,19 @@ type tableBucketMetadata struct {
const s3TablesAdminListLimit = 1000
func parseNamespaceInput(namespace string) ([]string, error) {
return s3tables.ParseNamespace(namespace)
}
func (s *AdminServer) parseNamespaceFromGin(c *gin.Context, namespace string) ([]string, bool) {
parts, err := parseNamespaceInput(namespace)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid namespace: " + err.Error()})
return nil, false
}
return parts, true
}
func newS3TablesManager() *s3tables.Manager {
manager := s3tables.NewManager()
manager.SetAccountID(s3_constants.AccountAdminId)
@@ -158,7 +171,11 @@ func (s *AdminServer) GetS3TablesTablesData(ctx context.Context, bucketArn, name
var resp s3tables.ListTablesResponse
var ns []string
if namespace != "" {
ns = []string{namespace}
parts, err := parseNamespaceInput(namespace)
if err != nil {
return S3TablesTablesData{}, err
}
ns = parts
}
req := &s3tables.ListTablesRequest{TableBucketARN: bucketArn, Namespace: ns, MaxTables: s3TablesAdminListLimit}
if err := s.executeS3TablesOperation(ctx, "ListTables", req, &resp); err != nil {
@@ -257,9 +274,13 @@ func (s *AdminServer) GetIcebergTablesData(ctx context.Context, catalogName, buc
// GetIcebergTableDetailsData returns Iceberg table metadata and snapshot information.
func (s *AdminServer) GetIcebergTableDetailsData(ctx context.Context, catalogName, bucketArn, namespace, tableName string) (IcebergTableDetailsData, error) {
var resp s3tables.GetTableResponse
namespaceParts, err := parseNamespaceInput(namespace)
if err != nil {
return IcebergTableDetailsData{}, err
}
req := &s3tables.GetTableRequest{
TableBucketARN: bucketArn,
Namespace: []string{namespace},
Namespace: namespaceParts,
Name: tableName,
}
if err := s.executeS3TablesOperation(ctx, "GetTable", req, &resp); err != nil {
@@ -686,7 +707,11 @@ func (s *AdminServer) CreateS3TablesNamespace(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket_arn and name are required"})
return
}
createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Name}}
namespaceParts, ok := s.parseNamespaceFromGin(c, req.Name)
if !ok {
return
}
createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts}
var resp s3tables.CreateNamespaceResponse
if err := s.executeS3TablesOperation(c.Request.Context(), "CreateNamespace", createReq, &resp); err != nil {
writeS3TablesError(c, err)
@@ -705,7 +730,11 @@ func (s *AdminServer) DeleteS3TablesNamespace(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket and name query parameters are required"})
return
}
req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
if !ok {
return
}
req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: namespaceParts}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteNamespace", req, nil); err != nil {
writeS3TablesError(c, err)
return
@@ -745,6 +774,10 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket_arn, namespace, and name are required"})
return
}
namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace)
if !ok {
return
}
format := req.Format
if format == "" {
format = "ICEBERG"
@@ -757,7 +790,7 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
}
createReq := &s3tables.CreateTableRequest{
TableBucketARN: req.BucketARN,
Namespace: []string{req.Namespace},
Namespace: namespaceParts,
Name: req.Name,
Format: format,
Tags: req.Tags,
@@ -780,7 +813,11 @@ func (s *AdminServer) DeleteS3TablesTable(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
return
}
req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name, VersionToken: version}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
if !ok {
return
}
req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name, VersionToken: version}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTable", req, nil); err != nil {
writeS3TablesError(c, err)
return
@@ -853,7 +890,11 @@ func (s *AdminServer) PutS3TablesTablePolicy(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket_arn, namespace, name, and policy are required"})
return
}
putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Namespace}, Name: req.Name, ResourcePolicy: req.Policy}
namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace)
if !ok {
return
}
putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts, Name: req.Name, ResourcePolicy: req.Policy}
if err := s.executeS3TablesOperation(c.Request.Context(), "PutTablePolicy", putReq, nil); err != nil {
writeS3TablesError(c, err)
return
@@ -869,7 +910,11 @@ func (s *AdminServer) GetS3TablesTablePolicy(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
return
}
getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
if !ok {
return
}
getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
var resp s3tables.GetTablePolicyResponse
if err := s.executeS3TablesOperation(c.Request.Context(), "GetTablePolicy", getReq, &resp); err != nil {
writeS3TablesError(c, err)
@@ -886,7 +931,11 @@ func (s *AdminServer) DeleteS3TablesTablePolicy(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
return
}
deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
if !ok {
return
}
deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil {
writeS3TablesError(c, err)
return
@@ -8,6 +8,7 @@ import (
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
@@ -21,8 +22,8 @@ const (
accessKeyPrefix = "ABIA" // Service account access keys use ABIA prefix
// Status constants
StatusActive = "Active"
StatusInactive = "Inactive"
StatusActive = iam.AccessKeyStatusActive
StatusInactive = iam.AccessKeyStatusInactive
)
// GetServiceAccounts returns all service accounts, optionally filtered by parent user
+20 -13
View File
@@ -725,20 +725,27 @@ function s3TablesNamespaceNameError(name) {
if (name.includes('/')) {
return "namespace name cannot contain '/'";
}
if (!isLowercaseLetterOrDigit(name[0])) {
return 'Namespace name must start with a letter or digit';
}
if (!isLowercaseLetterOrDigit(name[name.length - 1])) {
return 'Namespace name must end with a letter or digit';
}
for (const ch of name) {
if (isLowercaseLetterOrDigit(ch) || ch === '_') {
continue;
const parts = name.split('.');
for (const part of parts) {
if (!part) {
return 'namespace levels cannot be empty';
}
if (!isLowercaseLetterOrDigit(part[0])) {
return 'Namespace name must start with a letter or digit';
}
if (!isLowercaseLetterOrDigit(part[part.length - 1])) {
return 'Namespace name must end with a letter or digit';
}
for (const ch of part) {
if (isLowercaseLetterOrDigit(ch) || ch === '_') {
continue;
}
return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
}
if (part.startsWith('aws')) {
return "namespace name cannot start with reserved prefix 'aws'";
}
return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
}
if (name.startsWith('aws')) {
return "namespace name cannot start with reserved prefix 'aws'";
}
return '';
}
+8 -4
View File
@@ -7,8 +7,12 @@ import (
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
script changePageSize(path string, lastFileName string) {
window.location.href = '/files?path=' + encodeURIComponent(path) + '&lastFileName=' + encodeURIComponent(lastFileName) + '&limit=' + this.value
script changePageSize(path string, lastFileName string, pageSizeSelectId string) {
const pageSizeSelect = document.getElementById(pageSizeSelectId)
if (pageSizeSelect == null) {
return
}
window.location.href = '/files?path=' + encodeURIComponent(path) + '&lastFileName=' + encodeURIComponent(lastFileName) + '&limit=' + pageSizeSelect.value
}
templ FileBrowser(data dash.FileBrowserData) {
@@ -94,7 +98,7 @@ templ FileBrowser(data dash.FileBrowserData) {
<div class="d-flex align-items-center gap-2 ms-auto">
<div class="d-flex align-items-center">
<label class="me-1 mb-0 small text-muted">Show:</label>
<select class="form-select form-select-sm" style="width: 70px; font-size: 0.875rem;" onchange={ changePageSize(data.CurrentPath, data.CurrentLastFileName) }>
<select id="file-browser-page-size-top" class="form-select form-select-sm" style="width: 70px; font-size: 0.875rem;" onchange={ changePageSize(data.CurrentPath, data.CurrentLastFileName, "file-browser-page-size-top") }>
<option value="20" selected?={ data.PageSize == 20 }>20</option>
<option value="50" selected?={ data.PageSize == 50 }>50</option>
<option value="100" selected?={ data.PageSize == 100 }>100</option>
@@ -221,7 +225,7 @@ templ FileBrowser(data dash.FileBrowserData) {
<div class="col-md-6">
<div class="d-flex align-items-center">
<label class="me-2 mb-0">Show:</label>
<select class="form-select form-select-sm" style="width: auto;" onchange={ changePageSize(data.CurrentPath, data.CurrentLastFileName) }>
<select id="file-browser-page-size-bottom" class="form-select form-select-sm" style="width: auto;" onchange={ changePageSize(data.CurrentPath, data.CurrentLastFileName, "file-browser-page-size-bottom") }>
<option value="20" selected?={ data.PageSize == 20 }>20</option>
<option value="50" selected?={ data.PageSize == 50 }>50</option>
<option value="100" selected?={ data.PageSize == 100 }>100</option>
+43 -39
View File
@@ -15,13 +15,17 @@ import (
"strings"
)
func changePageSize(path string, lastFileName string) templ.ComponentScript {
func changePageSize(path string, lastFileName string, pageSizeSelectId string) templ.ComponentScript {
return templ.ComponentScript{
Name: `__templ_changePageSize_6f57`,
Function: `function __templ_changePageSize_6f57(path, lastFileName){window.location.href = '/files?path=' + encodeURIComponent(path) + '&lastFileName=' + encodeURIComponent(lastFileName) + '&limit=' + this.value
Name: `__templ_changePageSize_43f7`,
Function: `function __templ_changePageSize_43f7(path, lastFileName, pageSizeSelectId){const pageSizeSelect = document.getElementById(pageSizeSelectId)
if (pageSizeSelect == null) {
return
}
window.location.href = '/files?path=' + encodeURIComponent(path) + '&lastFileName=' + encodeURIComponent(lastFileName) + '&limit=' + pageSizeSelect.value
}`,
Call: templ.SafeScript(`__templ_changePageSize_6f57`, path, lastFileName),
CallInline: templ.SafeScriptInline(`__templ_changePageSize_6f57`, path, lastFileName),
Call: templ.SafeScript(`__templ_changePageSize_43f7`, path, lastFileName, pageSizeSelectId),
CallInline: templ.SafeScriptInline(`__templ_changePageSize_43f7`, path, lastFileName, pageSizeSelectId),
}
}
@@ -58,7 +62,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(data.TableBucketName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 18, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 22, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -72,7 +76,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.BucketName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 20, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 24, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -112,7 +116,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/files?path=%s", crumb.Path)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 58, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 62, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -125,7 +129,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 59, Col: 19}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 63, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -143,7 +147,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/files?path=%s", crumb.Path)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 64, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 68, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -162,7 +166,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 68, Col: 19}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 72, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -196,7 +200,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var8 templ.SafeURL
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/files?path=%s", data.CurrentPath)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 89, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 93, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -209,7 +213,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(filepath.Base(data.CurrentPath))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 89, Col: 154}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 93, Col: 154}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -224,15 +228,15 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, changePageSize(data.CurrentPath, data.CurrentLastFileName))
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, changePageSize(data.CurrentPath, data.CurrentLastFileName, "file-browser-page-size-top"))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<select class=\"form-select form-select-sm\" style=\"width: 70px; font-size: 0.875rem;\" onchange=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<select id=\"file-browser-page-size-top\" class=\"form-select form-select-sm\" style=\"width: 70px; font-size: 0.875rem;\" onchange=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 templ.ComponentScript = changePageSize(data.CurrentPath, data.CurrentLastFileName)
var templ_7745c5c3_Var10 templ.ComponentScript = changePageSize(data.CurrentPath, data.CurrentLastFileName, "file-browser-page-size-top")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -289,7 +293,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var11 templ.SafeURL
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/files?path=%s&lastFileName=%s&limit=%d", data.CurrentPath, data.LastFileName, data.PageSize)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 106, Col: 137}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 110, Col: 137}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -313,7 +317,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/files?path=%s", data.ParentPath)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 115, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 119, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -341,7 +345,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 143, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 147, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -359,7 +363,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var14 templ.SafeURL
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/files?path=%s", entry.FullPath)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 149, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 153, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -372,7 +376,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 150, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 154, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -408,7 +412,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 154, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 158, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -432,7 +436,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(entry.Size))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 162, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 166, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -452,7 +456,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(getMimeDisplayName(entry.Mime))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 170, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 174, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -467,7 +471,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(entry.ModTime.Format("2006-01-02 15:04"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 176, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 180, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
@@ -486,7 +490,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 182, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 186, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -499,7 +503,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%t", entry.IsDirectory))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 182, Col: 131}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 186, Col: 131}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
@@ -512,7 +516,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 182, Col: 146}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 186, Col: 146}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -530,7 +534,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 187, Col: 139}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 191, Col: 139}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
@@ -543,7 +547,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 190, Col: 128}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 194, Col: 128}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
@@ -561,7 +565,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 194, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 198, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
@@ -574,7 +578,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(entry.FullPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 197, Col: 133}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 201, Col: 133}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
if templ_7745c5c3_Err != nil {
@@ -599,15 +603,15 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, changePageSize(data.CurrentPath, data.CurrentLastFileName))
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, changePageSize(data.CurrentPath, data.CurrentLastFileName, "file-browser-page-size-bottom"))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<select class=\"form-select form-select-sm\" style=\"width: auto;\" onchange=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<select id=\"file-browser-page-size-bottom\" class=\"form-select form-select-sm\" style=\"width: auto;\" onchange=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 templ.ComponentScript = changePageSize(data.CurrentPath, data.CurrentLastFileName)
var templ_7745c5c3_Var29 templ.ComponentScript = changePageSize(data.CurrentPath, data.CurrentLastFileName, "file-browser-page-size-bottom")
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29.Call)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -664,7 +668,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var30 templ.SafeURL
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/files?path=%s&lastFileName=%s&limit=%d", data.CurrentPath, data.LastFileName, data.PageSize)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 237, Col: 137}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 241, Col: 137}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
@@ -687,7 +691,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 252, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 256, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -700,7 +704,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 277, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 281, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
@@ -713,7 +717,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component {
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 309, Col: 79}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `weed/admin/view/app/file_browser.templ`, Line: 313, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -195,7 +195,7 @@ templ IcebergNamespaces(data dash.IcebergNamespacesData) {
<div class="mb-3">
<label for="icebergNamespaceName" class="form-label">Namespace</label>
<input type="text" class="form-control" id="icebergNamespaceName" name="name" placeholder="analytics" required/>
<div class="form-text">Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.</div>
<div class="form-text">Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).</div>
</div>
</div>
<div class="modal-footer">
@@ -327,7 +327,7 @@ func IcebergNamespaces(data dash.IcebergNamespacesData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"> <input type=\"hidden\" id=\"icebergNamespaceCsrfToken\" name=\"csrf_token\"><div class=\"mb-3\"><label for=\"icebergNamespaceName\" class=\"form-label\">Namespace</label> <input type=\"text\" class=\"form-control\" id=\"icebergNamespaceName\" name=\"name\" placeholder=\"analytics\" required><div class=\"form-text\">Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create</button></div></form></div></div></div><script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tinitIcebergNamespaces();\n\t\t});\n\t</script>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"> <input type=\"hidden\" id=\"icebergNamespaceCsrfToken\" name=\"csrf_token\"><div class=\"mb-3\"><label for=\"icebergNamespaceName\" class=\"form-label\">Namespace</label> <input type=\"text\" class=\"form-control\" id=\"icebergNamespaceName\" name=\"name\" placeholder=\"analytics\" required><div class=\"form-text\">Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create</button></div></form></div></div></div><script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tinitIcebergNamespaces();\n\t\t});\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+48 -36
View File
@@ -154,14 +154,14 @@ templ S3TablesNamespaces(data dash.S3TablesNamespacesData) {
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="createS3TablesNamespaceForm">
<div class="modal-body">
<input type="hidden" name="bucket_arn" value={ data.BucketARN }/>
<div class="mb-3">
<label for="s3tablesNamespaceName" class="form-label">Namespace</label>
<input type="text" class="form-control" id="s3tablesNamespaceName" name="name" placeholder="analytics" required/>
<div class="form-text">Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.</div>
<div class="modal-body">
<input type="hidden" name="bucket_arn" value={ data.BucketARN }/>
<div class="mb-3">
<label for="s3tablesNamespaceName" class="form-label">Namespace</label>
<input type="text" class="form-control" id="s3tablesNamespaceName" name="name" placeholder="analytics" required/>
<div class="form-text">Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
@@ -235,41 +235,53 @@ templ S3TablesNamespaces(data dash.S3TablesNamespacesData) {
input.setCustomValidity('');
return '';
}
let message = '';
if (!name) {
message = 'Namespace name is required';
} else if (name.length < 1 || name.length > 255) {
message = 'Namespace name must be between 1 and 255 characters';
} else if (name === '.' || name === '..') {
message = "namespace name cannot be '.' or '..'";
} else if (name.includes('/')) {
message = "namespace name cannot contain '/'";
} else {
const start = name[0];
const end = name[name.length - 1];
const isStartValid = (start >= 'a' && start <= 'z') || (start >= '0' && start <= '9');
const isEndValid = (end >= 'a' && end <= 'z') || (end >= '0' && end <= '9');
if (!isStartValid) {
message = 'Namespace name must start with a letter or digit';
} else if (!isEndValid) {
message = 'Namespace name must end with a letter or digit';
let message = '';
if (!name) {
message = 'Namespace name is required';
} else if (name.length < 1 || name.length > 255) {
message = 'Namespace name must be between 1 and 255 characters';
} else if (name === '.' || name === '..') {
message = "namespace name cannot be '.' or '..'";
} else if (name.includes('/')) {
message = "namespace name cannot contain '/'";
} else {
for (const ch of name) {
const isLower = ch >= 'a' && ch <= 'z';
const isDigit = ch >= '0' && ch <= '9';
if (!(isLower || isDigit || ch === '_')) {
message = "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
for (const part of name.split('.')) {
if (!part) {
message = 'namespace levels cannot be empty';
break;
}
const start = part[0];
const end = part[part.length - 1];
const isStartValid = (start >= 'a' && start <= 'z') || (start >= '0' && start <= '9');
const isEndValid = (end >= 'a' && end <= 'z') || (end >= '0' && end <= '9');
if (!isStartValid) {
message = 'Namespace name must start with a letter or digit';
break;
}
if (!isEndValid) {
message = 'Namespace name must end with a letter or digit';
break;
}
for (const ch of part) {
const isLower = ch >= 'a' && ch <= 'z';
const isDigit = ch >= '0' && ch <= '9';
if (!(isLower || isDigit || ch === '_')) {
message = "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
break;
}
}
if (message) {
break;
}
if (part.startsWith('aws')) {
message = "namespace name cannot start with reserved prefix 'aws'";
break;
}
}
if (!message && name.startsWith('aws')) {
message = "namespace name cannot start with reserved prefix 'aws'";
}
}
input.setCustomValidity(message);
return message;
}
input.setCustomValidity(message);
return message;
}
document.addEventListener('DOMContentLoaded', function() {
s3tablesNamespaceDeleteModalInstance = new bootstrap.Modal(document.getElementById('deleteS3TablesNamespaceModal'));
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -126,7 +126,7 @@ templ ServiceAccounts(data dash.ServiceAccountsData) {
<code class="text-muted">{sa.AccessKeyId}</code>
</td>
<td>
if sa.Status == "Active" {
if sa.Status == dash.StatusActive {
<span class="badge bg-success">Active</span>
} else {
<span class="badge bg-secondary">Inactive</span>
@@ -141,7 +141,7 @@ templ ServiceAccounts(data dash.ServiceAccountsData) {
</button>
<button type="button" class="btn btn-outline-primary"
data-action="toggle-sa-status" data-sa-id={ sa.ID } data-current-status={ sa.Status }>
if sa.Status == "Active" {
if sa.Status == dash.StatusActive {
<i class="fas fa-pause"></i>
} else {
<i class="fas fa-play"></i>
@@ -121,7 +121,7 @@ func ServiceAccounts(data dash.ServiceAccountsData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if sa.Status == "Active" {
if sa.Status == dash.StatusActive {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<span class=\"badge bg-success\">Active</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
@@ -188,7 +188,7 @@ func ServiceAccounts(data dash.ServiceAccountsData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if sa.Status == "Active" {
if sa.Status == dash.StatusActive {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<i class=\"fas fa-pause\"></i>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
+3 -4
View File
@@ -390,11 +390,10 @@ func (option *RemoteGatewayOptions) detectBucketInfo(actualDir string) (bucket u
}
func extractBucketPath(bucketsDir, dir string) (util.FullPath, bool) {
if !strings.HasPrefix(dir, bucketsDir+"/") {
return "", false
if bucketPath, ok := util.ExtractBucketPath(bucketsDir, dir, false); ok {
return util.FullPath(bucketPath), true
}
parts := strings.SplitN(dir[len(bucketsDir)+1:], "/", 2)
return util.FullPath(bucketsDir).Child(parts[0]), true
return "", false
}
func (option *RemoteGatewayOptions) collectRemoteStorageConf() (err error) {
+68
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"os"
"os/user"
"path"
"runtime"
"strconv"
"strings"
@@ -26,6 +27,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mount_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"google.golang.org/grpc/reflection"
@@ -59,6 +61,63 @@ func runMount(cmd *Command, args []string) bool {
return RunMount(&mountOptions, os.FileMode(umask))
}
func ensureBucketAllowEmptyFolders(ctx context.Context, filerClient filer_pb.FilerClient, mountRoot, bucketRootPath string) error {
bucketPath, isBucketRootMount := bucketPathForMountRoot(mountRoot, bucketRootPath)
if !isBucketRootMount {
return nil
}
entry, err := filer_pb.GetEntry(ctx, filerClient, util.FullPath(bucketPath))
if err != nil {
return err
}
if entry == nil {
return fmt.Errorf("bucket %s not found", bucketPath)
}
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
if strings.EqualFold(strings.TrimSpace(string(entry.Extended[s3_constants.ExtAllowEmptyFolders])), "true") {
return nil
}
entry.Extended[s3_constants.ExtAllowEmptyFolders] = []byte("true")
bucketFullPath := util.FullPath(bucketPath)
parent, _ := bucketFullPath.DirAndName()
if err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return filer_pb.UpdateEntry(ctx, client, &filer_pb.UpdateEntryRequest{
Directory: parent,
Entry: entry,
})
}); err != nil {
return err
}
glog.V(3).Infof("RunMount: set bucket %s %s=true", bucketPath, s3_constants.ExtAllowEmptyFolders)
return nil
}
func bucketPathForMountRoot(mountRoot, bucketRootPath string) (string, bool) {
cleanPath := path.Clean("/" + strings.TrimPrefix(mountRoot, "/"))
cleanBucketRoot := path.Clean("/" + strings.TrimPrefix(bucketRootPath, "/"))
if cleanBucketRoot == "/" {
return "", false
}
prefix := cleanBucketRoot + "/"
if !strings.HasPrefix(cleanPath, prefix) {
return "", false
}
rest := strings.TrimPrefix(cleanPath, prefix)
bucketParts := strings.Split(rest, "/")
if len(bucketParts) != 1 || bucketParts[0] == "" {
return "", false
}
return cleanBucketRoot + "/" + bucketParts[0], true
}
func RunMount(option *MountOptions, umask os.FileMode) bool {
// basic checks
@@ -73,6 +132,7 @@ func RunMount(option *MountOptions, umask os.FileMode) bool {
util.LoadSecurityConfiguration()
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.client")
var cipher bool
var bucketRootPath string
var err error
for i := 0; i < 10; i++ {
err = pb.WithOneOfGrpcFilerClients(false, filerAddresses, grpcDialOption, func(client filer_pb.SeaweedFilerClient) error {
@@ -81,6 +141,7 @@ func RunMount(option *MountOptions, umask os.FileMode) bool {
return fmt.Errorf("get filer grpc address %v configuration: %w", filerAddresses, err)
}
cipher = resp.Cipher
bucketRootPath = resp.DirBuckets
return nil
})
if err != nil {
@@ -93,6 +154,9 @@ func RunMount(option *MountOptions, umask os.FileMode) bool {
glog.Errorf("failed to talk to filer %v: %v", filerAddresses, err)
return true
}
if bucketRootPath == "" {
bucketRootPath = "/buckets"
}
filerMountRootPath := *option.filerMountRootPath
@@ -287,6 +351,10 @@ func RunMount(option *MountOptions, umask os.FileMode) bool {
fmt.Printf("failed to create dir %s on filer %s: %v\n", mountRoot, filerAddresses, err)
return false
}
if err := ensureBucketAllowEmptyFolders(context.Background(), seaweedFileSystem, mountRoot, bucketRootPath); err != nil {
fmt.Printf("failed to set bucket auto-remove-empty-folders policy for %s: %v\n", mountRoot, err)
return false
}
server, err := fuse.NewServer(seaweedFileSystem, dir, fuseMountOptions)
if err != nil {
+52
View File
@@ -0,0 +1,52 @@
//go:build linux || darwin || freebsd
// +build linux darwin freebsd
package command
import "testing"
func Test_bucketPathForMountRoot(t *testing.T) {
tests := []struct {
name string
mountRoot string
expected string
ok bool
}{
{
name: "bucket root mount",
mountRoot: "/buckets/test",
expected: "/buckets/test",
ok: true,
},
{
name: "bucket root with trailing slash",
mountRoot: "/buckets/test/",
expected: "/buckets/test",
ok: true,
},
{
name: "subdirectory mount",
mountRoot: "/buckets/test/data",
expected: "",
ok: false,
},
{
name: "non-bucket mount",
mountRoot: "/data/test",
expected: "",
ok: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotPath, gotOK := bucketPathForMountRoot(tt.mountRoot, "/buckets")
if gotOK != tt.ok {
t.Fatalf("expected ok=%v, got %v", tt.ok, gotOK)
}
if gotPath != tt.expected {
t.Fatalf("expected path %q, got %q", tt.expected, gotPath)
}
})
}
}
@@ -21,8 +21,9 @@ type CleanupQueue struct {
// queueItem represents an item in the cleanup queue
type queueItem struct {
folder string
queueTime time.Time
folder string
triggeredBy string
queueTime time.Time
}
// NewCleanupQueue creates a new CleanupQueue with the specified limits
@@ -39,7 +40,7 @@ func NewCleanupQueue(maxSize int, maxAge time.Duration) *CleanupQueue {
// The item is inserted in time-sorted order (oldest at front) to handle out-of-order events.
// If folder already exists with an older time, the time is updated and position adjusted.
// Returns true if the folder was newly added, false if it was updated.
func (q *CleanupQueue) Add(folder string, eventTime time.Time) bool {
func (q *CleanupQueue) Add(folder string, triggeredBy string, eventTime time.Time) bool {
q.mu.Lock()
defer q.mu.Unlock()
@@ -51,23 +52,24 @@ func (q *CleanupQueue) Add(folder string, eventTime time.Time) bool {
// Remove from current position
q.items.Remove(elem)
// Re-insert with new time in sorted position
newElem := q.insertSorted(folder, eventTime)
newElem := q.insertSorted(folder, triggeredBy, eventTime)
q.itemsMap[folder] = newElem
}
return false
}
// Insert new folder in sorted position
elem := q.insertSorted(folder, eventTime)
elem := q.insertSorted(folder, triggeredBy, eventTime)
q.itemsMap[folder] = elem
return true
}
// insertSorted inserts an item in the correct position to maintain time ordering (oldest at front)
func (q *CleanupQueue) insertSorted(folder string, eventTime time.Time) *list.Element {
func (q *CleanupQueue) insertSorted(folder string, triggeredBy string, eventTime time.Time) *list.Element {
item := &queueItem{
folder: folder,
queueTime: eventTime,
folder: folder,
triggeredBy: triggeredBy,
queueTime: eventTime,
}
// Find the correct position (insert before the first item with a later time)
@@ -135,35 +137,35 @@ func (q *CleanupQueue) shouldProcessLocked() bool {
// Pop removes and returns the oldest folder from the queue.
// Returns the folder and true if an item was available, or empty string and false if queue is empty.
func (q *CleanupQueue) Pop() (string, bool) {
func (q *CleanupQueue) Pop() (string, string, bool) {
q.mu.Lock()
defer q.mu.Unlock()
front := q.items.Front()
if front == nil {
return "", false
return "", "", false
}
item := front.Value.(*queueItem)
q.items.Remove(front)
delete(q.itemsMap, item.folder)
return item.folder, true
return item.folder, item.triggeredBy, true
}
// Peek returns the oldest folder without removing it.
// Returns the folder and queue time if available, or empty values if queue is empty.
func (q *CleanupQueue) Peek() (folder string, queueTime time.Time, ok bool) {
func (q *CleanupQueue) Peek() (folder string, triggeredBy string, queueTime time.Time, ok bool) {
q.mu.Lock()
defer q.mu.Unlock()
front := q.items.Front()
if front == nil {
return "", time.Time{}, false
return "", "", time.Time{}, false
}
item := front.Value.(*queueItem)
return item.folder, item.queueTime, true
return item.folder, item.triggeredBy, item.queueTime, true
}
// Len returns the current queue size.
@@ -10,7 +10,7 @@ func TestCleanupQueue_Add(t *testing.T) {
now := time.Now()
// Add first item
if !q.Add("/buckets/b1/folder1", now) {
if !q.Add("/buckets/b1/folder1", "item1", now) {
t.Error("expected Add to return true for new item")
}
if q.Len() != 1 {
@@ -18,7 +18,7 @@ func TestCleanupQueue_Add(t *testing.T) {
}
// Add second item with later time
if !q.Add("/buckets/b1/folder2", now.Add(1*time.Second)) {
if !q.Add("/buckets/b1/folder2", "item2", now.Add(1*time.Second)) {
t.Error("expected Add to return true for new item")
}
if q.Len() != 2 {
@@ -26,7 +26,7 @@ func TestCleanupQueue_Add(t *testing.T) {
}
// Add duplicate with newer time - should update and reposition
if q.Add("/buckets/b1/folder1", now.Add(2*time.Second)) {
if q.Add("/buckets/b1/folder1", "item1-updated", now.Add(2*time.Second)) {
t.Error("expected Add to return false for existing item")
}
if q.Len() != 2 {
@@ -34,11 +34,14 @@ func TestCleanupQueue_Add(t *testing.T) {
}
// folder1 should now be at the back (newer time) - verify by popping
folder1, _ := q.Pop()
folder2, _ := q.Pop()
folder1, triggered1, _ := q.Pop()
folder2, triggered2, _ := q.Pop()
if folder1 != "/buckets/b1/folder2" || folder2 != "/buckets/b1/folder1" {
t.Errorf("expected folder1 to be moved to back, got %s, %s", folder1, folder2)
}
if triggered1 != "item2" || triggered2 != "item1-updated" {
t.Errorf("triggeredBy mismatch: got %s, %s", triggered1, triggered2)
}
}
func TestCleanupQueue_Add_OutOfOrder(t *testing.T) {
@@ -46,16 +49,23 @@ func TestCleanupQueue_Add_OutOfOrder(t *testing.T) {
baseTime := time.Now()
// Add items out of order
q.Add("/buckets/b1/folder3", baseTime.Add(3*time.Second))
q.Add("/buckets/b1/folder1", baseTime.Add(1*time.Second))
q.Add("/buckets/b1/folder2", baseTime.Add(2*time.Second))
q.Add("/buckets/b1/folder3", "f3", baseTime.Add(3*time.Second))
q.Add("/buckets/b1/folder1", "f1", baseTime.Add(1*time.Second))
q.Add("/buckets/b1/folder2", "f2", baseTime.Add(2*time.Second))
// Items should be in time order (oldest first) - verify by popping
expected := []string{"/buckets/b1/folder1", "/buckets/b1/folder2", "/buckets/b1/folder3"}
expected := []struct {
folder string
item string
}{
{"/buckets/b1/folder1", "f1"},
{"/buckets/b1/folder2", "f2"},
{"/buckets/b1/folder3", "f3"},
}
for i, exp := range expected {
folder, ok := q.Pop()
if !ok || folder != exp {
t.Errorf("at index %d: expected %s, got %s", i, exp, folder)
folder, item, ok := q.Pop()
if !ok || folder != exp.folder || item != exp.item {
t.Errorf("at index %d: expected %s by %s, got %s by %s", i, exp.folder, exp.item, folder, item)
}
}
}
@@ -65,15 +75,15 @@ func TestCleanupQueue_Add_DuplicateWithOlderTime(t *testing.T) {
baseTime := time.Now()
// Add folder at t=5
q.Add("/buckets/b1/folder1", baseTime.Add(5*time.Second))
q.Add("/buckets/b1/folder1", "t5", baseTime.Add(5*time.Second))
// Try to add same folder with older time - should NOT update
q.Add("/buckets/b1/folder1", baseTime.Add(2*time.Second))
q.Add("/buckets/b1/folder1", "t2", baseTime.Add(2*time.Second))
// Time should remain at t=5
_, queueTime, _ := q.Peek()
if queueTime != baseTime.Add(5*time.Second) {
t.Errorf("expected time to remain unchanged, got %v", queueTime)
// Time and triggeredBy should remain at t=5
_, triggered, queueTime, _ := q.Peek()
if queueTime != baseTime.Add(5*time.Second) || triggered != "t5" {
t.Errorf("expected time to remain unchanged, got %v and %s", queueTime, triggered)
}
}
@@ -81,9 +91,9 @@ func TestCleanupQueue_Remove(t *testing.T) {
q := NewCleanupQueue(100, 10*time.Minute)
now := time.Now()
q.Add("/buckets/b1/folder1", now)
q.Add("/buckets/b1/folder2", now.Add(1*time.Second))
q.Add("/buckets/b1/folder3", now.Add(2*time.Second))
q.Add("/buckets/b1/folder1", "i1", now)
q.Add("/buckets/b1/folder2", "i2", now.Add(1*time.Second))
q.Add("/buckets/b1/folder3", "i3", now.Add(2*time.Second))
// Remove middle item
if !q.Remove("/buckets/b1/folder2") {
@@ -102,8 +112,8 @@ func TestCleanupQueue_Remove(t *testing.T) {
}
// Verify order is preserved by popping
folder1, _ := q.Pop()
folder3, _ := q.Pop()
folder1, _, _ := q.Pop()
folder3, _, _ := q.Pop()
if folder1 != "/buckets/b1/folder1" || folder3 != "/buckets/b1/folder3" {
t.Errorf("unexpected order: %s, %s", folder1, folder3)
}
@@ -114,30 +124,30 @@ func TestCleanupQueue_Pop(t *testing.T) {
now := time.Now()
// Pop from empty queue
folder, ok := q.Pop()
folder, item, ok := q.Pop()
if ok {
t.Error("expected Pop to return false for empty queue")
}
if folder != "" {
t.Errorf("expected empty folder, got %s", folder)
if folder != "" || item != "" {
t.Errorf("expected empty results, got %s by %s", folder, item)
}
// Add items and pop in order
q.Add("/buckets/b1/folder1", now)
q.Add("/buckets/b1/folder2", now.Add(1*time.Second))
q.Add("/buckets/b1/folder3", now.Add(2*time.Second))
q.Add("/buckets/b1/folder1", "i1", now)
q.Add("/buckets/b1/folder2", "i2", now.Add(1*time.Second))
q.Add("/buckets/b1/folder3", "i3", now.Add(2*time.Second))
folder, ok = q.Pop()
folder, _, ok = q.Pop()
if !ok || folder != "/buckets/b1/folder1" {
t.Errorf("expected folder1, got %s (ok=%v)", folder, ok)
}
folder, ok = q.Pop()
folder, _, ok = q.Pop()
if !ok || folder != "/buckets/b1/folder2" {
t.Errorf("expected folder2, got %s (ok=%v)", folder, ok)
}
folder, ok = q.Pop()
folder, _, ok = q.Pop()
if !ok || folder != "/buckets/b1/folder3" {
t.Errorf("expected folder3, got %s (ok=%v)", folder, ok)
}
@@ -153,16 +163,16 @@ func TestCleanupQueue_Peek(t *testing.T) {
now := time.Now()
// Peek empty queue
folder, _, ok := q.Peek()
folder, item, _, ok := q.Peek()
if ok {
t.Error("expected Peek to return false for empty queue")
}
// Add item and peek
q.Add("/buckets/b1/folder1", now)
folder, queueTime, ok := q.Peek()
if !ok || folder != "/buckets/b1/folder1" {
t.Errorf("expected folder1, got %s (ok=%v)", folder, ok)
q.Add("/buckets/b1/folder1", "i1", now)
folder, item, queueTime, ok := q.Peek()
if !ok || folder != "/buckets/b1/folder1" || item != "i1" {
t.Errorf("expected folder1 by i1, got %s by %s (ok=%v)", folder, item, ok)
}
if queueTime != now {
t.Errorf("expected queue time %v, got %v", now, queueTime)
@@ -178,7 +188,7 @@ func TestCleanupQueue_Contains(t *testing.T) {
q := NewCleanupQueue(100, 10*time.Minute)
now := time.Now()
q.Add("/buckets/b1/folder1", now)
q.Add("/buckets/b1/folder1", "i1", now)
if !q.Contains("/buckets/b1/folder1") {
t.Error("expected Contains to return true")
@@ -198,14 +208,14 @@ func TestCleanupQueue_ShouldProcess_MaxSize(t *testing.T) {
}
// Add items below max
q.Add("/buckets/b1/folder1", now)
q.Add("/buckets/b1/folder2", now.Add(1*time.Second))
q.Add("/buckets/b1/folder1", "i1", now)
q.Add("/buckets/b1/folder2", "i2", now.Add(1*time.Second))
if q.ShouldProcess() {
t.Error("queue below max should not need processing")
}
// Add item to reach max
q.Add("/buckets/b1/folder3", now.Add(2*time.Second))
q.Add("/buckets/b1/folder3", "i3", now.Add(2*time.Second))
if !q.ShouldProcess() {
t.Error("queue at max should need processing")
}
@@ -216,7 +226,7 @@ func TestCleanupQueue_ShouldProcess_MaxAge(t *testing.T) {
// Add item with old event time
oldTime := time.Now().Add(-1 * time.Second) // 1 second ago
q.Add("/buckets/b1/folder1", oldTime)
q.Add("/buckets/b1/folder1", "i1", oldTime)
// Item is older than maxAge, should need processing
if !q.ShouldProcess() {
@@ -225,7 +235,7 @@ func TestCleanupQueue_ShouldProcess_MaxAge(t *testing.T) {
// Clear and add fresh item
q.Clear()
q.Add("/buckets/b1/folder2", time.Now())
q.Add("/buckets/b1/folder2", "i2", time.Now())
// Fresh item should not trigger processing
if q.ShouldProcess() {
@@ -237,9 +247,9 @@ func TestCleanupQueue_Clear(t *testing.T) {
q := NewCleanupQueue(100, 10*time.Minute)
now := time.Now()
q.Add("/buckets/b1/folder1", now)
q.Add("/buckets/b1/folder2", now.Add(1*time.Second))
q.Add("/buckets/b1/folder3", now.Add(2*time.Second))
q.Add("/buckets/b1/folder1", "i1", now)
q.Add("/buckets/b1/folder2", "i2", now.Add(1*time.Second))
q.Add("/buckets/b1/folder3", "i3", now.Add(2*time.Second))
q.Clear()
@@ -261,7 +271,7 @@ func TestCleanupQueue_OldestAge(t *testing.T) {
// Add item with time in the past
oldTime := time.Now().Add(-5 * time.Minute)
q.Add("/buckets/b1/folder1", oldTime)
q.Add("/buckets/b1/folder1", "i1", oldTime)
// Age should be approximately 5 minutes
age := q.OldestAge()
@@ -283,12 +293,12 @@ func TestCleanupQueue_TimeOrder(t *testing.T) {
"/buckets/b1/e",
}
for i, item := range items {
q.Add(item, baseTime.Add(time.Duration(i)*time.Second))
q.Add(item, "i", baseTime.Add(time.Duration(i)*time.Second))
}
// Pop should return in time order
for i, expected := range items {
got, ok := q.Pop()
got, _, ok := q.Pop()
if !ok {
t.Errorf("Pop %d: expected item, got empty", i)
}
@@ -303,17 +313,17 @@ func TestCleanupQueue_DuplicateWithNewerTime(t *testing.T) {
baseTime := time.Now()
// Add items
q.Add("/buckets/b1/folder1", baseTime)
q.Add("/buckets/b1/folder2", baseTime.Add(1*time.Second))
q.Add("/buckets/b1/folder3", baseTime.Add(2*time.Second))
q.Add("/buckets/b1/folder1", "i1", baseTime)
q.Add("/buckets/b1/folder2", "i2", baseTime.Add(1*time.Second))
q.Add("/buckets/b1/folder3", "i3", baseTime.Add(2*time.Second))
// Add duplicate with newer time - should update and reposition
q.Add("/buckets/b1/folder1", baseTime.Add(3*time.Second))
q.Add("/buckets/b1/folder1", "i1-new", baseTime.Add(3*time.Second))
// folder1 should now be at the back (newest time) - verify by popping
expected := []string{"/buckets/b1/folder2", "/buckets/b1/folder3", "/buckets/b1/folder1"}
for i, exp := range expected {
folder, ok := q.Pop()
folder, _, ok := q.Pop()
if !ok || folder != exp {
t.Errorf("at index %d: expected %s, got %s", i, exp, folder)
}
@@ -328,7 +338,7 @@ func TestCleanupQueue_Concurrent(t *testing.T) {
// Concurrent adds
go func() {
for i := 0; i < 100; i++ {
q.Add("/buckets/b1/folder"+string(rune('A'+i%26)), now.Add(time.Duration(i)*time.Millisecond))
q.Add("/buckets/b1/folder"+string(rune('A'+i%26)), "item", now.Add(time.Duration(i)*time.Millisecond))
}
done <- true
}()
@@ -18,7 +18,7 @@ const (
DefaultMaxCountCheck = 1000
DefaultCacheExpiry = 5 * time.Minute
DefaultQueueMaxSize = 1000
DefaultQueueMaxAge = 10 * time.Minute
DefaultQueueMaxAge = 5 * time.Second
DefaultProcessorSleep = 10 * time.Second // How often to check queue
)
@@ -32,12 +32,17 @@ type FilerOperations interface {
// folderState tracks the state of a folder for empty folder cleanup
type folderState struct {
roughCount int // Cached rough count (up to maxCountCheck)
isImplicit *bool // Tri-state boolean: nil (unknown), true (implicit), false (explicit)
lastAddTime time.Time // Last time an item was added
lastDelTime time.Time // Last time an item was deleted
lastCheck time.Time // Last time we checked the actual count
}
type bucketCleanupPolicyState struct {
autoRemove bool
attrValue string
lastCheck time.Time
}
// EmptyFolderCleaner handles asynchronous cleanup of empty folders
// Each filer owns specific folders via consistent hashing based on the peer filer list
type EmptyFolderCleaner struct {
@@ -46,8 +51,9 @@ type EmptyFolderCleaner struct {
host pb.ServerAddress
// Folder state tracking
mu sync.RWMutex
folderCounts map[string]*folderState // Rough count cache
mu sync.RWMutex
folderCounts map[string]*folderState // Rough count cache
bucketCleanupPolicies map[string]*bucketCleanupPolicyState // bucket path -> cleanup policy cache
// Cleanup queue (thread-safe, has its own lock)
cleanupQueue *CleanupQueue
@@ -66,17 +72,18 @@ type EmptyFolderCleaner struct {
// NewEmptyFolderCleaner creates a new EmptyFolderCleaner
func NewEmptyFolderCleaner(filer FilerOperations, lockRing *lock_manager.LockRing, host pb.ServerAddress, bucketPath string) *EmptyFolderCleaner {
efc := &EmptyFolderCleaner{
filer: filer,
lockRing: lockRing,
host: host,
folderCounts: make(map[string]*folderState),
cleanupQueue: NewCleanupQueue(DefaultQueueMaxSize, DefaultQueueMaxAge),
maxCountCheck: DefaultMaxCountCheck,
cacheExpiry: DefaultCacheExpiry,
processorSleep: DefaultProcessorSleep,
bucketPath: bucketPath,
enabled: true,
stopCh: make(chan struct{}),
filer: filer,
lockRing: lockRing,
host: host,
folderCounts: make(map[string]*folderState),
bucketCleanupPolicies: make(map[string]*bucketCleanupPolicyState),
cleanupQueue: NewCleanupQueue(DefaultQueueMaxSize, DefaultQueueMaxAge),
maxCountCheck: DefaultMaxCountCheck,
cacheExpiry: DefaultCacheExpiry,
processorSleep: DefaultProcessorSleep,
bucketPath: bucketPath,
enabled: true,
stopCh: make(chan struct{}),
}
go efc.cacheEvictionLoop()
go efc.cleanupProcessor()
@@ -162,8 +169,8 @@ func (efc *EmptyFolderCleaner) OnDeleteEvent(directory string, entryName string,
}
// Add to cleanup queue with event time (handles out-of-order events)
if efc.cleanupQueue.Add(directory, eventTime) {
glog.V(3).Infof("EmptyFolderCleaner: queued %s for cleanup", directory)
if efc.cleanupQueue.Add(directory, entryName, eventTime) {
glog.V(3).Infof("EmptyFolderCleaner: queued %s for cleanup (triggered by %s)", directory, entryName)
}
}
@@ -214,6 +221,10 @@ func (efc *EmptyFolderCleaner) cleanupProcessor() {
func (efc *EmptyFolderCleaner) processCleanupQueue() {
// Check if we should process
if !efc.cleanupQueue.ShouldProcess() {
if efc.cleanupQueue.Len() > 0 {
glog.Infof("EmptyFolderCleaner: pending queue not processed yet (len=%d, oldest_age=%v, max_size=%d, max_age=%v)",
efc.cleanupQueue.Len(), efc.cleanupQueue.OldestAge(), efc.cleanupQueue.maxSize, efc.cleanupQueue.maxAge)
}
return
}
@@ -228,35 +239,30 @@ func (efc *EmptyFolderCleaner) processCleanupQueue() {
}
// Pop the oldest item
folder, ok := efc.cleanupQueue.Pop()
folder, triggeredBy, ok := efc.cleanupQueue.Pop()
if !ok {
break
}
// Execute cleanup for this folder
efc.executeCleanup(folder)
// If queue is no longer full and oldest item is not old enough, stop processing
if !efc.cleanupQueue.ShouldProcess() {
break
}
efc.executeCleanup(folder, triggeredBy)
}
}
// executeCleanup performs the actual cleanup of an empty folder
func (efc *EmptyFolderCleaner) executeCleanup(folder string) {
func (efc *EmptyFolderCleaner) executeCleanup(folder string, triggeredBy string) {
efc.mu.Lock()
// Quick check: if we have cached count and it's > 0, skip
if state, exists := efc.folderCounts[folder]; exists {
if state.roughCount > 0 {
glog.V(3).Infof("EmptyFolderCleaner: skipping %s, cached count=%d", folder, state.roughCount)
glog.V(3).Infof("EmptyFolderCleaner: skipping %s (triggered by %s), cached count=%d", folder, triggeredBy, state.roughCount)
efc.mu.Unlock()
return
}
// If there was an add after our delete, skip
if !state.lastAddTime.IsZero() && state.lastAddTime.After(state.lastDelTime) {
glog.V(3).Infof("EmptyFolderCleaner: skipping %s, add happened after delete", folder)
glog.V(3).Infof("EmptyFolderCleaner: skipping %s (triggered by %s), add happened after delete", folder, triggeredBy)
efc.mu.Unlock()
return
}
@@ -265,47 +271,23 @@ func (efc *EmptyFolderCleaner) executeCleanup(folder string) {
// Re-check ownership (topology might have changed)
if !efc.ownsFolder(folder) {
glog.V(3).Infof("EmptyFolderCleaner: no longer owner of %s, skipping", folder)
glog.V(3).Infof("EmptyFolderCleaner: no longer owner of %s (triggered by %s), skipping", folder, triggeredBy)
return
}
// Check for explicit implicit_dir attribute
// First check cache
ctx := context.Background()
efc.mu.RLock()
var cachedImplicit *bool
if state, exists := efc.folderCounts[folder]; exists {
cachedImplicit = state.isImplicit
}
efc.mu.RUnlock()
var isImplicit bool
if cachedImplicit != nil {
isImplicit = *cachedImplicit
} else {
// Not cached, check filer
attrs, err := efc.filer.GetEntryAttributes(ctx, util.FullPath(folder))
if err != nil {
if err == filer_pb.ErrNotFound {
return
}
glog.V(2).Infof("EmptyFolderCleaner: error getting attributes for %s: %v", folder, err)
bucketPath, autoRemove, source, attrValue, err := efc.getBucketCleanupPolicy(ctx, folder)
if err != nil {
if err == filer_pb.ErrNotFound {
return
}
isImplicit = attrs != nil && string(attrs[s3_constants.ExtS3ImplicitDir]) == "true"
// Update cache
efc.mu.Lock()
if _, exists := efc.folderCounts[folder]; !exists {
efc.folderCounts[folder] = &folderState{}
}
efc.folderCounts[folder].isImplicit = &isImplicit
efc.mu.Unlock()
glog.V(2).Infof("EmptyFolderCleaner: failed to load bucket cleanup policy for folder %s (triggered by %s): %v", folder, triggeredBy, err)
return
}
if !isImplicit {
glog.V(4).Infof("EmptyFolderCleaner: folder %s is not marked as implicit, skipping", folder)
if !autoRemove {
glog.V(3).Infof("EmptyFolderCleaner: skipping folder %s (triggered by %s), bucket %s auto-remove-empty-folders disabled (source=%s attr=%s)",
folder, triggeredBy, bucketPath, source, attrValue)
return
}
@@ -326,14 +308,14 @@ func (efc *EmptyFolderCleaner) executeCleanup(folder string) {
efc.mu.Unlock()
if count > 0 {
glog.V(3).Infof("EmptyFolderCleaner: folder %s has %d items, not empty", folder, count)
glog.Infof("EmptyFolderCleaner: folder %s (triggered by %s) has %d items, not empty", folder, triggeredBy, count)
return
}
// Delete the empty folder
glog.V(2).Infof("EmptyFolderCleaner: deleting empty folder %s", folder)
glog.Infof("EmptyFolderCleaner: deleting empty folder %s (triggered by %s)", folder, triggeredBy)
if err := efc.deleteFolder(ctx, folder); err != nil {
glog.V(2).Infof("EmptyFolderCleaner: failed to delete empty folder %s: %v", folder, err)
glog.V(2).Infof("EmptyFolderCleaner: failed to delete empty folder %s (triggered by %s): %v", folder, triggeredBy, err)
return
}
@@ -357,6 +339,60 @@ func (efc *EmptyFolderCleaner) deleteFolder(ctx context.Context, folder string)
return efc.filer.DeleteEntryMetaAndData(ctx, util.FullPath(folder), false, false, false, false, nil, 0)
}
func (efc *EmptyFolderCleaner) getBucketCleanupPolicy(ctx context.Context, folder string) (bucketPath string, autoRemove bool, source string, attrValue string, err error) {
bucketPath, ok := util.ExtractBucketPath(efc.bucketPath, folder, true)
if !ok {
return "", true, "default", "<not_bucket_path>", nil
}
now := time.Now()
efc.mu.RLock()
if state, found := efc.bucketCleanupPolicies[bucketPath]; found && now.Sub(state.lastCheck) <= efc.cacheExpiry {
efc.mu.RUnlock()
return bucketPath, state.autoRemove, "cache", state.attrValue, nil
}
efc.mu.RUnlock()
attrs, err := efc.filer.GetEntryAttributes(ctx, util.FullPath(bucketPath))
if err != nil {
return "", true, "", "", err
}
autoRemove, attrValue = autoRemoveEmptyFoldersEnabled(attrs)
efc.mu.Lock()
if efc.bucketCleanupPolicies == nil {
efc.bucketCleanupPolicies = make(map[string]*bucketCleanupPolicyState)
}
efc.bucketCleanupPolicies[bucketPath] = &bucketCleanupPolicyState{
autoRemove: autoRemove,
attrValue: attrValue,
lastCheck: now,
}
efc.mu.Unlock()
return bucketPath, autoRemove, "filer", attrValue, nil
}
func autoRemoveEmptyFoldersEnabled(attrs map[string][]byte) (bool, string) {
if attrs == nil {
return true, "<no_attrs>"
}
value, found := attrs[s3_constants.ExtAllowEmptyFolders]
if !found {
return true, "<missing>"
}
text := strings.TrimSpace(string(value))
if text == "" {
return true, "<empty>"
}
return !strings.EqualFold(text, "true"), text
}
// isUnderPath checks if child is under parent path
func isUnderPath(child, parent string) bool {
if parent == "" || parent == "/" {
@@ -445,6 +481,12 @@ func (efc *EmptyFolderCleaner) evictStaleCacheEntries() {
}
}
for bucketPath, state := range efc.bucketCleanupPolicies {
if now.Sub(state.lastCheck) > efc.cacheExpiry {
delete(efc.bucketCleanupPolicies, bucketPath)
}
}
if expiredCount > 0 {
glog.V(3).Infof("EmptyFolderCleaner: evicted %d stale cache entries", expiredCount)
}
@@ -460,6 +502,7 @@ func (efc *EmptyFolderCleaner) Stop() {
efc.enabled = false
efc.cleanupQueue.Clear()
efc.folderCounts = make(map[string]*folderState) // Clear cache on stop
efc.bucketCleanupPolicies = make(map[string]*bucketCleanupPolicyState)
}
// GetPendingCleanupCount returns the number of pending cleanup tasks (for testing)
@@ -1,13 +1,43 @@
package empty_folder_cleanup
import (
"context"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util"
)
type mockFilerOps struct {
countFn func(path util.FullPath) (int, error)
deleteFn func(path util.FullPath) error
attrsFn func(path util.FullPath) (map[string][]byte, error)
}
func (m *mockFilerOps) CountDirectoryEntries(_ context.Context, dirPath util.FullPath, _ int) (int, error) {
if m.countFn == nil {
return 0, nil
}
return m.countFn(dirPath)
}
func (m *mockFilerOps) DeleteEntryMetaAndData(_ context.Context, p util.FullPath, _, _, _, _ bool, _ []int32, _ int64) error {
if m.deleteFn == nil {
return nil
}
return m.deleteFn(p)
}
func (m *mockFilerOps) GetEntryAttributes(_ context.Context, p util.FullPath) (map[string][]byte, error) {
if m.attrsFn == nil {
return nil, nil
}
return m.attrsFn(p)
}
func Test_isUnderPath(t *testing.T) {
tests := []struct {
name string
@@ -66,6 +96,56 @@ func Test_isUnderBucketPath(t *testing.T) {
}
}
func Test_autoRemoveEmptyFoldersEnabled(t *testing.T) {
tests := []struct {
name string
attrs map[string][]byte
enabled bool
attrValue string
}{
{
name: "no attrs defaults enabled",
attrs: nil,
enabled: true,
attrValue: "<no_attrs>",
},
{
name: "missing key defaults enabled",
attrs: map[string][]byte{},
enabled: true,
attrValue: "<missing>",
},
{
name: "allow-empty disables cleanup",
attrs: map[string][]byte{
s3_constants.ExtAllowEmptyFolders: []byte("true"),
},
enabled: false,
attrValue: "true",
},
{
name: "explicit false keeps cleanup enabled",
attrs: map[string][]byte{
s3_constants.ExtAllowEmptyFolders: []byte("false"),
},
enabled: true,
attrValue: "false",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
enabled, attrValue := autoRemoveEmptyFoldersEnabled(tt.attrs)
if enabled != tt.enabled {
t.Fatalf("expected enabled=%v, got %v", tt.enabled, enabled)
}
if attrValue != tt.attrValue {
t.Fatalf("expected attrValue=%q, got %q", tt.attrValue, attrValue)
}
})
}
}
func TestEmptyFolderCleaner_ownsFolder(t *testing.T) {
// Create a LockRing with multiple servers
lockRing := lock_manager.NewLockRing(5 * time.Second)
@@ -512,7 +592,7 @@ func TestEmptyFolderCleaner_cacheEviction_skipsEntriesInQueue(t *testing.T) {
// Add a stale cache entry
cleaner.folderCounts[folder] = &folderState{roughCount: 0, lastCheck: oldTime}
// Also add to cleanup queue
cleaner.cleanupQueue.Add(folder, time.Now())
cleaner.cleanupQueue.Add(folder, "item", time.Now())
// Run eviction
cleaner.evictStaleCacheEntries()
@@ -558,7 +638,7 @@ func TestEmptyFolderCleaner_queueFIFOOrder(t *testing.T) {
// Verify time-sorted order by popping
for i, expected := range folders {
folder, ok := cleaner.cleanupQueue.Pop()
folder, _, ok := cleaner.cleanupQueue.Pop()
if !ok || folder != expected {
t.Errorf("expected folder %s at index %d, got %s", expected, i, folder)
}
@@ -566,3 +646,90 @@ func TestEmptyFolderCleaner_queueFIFOOrder(t *testing.T) {
cleaner.Stop()
}
func TestEmptyFolderCleaner_processCleanupQueue_drainsAllOnceTriggered(t *testing.T) {
lockRing := lock_manager.NewLockRing(5 * time.Second)
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"})
var deleted []string
mock := &mockFilerOps{
countFn: func(_ util.FullPath) (int, error) {
return 0, nil
},
deleteFn: func(path util.FullPath) error {
deleted = append(deleted, string(path))
return nil
},
}
cleaner := &EmptyFolderCleaner{
filer: mock,
lockRing: lockRing,
host: "filer1:8888",
bucketPath: "/buckets",
enabled: true,
folderCounts: make(map[string]*folderState),
cleanupQueue: NewCleanupQueue(2, time.Hour),
maxCountCheck: 1000,
cacheExpiry: time.Minute,
processorSleep: time.Second,
stopCh: make(chan struct{}),
}
now := time.Now()
cleaner.cleanupQueue.Add("/buckets/test/folder1", "i1", now)
cleaner.cleanupQueue.Add("/buckets/test/folder2", "i2", now.Add(time.Millisecond))
cleaner.cleanupQueue.Add("/buckets/test/folder3", "i3", now.Add(2*time.Millisecond))
cleaner.processCleanupQueue()
if got := cleaner.cleanupQueue.Len(); got != 0 {
t.Fatalf("expected queue to be drained, got len=%d", got)
}
if len(deleted) != 3 {
t.Fatalf("expected 3 deleted folders, got %d", len(deleted))
}
}
func TestEmptyFolderCleaner_executeCleanup_bucketPolicyDisabledSkips(t *testing.T) {
lockRing := lock_manager.NewLockRing(5 * time.Second)
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"})
var deleted []string
mock := &mockFilerOps{
countFn: func(_ util.FullPath) (int, error) {
return 0, nil
},
deleteFn: func(path util.FullPath) error {
deleted = append(deleted, string(path))
return nil
},
attrsFn: func(path util.FullPath) (map[string][]byte, error) {
if string(path) == "/buckets/test" {
return map[string][]byte{s3_constants.ExtAllowEmptyFolders: []byte("true")}, nil
}
return nil, nil
},
}
cleaner := &EmptyFolderCleaner{
filer: mock,
lockRing: lockRing,
host: "filer1:8888",
bucketPath: "/buckets",
enabled: true,
folderCounts: make(map[string]*folderState),
cleanupQueue: NewCleanupQueue(1000, time.Minute),
maxCountCheck: 1000,
cacheExpiry: time.Minute,
processorSleep: time.Second,
stopCh: make(chan struct{}),
}
folder := "/buckets/test/folder"
cleaner.executeCleanup(folder, "triggered_item")
if len(deleted) != 0 {
t.Fatalf("expected folder %s to be skipped, got deletions %v", folder, deleted)
}
}
+1 -6
View File
@@ -9,7 +9,6 @@ import (
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3bucket"
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager"
@@ -303,12 +302,8 @@ func (f *Filer) ensureParentDirectoryEntry(ctx context.Context, entry *Entry, di
GroupNames: entry.GroupNames,
},
}
// level > 3 corresponds to a path depth greater than "/buckets/<bucket_name>",
// ensuring we only mark subdirectories within a bucket as implicit.
if isUnderBuckets && level > 3 {
dirEntry.Extended = map[string][]byte{
s3_constants.ExtS3ImplicitDir: []byte("true"),
}
// Parent directories under buckets are created automatically; no additional logging.
}
glog.V(2).InfofCtx(ctx, "create directory: %s %v", dirPath, dirEntry.Mode)
@@ -251,6 +251,132 @@ func TestPolicyEnforcement(t *testing.T) {
}
}
// TestSessionPolicyBoundary verifies that inline session policies restrict permissions.
func TestSessionPolicyBoundary(t *testing.T) {
iamManager := setupIntegratedIAMSystem(t)
ctx := context.Background()
stsService := iamManager.GetSTSService()
require.NotNil(t, stsService)
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::test-bucket/allowed/*"]}]}`
sessionId, err := sts.GenerateSessionId()
require.NoError(t, err)
expiresAt := time.Now().Add(time.Hour)
principal := "arn:aws:sts::000000000000:assumed-role/S3ReadOnlyRole/policy-session"
claims := sts.NewSTSSessionClaims(sessionId, stsService.Config.Issuer, expiresAt).
WithSessionName("policy-session").
WithRoleInfo("arn:aws:iam::role/S3ReadOnlyRole", principal, principal).
WithSessionPolicy(sessionPolicy)
sessionToken, err := stsService.GetTokenGenerator().GenerateJWTWithClaims(claims)
require.NoError(t, err)
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
Principal: principal,
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/allowed/file.txt",
SessionToken: sessionToken,
})
require.NoError(t, err)
assert.True(t, allowed, "Session policy should allow GetObject within allowed prefix")
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
Principal: principal,
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/other/file.txt",
SessionToken: sessionToken,
})
require.NoError(t, err)
assert.False(t, allowed, "Session policy should deny GetObject outside allowed prefix")
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
Principal: principal,
Action: "s3:ListBucket",
Resource: "arn:aws:s3:::test-bucket",
SessionToken: sessionToken,
})
require.NoError(t, err)
assert.False(t, allowed, "Session policy should deny ListBucket when not explicitly allowed")
}
// TestAssumeRoleWithWebIdentitySessionPolicy verifies Policy downscoping is applied to web identity sessions.
func TestAssumeRoleWithWebIdentitySessionPolicy(t *testing.T) {
iamManager := setupIntegratedIAMSystem(t)
ctx := context.Background()
validJWTToken := createTestJWT(t, "https://test-issuer.com", "test-user-123", "test-signing-key")
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::test-bucket/allowed/*"]}]}`
assumeRequest := &sts.AssumeRoleWithWebIdentityRequest{
RoleArn: "arn:aws:iam::role/S3ReadOnlyRole",
WebIdentityToken: validJWTToken,
RoleSessionName: "policy-web-identity",
Policy: &sessionPolicy,
}
response, err := iamManager.AssumeRoleWithWebIdentity(ctx, assumeRequest)
require.NoError(t, err)
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
Principal: response.AssumedRoleUser.Arn,
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/allowed/file.txt",
SessionToken: response.Credentials.SessionToken,
})
require.NoError(t, err)
assert.True(t, allowed, "Session policy should allow GetObject within allowed prefix")
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
Principal: response.AssumedRoleUser.Arn,
Action: "s3:GetObject",
Resource: "arn:aws:s3:::test-bucket/other/file.txt",
SessionToken: response.Credentials.SessionToken,
})
require.NoError(t, err)
assert.False(t, allowed, "Session policy should deny GetObject outside allowed prefix")
}
// TestAssumeRoleWithCredentialsSessionPolicy verifies Policy downscoping is applied to credentials sessions.
func TestAssumeRoleWithCredentialsSessionPolicy(t *testing.T) {
iamManager := setupIntegratedIAMSystem(t)
ctx := context.Background()
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["filer:CreateEntry"],"Resource":["arn:aws:filer::path/user-docs/allowed/*"]}]}`
assumeRequest := &sts.AssumeRoleWithCredentialsRequest{
RoleArn: "arn:aws:iam::role/LDAPUserRole",
Username: "testuser",
Password: "testpass",
RoleSessionName: "policy-ldap",
ProviderName: "test-ldap",
Policy: &sessionPolicy,
}
response, err := iamManager.AssumeRoleWithCredentials(ctx, assumeRequest)
require.NoError(t, err)
allowed, err := iamManager.IsActionAllowed(ctx, &ActionRequest{
Principal: response.AssumedRoleUser.Arn,
Action: "filer:CreateEntry",
Resource: "arn:aws:filer::path/user-docs/allowed/file.txt",
SessionToken: response.Credentials.SessionToken,
})
require.NoError(t, err)
assert.True(t, allowed, "Session policy should allow CreateEntry within allowed prefix")
allowed, err = iamManager.IsActionAllowed(ctx, &ActionRequest{
Principal: response.AssumedRoleUser.Arn,
Action: "filer:CreateEntry",
Resource: "arn:aws:filer::path/user-docs/other/file.txt",
SessionToken: response.Credentials.SessionToken,
})
require.NoError(t, err)
assert.False(t, allowed, "Session policy should deny CreateEntry outside allowed prefix")
}
// TestSessionExpiration tests session expiration and cleanup
func TestSessionExpiration(t *testing.T) {
iamManager := setupIntegratedIAMSystem(t)
+129 -39
View File
@@ -7,10 +7,12 @@ import (
"fmt"
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
"github.com/seaweedfs/seaweedfs/weed/iam/providers"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/iam/utils"
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
)
// maxPoliciesForEvaluation defines an upper bound on the number of policies that
@@ -23,6 +25,7 @@ type IAMManager struct {
stsService *sts.STSService
policyEngine *policy.PolicyEngine
roleStore RoleStore
userStore UserStore
filerAddressProvider func() string // Function to get current filer address
initialized bool
}
@@ -48,6 +51,11 @@ type RoleStoreConfig struct {
StoreConfig map[string]interface{} `json:"storeConfig,omitempty"`
}
// UserStore defines the interface for retrieving IAM user policy attachments.
type UserStore interface {
GetUser(ctx context.Context, username string) (*iam_pb.Identity, error)
}
// RoleDefinition defines a role with its trust policy and attached policies
type RoleDefinition struct {
// RoleName is the name of the role
@@ -92,6 +100,11 @@ func NewIAMManager() *IAMManager {
return &IAMManager{}
}
// SetUserStore assigns the user store used to resolve IAM user policy attachments.
func (m *IAMManager) SetUserStore(store UserStore) {
m.userStore = store
}
// Initialize initializes the IAM manager with all components
func (m *IAMManager) Initialize(config *IAMConfig, filerAddressProvider func() string) error {
if config == nil {
@@ -310,12 +323,30 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
return false, fmt.Errorf("IAM manager not initialized")
}
// Validate session token if present (skip for OIDC tokens which are already validated,
// and skip for empty tokens which represent static access keys)
if request.SessionToken != "" && !isOIDCToken(request.SessionToken) {
_, err := m.stsService.ValidateSessionToken(ctx, request.SessionToken)
if err != nil {
return false, fmt.Errorf("invalid session: %w", err)
// Validate session token if present
// We always try to validate with the internal STS service first if it's a SeaweedFS token.
// This ensures that session policies embedded in the token are correctly extracted and enforced.
var sessionInfo *sts.SessionInfo
if request.SessionToken != "" {
// Parse unverified to check issuer
parsed, _, err := new(jwt.Parser).ParseUnverified(request.SessionToken, jwt.MapClaims{})
isInternal := false
if err == nil {
if claims, ok := parsed.Claims.(jwt.MapClaims); ok {
if issuer, ok := claims["iss"].(string); ok && m.stsService != nil && m.stsService.Config != nil {
if issuer == m.stsService.Config.Issuer {
isInternal = true
}
}
}
}
if isInternal || !isOIDCToken(request.SessionToken) {
var err error
sessionInfo, err = m.stsService.ValidateSessionToken(ctx, request.SessionToken)
if err != nil {
return false, fmt.Errorf("invalid session: %w", err)
}
}
}
@@ -334,7 +365,17 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
// Add principal to context for policy matching
// The PolicyEngine checks RequestContext["principal"] or RequestContext["aws:PrincipalArn"]
evalCtx.RequestContext["principal"] = request.Principal
evalCtx.RequestContext["aws:PrincipalArn"] = request.Principal
evalCtx.RequestContext["aws:PrincipalArn"] = request.Principal // AWS standard key
// Check if this is an admin request - bypass policy evaluation if so
// This mirrors the logic in auth_signature_v4.go but applies it at authorization time
isAdmin := false
if request.RequestContext != nil {
if val, ok := request.RequestContext["is_admin"].(bool); ok && val {
isAdmin = true
}
// Print full request context for debugging
}
// Parse principal ARN to extract details for context variables (e.g. ${aws:username})
arnInfo := utils.ParsePrincipalARN(request.Principal)
@@ -349,6 +390,9 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
evalCtx.RequestContext["aws:username"] = awsUsername
evalCtx.RequestContext["aws:userid"] = arnInfo.RoleName
} else if userName := utils.ExtractUserNameFromPrincipal(request.Principal); userName != "" {
evalCtx.RequestContext["aws:username"] = userName
evalCtx.RequestContext["aws:userid"] = userName
}
if arnInfo.AccountID != "" {
evalCtx.RequestContext["aws:PrincipalAccount"] = arnInfo.AccountID
@@ -364,58 +408,83 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest
}
}
// If explicit policy names are provided (e.g. from user identity), evaluate them directly
if len(request.PolicyNames) > 0 {
var baseResult *policy.EvaluationResult
var err error
if isAdmin {
// Admin always has base access allowed
baseResult = &policy.EvaluationResult{Effect: policy.EffectAllow}
} else {
policies := request.PolicyNames
if len(policies) == 0 {
// Extract role name from principal ARN
roleName := utils.ExtractRoleNameFromPrincipal(request.Principal)
if roleName == "" {
userName := utils.ExtractUserNameFromPrincipal(request.Principal)
if userName == "" {
return false, fmt.Errorf("could not extract role from principal: %s", request.Principal)
}
if m.userStore == nil {
return false, fmt.Errorf("user store unavailable for principal: %s", request.Principal)
}
user, err := m.userStore.GetUser(ctx, userName)
if err != nil || user == nil {
return false, fmt.Errorf("user not found for principal: %s (user=%s)", request.Principal, userName)
}
policies = user.GetPolicyNames()
} else {
// Get role definition
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return false, fmt.Errorf("role not found: %s", roleName)
}
policies = roleDef.AttachedPolicies
}
}
if bucketPolicyName != "" {
// Enforce an upper bound on the number of policies to avoid excessive allocations
if len(policies) >= maxPoliciesForEvaluation {
return false, fmt.Errorf("too many policies for evaluation: %d >= %d", len(policies), maxPoliciesForEvaluation)
}
// Create a new slice to avoid modifying the request and append the bucket policy
// Create a new slice to avoid modifying the original and append the bucket policy
copied := make([]string, len(policies))
copy(copied, policies)
policies = append(copied, bucketPolicyName)
}
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
baseResult, err = m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
if err != nil {
return false, fmt.Errorf("policy evaluation failed: %w", err)
}
return result.Effect == policy.EffectAllow, nil
}
// Extract role name from principal ARN
roleName := utils.ExtractRoleNameFromPrincipal(request.Principal)
if roleName == "" {
return false, fmt.Errorf("could not extract role from principal: %s", request.Principal)
// Base policy must allow; if it doesn't, deny immediately (session policy can only further restrict)
if baseResult.Effect != policy.EffectAllow {
return false, nil
}
// Get role definition
roleDef, err := m.roleStore.GetRole(ctx, m.getFilerAddress(), roleName)
if err != nil {
return false, fmt.Errorf("role not found: %s", roleName)
}
// Evaluate policies attached to the role
policies := roleDef.AttachedPolicies
if bucketPolicyName != "" {
// Enforce an upper bound on the number of policies to avoid excessive allocations
if len(policies) >= maxPoliciesForEvaluation {
return false, fmt.Errorf("too many policies for evaluation: %d >= %d", len(policies), maxPoliciesForEvaluation)
// If there's a session policy, it must also allow the action
if sessionInfo != nil && sessionInfo.SessionPolicy != "" {
var sessionPolicy policy.PolicyDocument
if err := json.Unmarshal([]byte(sessionInfo.SessionPolicy), &sessionPolicy); err != nil {
return false, fmt.Errorf("invalid session policy JSON: %w", err)
}
if err := policy.ValidatePolicyDocument(&sessionPolicy); err != nil {
return false, fmt.Errorf("invalid session policy document: %w", err)
}
sessionResult, err := m.policyEngine.EvaluatePolicyDocument(ctx, evalCtx, "session-policy", &sessionPolicy, policy.EffectDeny)
if err != nil {
return false, fmt.Errorf("session policy evaluation failed: %w", err)
}
if sessionResult.Effect != policy.EffectAllow {
// Session policy does not allow this action
return false, nil
}
// Create a new slice to avoid modifying the role definition and append the bucket policy
copied := make([]string, len(policies))
copy(copied, policies)
policies = append(copied, bucketPolicyName)
}
result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, policies)
if err != nil {
return false, fmt.Errorf("policy evaluation failed: %w", err)
}
return result.Effect == policy.EffectAllow, nil
return true, nil
}
// ValidateTrustPolicy validates if a principal can assume a role (for testing)
@@ -643,7 +712,28 @@ func isOIDCToken(token string) bool {
}
// JWT tokens typically start with "eyJ" (base64 encoded JSON starting with "{")
return strings.HasPrefix(token, "eyJ")
if !strings.HasPrefix(token, "eyJ") {
return false
}
parsed, _, err := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return false
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return false
}
if typ, ok := claims["typ"].(string); ok && typ == sts.TokenTypeSession {
return false
}
if typ, ok := claims[sts.JWTClaimTokenType].(string); ok && typ == sts.TokenTypeSession {
return false
}
return true
}
// TrustPolicyValidator interface implementation
+62
View File
@@ -474,6 +474,68 @@ func (e *PolicyEngine) EvaluateTrustPolicy(ctx context.Context, trustPolicy *Pol
return result, nil
}
// EvaluatePolicyDocument evaluates a single policy document without storing it.
// defaultEffect controls the fallback result when no statements match.
func (e *PolicyEngine) EvaluatePolicyDocument(ctx context.Context, evalCtx *EvaluationContext, policyName string, policyDoc *PolicyDocument, defaultEffect Effect) (*EvaluationResult, error) {
if !e.initialized {
return nil, fmt.Errorf("policy engine not initialized")
}
if evalCtx == nil {
return nil, fmt.Errorf("evaluation context cannot be nil")
}
if policyDoc == nil {
return nil, fmt.Errorf("policy document cannot be nil")
}
if policyName == "" {
policyName = "inline-policy"
}
result := &EvaluationResult{
Effect: defaultEffect,
EvaluationDetails: &EvaluationDetails{
Principal: evalCtx.Principal,
Action: evalCtx.Action,
Resource: evalCtx.Resource,
PoliciesEvaluated: []string{policyName},
},
}
var matchingStatements []StatementMatch
explicitDeny := false
hasAllow := false
for _, statement := range policyDoc.Statement {
if e.statementMatches(&statement, evalCtx) {
match := StatementMatch{
PolicyName: policyName,
StatementSid: statement.Sid,
Effect: Effect(statement.Effect),
Reason: "Action, Resource, and Condition matched",
}
matchingStatements = append(matchingStatements, match)
if statement.Effect == "Deny" {
explicitDeny = true
} else if statement.Effect == "Allow" {
hasAllow = true
}
}
}
result.MatchingStatements = matchingStatements
if explicitDeny {
result.Effect = EffectDeny
} else if hasAllow {
result.Effect = EffectAllow
}
return result, nil
}
// statementMatches checks if a statement matches the evaluation context
func (e *PolicyEngine) statementMatches(statement *Statement, evalCtx *EvaluationContext) bool {
// Check principal match (for trust policies)
+68
View File
@@ -0,0 +1,68 @@
package sts
import (
"encoding/base64"
"encoding/hex"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestTemporaryCredentialPrefix verifies that temporary credentials use ASIA prefix
// (not AKIA which is for permanent IAM user credentials)
func TestTemporaryCredentialPrefix(t *testing.T) {
sessionId := "test-session-for-prefix"
expiration := time.Now().Add(time.Hour)
credGen := NewCredentialGenerator()
cred, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
assert.NoError(t, err)
assert.NotNil(t, cred)
// Verify ASIA prefix for temporary credentials
assert.True(t, strings.HasPrefix(cred.AccessKeyId, "ASIA"),
"Temporary credentials must use ASIA prefix, got: %s", cred.AccessKeyId)
// Verify it's NOT using AKIA (permanent credentials)
assert.False(t, strings.HasPrefix(cred.AccessKeyId, "AKIA"),
"Temporary credentials must NOT use AKIA prefix (that's for permanent IAM keys)")
}
// TestTemporaryCredentialFormat verifies the full format of temporary credentials
func TestTemporaryCredentialFormat(t *testing.T) {
sessionId := "format-test-session"
expiration := time.Now().Add(time.Hour)
credGen := NewCredentialGenerator()
cred, err := credGen.GenerateTemporaryCredentials(sessionId, expiration)
assert.NoError(t, err)
assert.NotNil(t, cred)
// AWS temporary access key format: ASIA + 16 hex characters = 20 chars total
assert.Equal(t, 20, len(cred.AccessKeyId),
"Access key ID should be 20 characters (ASIA + 16 hex chars)")
// Verify it starts with ASIA
assert.True(t, strings.HasPrefix(cred.AccessKeyId, "ASIA"),
"Access key must start with ASIA prefix")
// Verify the rest is hex (after ASIA prefix)
hexPart := cred.AccessKeyId[4:]
assert.Equal(t, 16, len(hexPart), "Hex part should be 16 characters")
_, err = hex.DecodeString(hexPart)
assert.NoError(t, err, "The part after ASIA prefix should be valid hex")
// Verify secret key is not empty and is a valid base64-encoded SHA256 hash
assert.NotEmpty(t, cred.SecretAccessKey)
assert.Equal(t, 44, len(cred.SecretAccessKey),
"SecretAccessKey should be 44 characters for a base64-encoded 32-byte hash")
_, err = base64.StdEncoding.DecodeString(cred.SecretAccessKey)
assert.NoError(t, err, "SecretAccessKey should be a valid base64 string")
// Verify session token is not empty
assert.NotEmpty(t, cred.SessionToken)
}
+9
View File
@@ -31,6 +31,8 @@ type STSSessionClaims struct {
// Authorization data
Policies []string `json:"pol,omitempty"` // policies (abbreviated)
// SessionPolicy contains inline session policy JSON (optional)
SessionPolicy string `json:"spol,omitempty"`
// Identity provider information
IdentityProvider string `json:"idp"` // identity_provider
@@ -88,6 +90,7 @@ func (c *STSSessionClaims) ToSessionInfo() *SessionInfo {
AssumedRoleUser: c.AssumedRole,
Principal: c.Principal,
Policies: c.Policies,
SessionPolicy: c.SessionPolicy,
ExpiresAt: expiresAt,
IdentityProvider: c.IdentityProvider,
ExternalUserId: c.ExternalUserId,
@@ -148,6 +151,12 @@ func (c *STSSessionClaims) WithPolicies(policies []string) *STSSessionClaims {
return c
}
// WithSessionPolicy sets the inline session policy JSON for this session
func (c *STSSessionClaims) WithSessionPolicy(policy string) *STSSessionClaims {
c.SessionPolicy = policy
return c
}
// WithIdentityProvider sets identity provider information
func (c *STSSessionClaims) WithIdentityProvider(providerName, externalUserId, providerIssuer string) *STSSessionClaims {
c.IdentityProvider = providerName
+3
View File
@@ -89,6 +89,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
expiresAt := time.Now().Add(2 * time.Hour)
policies := []string{"policy1", "policy2"}
sessionPolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::bucket/*"]}]}`
requestContext := map[string]interface{}{
"sourceIp": "192.168.1.1",
"userAgent": "test-agent",
@@ -99,6 +100,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
WithRoleInfo("role-arn", "assumed-role", "principal").
WithIdentityProvider("provider", "external-id", "issuer").
WithPolicies(policies).
WithSessionPolicy(sessionPolicy).
WithRequestContext(requestContext).
WithMaxDuration(2 * time.Hour)
@@ -114,6 +116,7 @@ func TestSTSSessionClaimsToSessionInfoPreservesAllFields(t *testing.T) {
assert.Equal(t, "external-id", sessionInfo.ExternalUserId)
assert.Equal(t, "issuer", sessionInfo.ProviderIssuer)
assert.Equal(t, policies, sessionInfo.Policies)
assert.Equal(t, sessionPolicy, sessionInfo.SessionPolicy)
assert.Equal(t, requestContext, sessionInfo.RequestContext)
assert.WithinDuration(t, expiresAt, sessionInfo.ExpiresAt, 1*time.Second)
}

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